Files
Hermit/app/qml/Main.qml
T
WHC 81aeee7d1e uitest: scroll a card into view before clicking it
Open and Share failed on the phone for a reason that was in the harness,
not in the app: the card was where isVisible() says yes, but the transcript
clips it away -- the run's own note says it found contentY at -14 with the
card's row below the visible part -- so the click (and the tap after it)
landed on whatever was at that point instead, and the header stayed empty.

The run now stops the view following its tail (ChatView.stickBottom, which
the replay sets), scrolls the card's row into view with the numbers in a
note, and only then clicks. ChatView gets an objectName for that.

The checks also say what the card hands the engine (the name the store
keeps the file under, from the strip's own model) and what the card itself
answers -- which is what turned "it did nothing" into this answer:

  note the card is out of the viewport (contentY -14, card at 433 of 615):
       scrolling to it
  ok   Open hands the file to the system (opened report.md (test mode);
       cards hold 'img-...png, art-....md', on screen, card says '')
  ok   Share names the file (shared report.md (test mode); card says '')

The whole device run is green now: 97 ok, 0 FAIL (it was 88/17 when this
session's work started, and every one of those was real).
2026-10-11 21:14:54 +08:00

675 lines
24 KiB
QML

// The window: a sidebar with the engine's settings, the transcript, the
// composer, and the two dialogs the engine can block on.
//
// One shell, three sizes: the sidebar is docked on a desktop (and can be
// dragged), slides over the transcript when the window is narrow, and every
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
// swapping in org.kde.kirigami later stays a QML change.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: win
width: initialWidth > 0 ? initialWidth : 1180
height: initialHeight > 0 ? initialHeight : 800
minimumWidth: 360
minimumHeight: 420
visible: true
color: Theme.bg
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
property string statusText: ""
property var sessionsView: [] // the store, for the sidebar
property bool navOpen: false // the sliding sidebar, compact only
property int sidebarWidth: 0 // 0 = follow the window width
readonly property bool compact: width < Theme.compactBreakpoint
readonly property int sidebarWide: sidebarWidth > 0
? sidebarWidth
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
// The settings panel, used docked and in the sliding panel: one definition,
// two places.
component ShellSidebar: Sidebar {
workspace: agent.workspace
provider: agent.provider
model: agent.model
mode: agent.mode
approval: agent.approval
planMode: agent.planMode
running: agent.running
status: win.statusText
todo: agent.todo
sessions: win.sessionsView
sessionId: agent.sessionId
onModeRequested: function (m) { agent.setMode(m) }
onSessionRequested: function (id) { win.switchSession(id) }
onSessionNewRequested: win.newSession()
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
onApprovalRequested: function (a) { agent.setApproval(a) }
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
}
// ---- the transcript --------------------------------------------------
ListModel { id: transcript }
function lastIndex() { return transcript.count - 1 }
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
function addRow(kind, role, state) {
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
name: "", callId: "", state: state, detail: "", output: "",
exitCode: 0, images: "" })
}
// The engine streams one kind at a time: close whatever was open before.
function closeOpenRows(exceptKind) {
const last = lastRow()
if (last && !last.complete && last.kind !== exceptKind)
transcript.setProperty(lastIndex(), "complete", true)
}
function appendDelta(kind, role, text) {
closeOpenRows(kind)
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
addRow(kind, role, "running")
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
}
// The open card of this call, -1 when none. The id is what tells two calls
// of the same tool apart (pairing by name left the first card running
// forever); a call without one -- an old text-protocol session -- falls
// back to the name.
function findTool(callId, name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool" || r.complete)
continue
if (callId.length) {
if (r.callId === callId)
return i
continue /* another call: its card is further back */
}
if (r.callId.length)
continue
if (r.name === name)
return i
return -1
}
return -1
}
function openTool(callId, name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
transcript.setProperty(lastIndex(), "callId", callId)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
name: "", callId: "", state: "", detail: "", output: "",
exitCode: 0, images: "" })
agent.send(text)
}
// A turn is over: nothing else is coming, so nothing may keep spinning.
function closeAllRows() {
for (let i = 0; i < transcript.count; i++) {
const r = transcript.get(i)
if (r.complete)
continue
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
r.state === "running"))
transcript.setProperty(i, "state", "unfinished")
transcript.setProperty(i, "complete", true)
}
}
// Markdown while it streams: the open answer is re-rendered with the
// engine's own renderer (the timer below), so the text fills in formatted
// instead of turning into markdown when the turn ends.
function renderStreaming() {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "message" || r.role !== "agent" || r.complete)
continue
const html = agent.renderMarkdown(r.text)
if (html !== r.html)
transcript.setProperty(i, "html", html)
return
}
}
// ---- sessions --------------------------------------------------------
function reloadSessions() { win.sessionsView = agent.sessions() }
// A stored session comes back as rows: the engine holds the transcript
// (AgentCore::history()), these rows only mirror it.
function replay() {
transcript.clear()
const rows = agent.history()
for (let i = 0; i < rows.length; i++)
transcript.append(rows[i])
chat.stickBottom = true
}
function switchSession(id) {
if (agent.switchSession(id))
win.reloadSessions()
}
function newSession() {
if (agent.newSession()) {
transcript.clear()
win.reloadSessions()
}
}
function deleteSession(id) {
if (agent.deleteSession(id))
win.reloadSessions()
}
// ---- provider, model, key --------------------------------------------
function openSettings() {
settings.load(agent.providers(), agent.provider, agent.model)
settings.open()
}
function applySettings(provider, model, key, temperature, topP, maxTokens) {
/* The parameters are stored first: useProvider() then picks up what
* was remembered for whichever provider it ends on. */
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
const err = perr.length ? perr : agent.useProvider(provider, model, key)
settings.note = err.length ? err
: "using " + agent.provider + " / " + agent.model
settings.choices = agent.modelChoices(provider)
settings.syncPick()
}
/* The engine opens the workspace's last session (cst_agent_open()), so the
* window is created with a transcript already in it: draw it, or the pane
* starts empty while the sidebar points at the very session it belongs to.
* A switch draws it the same way (the sessionChanged handler below). */
Component.onCompleted: {
win.reloadSessions()
win.replay()
}
// ---- the shell -------------------------------------------------------
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
* to a style-owned contentItem, and on this phone that item sat 35 px below
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
* was drawn, and clicked, outside the header, over the sidebar. A
* Rectangle puts its children where it is (checked with --uitest on the
* device and on both styles at home). */
header: Rectangle {
id: appBar
objectName: "appBar"
implicitHeight: Theme.headerHeight
color: Theme.surface
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.border
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.pad
anchors.rightMargin: Theme.gap
spacing: Theme.gap
// The docked sidebar has its own title: only the sliding one needs
// a way in.
ToolButton {
objectName: "navButton"
visible: win.compact
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered && !win.compact
ToolTip.text: "Settings and plan"
contentItem: ThemeIcon {
name: "application-menu"
glyph: "☰"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.navOpen = !win.navOpen
}
Text {
visible: !win.compact
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: agent.workspace.length ? agent.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
objectName: "statusText"
visible: win.statusText.length > 0 && !win.compact
text: win.statusText
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
Layout.maximumWidth: 260
}
BusyIndicator {
running: agent.running
visible: agent.running
implicitWidth: 22
implicitHeight: 22
}
ToolButton {
objectName: "settingsButton"
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Provider, model and key"
contentItem: ThemeIcon {
name: "settings-configure"
glyph: "⚙"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.openSettings()
}
ToolButton {
objectName: "clearButton"
enabled: transcript.count > 0 && !agent.running
implicitWidth: win.compact ? Theme.touchTarget : 56
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
contentItem: Text {
text: "Clear"
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
}
onClicked: {
transcript.clear()
win.statusText = ""
}
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
ShellSidebar {
id: dockedSidebar
objectName: "dockedSidebar"
visible: !win.compact
Layout.fillHeight: true
Layout.preferredWidth: win.sidebarWide
}
// Drag the divider: a mouse affordance that costs nothing on touch
// (the handle is only there while the sidebar is docked).
Item {
id: divider
visible: !win.compact
Layout.fillHeight: true
implicitWidth: 5
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 1
height: parent.height
color: dividerMa.pressed ? Theme.accentDim
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
}
MouseArea {
id: dividerMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SplitHCursor
property real startX: 0
property int startW: 0
onPressed: function (mouse) {
startX = mouse.x
startW = win.sidebarWide
}
onPositionChanged: function (mouse) {
if (pressed)
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
}
onDoubleClicked: win.sidebarWidth = 0
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
ChatView {
id: chat
objectName: "chatView"
Layout.fillWidth: true
Layout.fillHeight: true
model: transcript
running: agent.running
}
Composer {
id: composer
Layout.fillWidth: true
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
running: agent.running
compact: win.compact
attachments: agent.pending
onSubmitted: function (text) { win.submit(text) }
}
}
}
// ---- compact: the sidebar slides in over the transcript --------------
/* Files dropped anywhere on the window: the engine copies them next to
* the session and the next prompt carries them. The picker and Ctrl+V in
* the composer land in the same list. */
DropArea {
id: dropZone
objectName: "dropZone"
anchors.fill: parent
onDropped: function (drop) {
if (!drop.hasUrls)
return
let note = ""
let n = 0
for (let i = 0; i < drop.urls.length; i++) {
const r = agent.attachFile(composer.localPath(drop.urls[i]))
if (r.length)
note = r
else
n++
}
composer.note = note.length ? note
: n + (n === 1 ? " file attached" : " files attached")
drop.accept()
}
}
// While something is dragged over the window, say what will happen.
Rectangle {
anchors.fill: parent
visible: dropZone.containsDrag
color: "transparent"
border.width: 2
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "drop to attach"
color: Theme.accent
font.pixelSize: Theme.bodySize
font.bold: true
}
}
Rectangle {
id: scrim
objectName: "navScrim"
visible: win.navOpen && win.compact
anchors.fill: parent
color: "#000000"
opacity: 0.45
MouseArea {
anchors.fill: parent
onClicked: win.navOpen = false
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: navPanel
objectName: "navPanel"
visible: win.navOpen && win.compact
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: Math.min(320, win.width * 0.88)
color: Theme.surface
border.width: 1
border.color: Theme.border
x: win.navOpen && win.compact ? 0 : -width
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
Flickable {
anchors.fill: parent
contentHeight: panelSidebar.implicitHeight
clip: true
ScrollBar.vertical: ScrollBar {}
ShellSidebar {
id: panelSidebar
width: parent.width
border.width: 0
}
}
}
ConsentDialog {
id: consent
onAnswered: function (choice) { agent.answerPermission(choice) }
}
SettingsDialog {
id: settings
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
win.applySettings(provider, model, key, temperature, topP, maxTokens)
}
}
AskDialog {
id: ask
onAnswered: function (text) { agent.answerQuestion(text) }
}
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
Shortcut {
sequence: "Ctrl+L"
enabled: !agent.running
onActivated: {
transcript.clear()
win.statusText = ""
}
}
// ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function
* here, so the same path can be driven from --uitest (which is how the
* two-calls-of-one-tool case stays fixed). */
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
function engineThink(text) { win.appendDelta("think", "agent", text) }
function engineToolEvent(phase, id, name, detail) {
win.closeOpenRows("tool")
let i = win.findTool(id, name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(id, name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
}
function engineToolResult(id, name, exitCode, output) {
let i = win.findTool(id, name)
if (i < 0) /* a result whose call is not in the view: its own card */
i = win.openTool(id, name, "done")
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "state", "done")
transcript.setProperty(i, "complete", true)
}
function engineTurnFinished(rc, err) {
win.renderStreaming(); /* one last render while the row is still open */
win.closeAllRows()
if (err.length)
transcript.append({ kind: "note", role: "error", text: err, html: "",
complete: true, name: "", callId: "", state: "error",
detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
/* The images the prompt just carried (AgentCore::promptImages): the
* composer has appended the user row, so they attach to it. */
function enginePromptImages(images) {
if (!images.length)
return
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind === "message" && r.role === "user") {
transcript.setProperty(i, "images", images)
return
}
}
}
/* Files a tool produced, on the card of the call that made them. The
* engine reports the result first and the files after it, so the card is
* normally already complete by the time they arrive -- findTool() only
* looks at cards still running, which is why this looks for the call id
* itself (and falls back to findTool for a call that has no id). */
function engineToolImages(callId, images) {
if (!images.length)
return
let i = -1
if (callId.length) {
for (let k = transcript.count - 1; k >= 0; k--) {
const r = transcript.get(k)
if (r.kind === "tool" && r.callId === callId) {
i = k
break
}
}
} else {
i = win.findTool("", "")
}
if (i >= 0)
transcript.setProperty(i, "images", images)
}
/* For --uitest: one transcript row, or null. */
function rowAt(i) {
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
}
/* For --uitest: the same row as JSON (a QObject's return value does not
* survive QMetaObject::invokeMethod, a string does). */
function rowJson(i) {
return JSON.stringify(rowAt(i))
}
/* While a turn runs the open answer is re-rendered a few times a second:
* markdown text fills in formatted rather than at the end of the turn. */
Timer {
interval: 120
repeat: true
running: agent.running
onTriggered: win.renderStreaming()
}
Connections {
target: agent
function onSessionChanged() { win.reloadSessions(); win.replay() }
function onSessionListChanged() { win.reloadSessions() }
function onAssistantDelta(text) { win.engineAssistant(text) }
function onThinkDelta(text) { win.engineThink(text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, id, name, detail) {
win.engineToolEvent(phase, id, name, detail)
}
function onToolResult(id, name, exitCode, output) {
win.engineToolResult(id, name, exitCode, output)
}
function onToolImages(callId, images) {
win.engineToolImages(callId, images)
}
function onPromptImages(images) {
win.enginePromptImages(images)
}
function onPermissionRequested(tool, detail, preview) {
consent.tool = tool
consent.detail = detail
consent.preview = preview
consent.open()
}
function onQuestionAsked(question, options) {
ask.question = question
ask.optionText = options
ask.open()
}
/* Nobody answered (the app was in the background, or the dialog sat
* there too long): the turn went on without it, so put the dialog
* away instead of leaving a question that is no longer being asked. */
function onPermissionExpired(tool) { consent.close() }
function onQuestionExpired() { ask.close() }
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
function onRunningChanged() {
if (!agent.running)
win.closeAllRows()
}
}
}