Files
CST-agent/app/qml/ChatView.qml
T
WHC f5d340bd2a v0.8.9: sessions in the Qt front end, transcript replay, UI test helpers
A conversation is now something the window can pick. The sidebar lists the
workspace's stored sessions and switching replays one into the view.

Sessions (app/src/core/AgentCore.*, app/qml/SessionRow.qml, Sidebar.qml):
- AgentCore::open() resolves the workspace's current session through
  cst_agent_store_resolve_default() before the engine starts. Without it the
  engine opened session.jsonl in the process's working directory, next to a
  session list that stayed empty.
- sessionId property; sessions() (id, title, firstUser, messages, mtime in ms,
  current); newSession(), switchSession(), deleteSession() (moves the engine
  off a session it has open first), renameSession(); sessionChanged and
  sessionListChanged signals. No mutation runs during a turn.
- history() turns the open session back into the rows the live signals build
  (kind/role/text/name/state/detail/output/exitCode/complete): system prompt
  skipped, notes and attachments shown, assistant reasoning as a think row,
  tool_calls_json as one card per call, and each result paired with its call
  the way findTool() pairs the live stream. A call with no result keeps state
  "unfinished" ("no result"), so a replay never shows a spinner that cannot
  stop. Main.qml replays on sessionChanged.
- SessionRow.qml is a file of its own: as an inline component it would not see
  the Repeater's modelData. Session controls are disabled while a turn runs.

Tests (app/src/ui/UiTest.cpp, app/src/main.cpp):
- the fixture session is written through the store's own API (a tool call with
  its result), then the checks click its row, assert the five replayed rows and
  the paired result, start two new sessions (the view empties), delete one by
  its button (the open one stays), and switch back. 28 checks in total.
- item() falls back to the visual tree: delegates a model owns are not always
  in the window's QObject tree, which is why the rows were invisible to
  findChild even though they were on screen.
- --uitest drives a scratch workspace unless --workspace is given; it creates
  and deletes sessions. CST_APP_SHOT=file saves the window as a PNG when the
  run ends and CST_APP_DUMP=1 prints the session rows seen; both are how the
  layout gets looked at on a machine without a screen.

QML: ThinkBlock's click target is a TapHandler on its row (a MouseArea anchored
inside a layout is undefined behavior and Qt said so).

Verified: 28/28 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of a replayed session (cards, tool card with its result, the
think block, the sidebar list).

Version 0.8.9.
2026-10-11 12:14:59 +08:00

136 lines
4.5 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The transcript: cards for the messages, a card per tool call, folded
// thinking blocks and one-line notes. Follows the tail of the conversation
// until the user scrolls away from it.
Item {
id: view
property var model: null
property bool running: false
ListView {
id: list
objectName: "transcript"
anchors.fill: parent
anchors.leftMargin: Theme.gapL
anchors.rightMargin: Theme.gapL
model: view.model
clip: true
spacing: Theme.gap
topMargin: Theme.gapL
bottomMargin: Theme.gapL
cacheBuffer: 2000
boundsBehavior: Flickable.StopAtBounds
// The delegate picks the card by row kind; the components below read
// the ListModel roles directly, which is why they are declared in the
// delegate's scope.
delegate: Loader {
id: rowLoader
width: list.width
sourceComponent: model.kind === "tool" ? toolComp
: model.kind === "think" ? thinkComp
: model.kind === "note" ? noteComp : msgComp
Component {
id: msgComp
MessageCard {
role: model.role
text: model.text
complete: model.complete
html: (model.role === "agent" || model.role === "error") &&
(model.complete || model.text.length > 12000) &&
model.text.length > 0
? agent.renderMarkdown(model.text) : ""
}
}
Component {
id: thinkComp
ThinkBlock {
text: model.text
complete: model.complete
}
}
Component {
id: toolComp
ToolCard {
name: model.name
state: model.state
detail: model.detail
output: model.output
exitCode: model.exitCode
hasResult: model.complete
}
}
Component {
id: noteComp
NoteLine {
text: model.text
level: model.state === "warn" || model.state === "error" ? model.state : "info"
}
}
}
onMovementEnded: view.stickBottom = atYEnd
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
}
// Follow the tail unless the user asked otherwise.
property bool stickBottom: true
// Empty state: what this window is and what to do with it.
ColumnLayout {
anchors.centerIn: parent
width: Math.min(parent.width - 80, 520)
spacing: Theme.gap
visible: view.model ? view.model.count === 0 : true
Text {
Layout.alignment: Qt.AlignHCenter
text: "CST-agent"
color: Theme.text
font.pixelSize: 22
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "The engine runs in this window — no server, no second process."
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Text {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Theme.gap
text: "Ask for a change, a command to run, a file to explain.\n" +
"Every write, delete and command asks first: Deny / Once / Always.\n" +
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
// Jump back to the live end of the conversation.
RoundButton {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.padL
width: Theme.jumpButton
height: Theme.jumpButton
visible: !view.stickBottom && list.contentHeight > list.height
text: "↓"
onClicked: {
view.stickBottom = true
list.positionViewAtEnd()
}
}
}