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.
This commit is contained in:
WHC
2026-10-11 12:14:59 +08:00
parent da812cc7e8
commit f5d340bd2a
13 changed files with 691 additions and 13 deletions
+1
View File
@@ -12,6 +12,7 @@ Item {
ListView {
id: list
objectName: "transcript"
anchors.fill: parent
anchors.leftMargin: Theme.gapL
anchors.rightMargin: Theme.gapL
+41
View File
@@ -22,6 +22,7 @@ ApplicationWindow {
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
@@ -41,7 +42,12 @@ ApplicationWindow {
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) }
}
@@ -98,6 +104,38 @@ ApplicationWindow {
agent.send(text)
}
// ---- 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()
}
Component.onCompleted: win.reloadSessions()
// ---- the shell -------------------------------------------------------
header: ToolBar {
id: appBar
@@ -335,6 +373,9 @@ ApplicationWindow {
Connections {
target: agent
function onSessionChanged() { win.reloadSessions(); win.replay() }
function onSessionListChanged() { win.reloadSessions() }
function onAssistantDelta(text) { win.appendDelta("message", "agent", text) }
function onThinkDelta(text) { win.appendDelta("think", "agent", text) }
+106
View File
@@ -0,0 +1,106 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One stored session in the sidebar: its name (a rename, else its first
// prompt), how much is in it and when it was last written, and a way to drop
// it. The open one is marked. A file of its own because the sidebar lists them
// through a Repeater -- an inline component would not see modelData there.
Rectangle {
id: srow
property var entry: null
readonly property bool open: entry !== null && entry.current === true
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
implicitHeight: rowCol.implicitHeight + 8
radius: Theme.radiusSm
color: srow.open ? Theme.accentDim
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
border.width: 1
border.color: srow.open ? Theme.accent : "transparent"
signal activated(string id)
signal removeRequested(string id)
// "3h ago"; the entries carry ms since the epoch.
function ago(ms) {
if (!ms)
return "never"
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60)
return "just now"
if (s < 3600)
return Math.floor(s / 60) + "m ago"
if (s < 86400)
return Math.floor(s / 3600) + "h ago"
return Math.floor(s / 86400) + "d ago"
}
HoverHandler { id: rowHover }
MouseArea {
anchors.fill: parent
onClicked: srow.activated(srow.entry.id)
}
RowLayout {
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 4
}
spacing: 2
ColumnLayout {
id: rowCol
Layout.fillWidth: true
Layout.leftMargin: 4
spacing: 0
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
: "(no prompt yet)"
color: srow.open ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: {
if (srow.entry === null)
return ""
const n = srow.entry.messages
const when = srow.ago(srow.entry.mtime)
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
: when
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
ToolButton {
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
implicitWidth: 26
implicitHeight: 26
ToolTip.visible: hovered
ToolTip.text: "Delete this session and its transcript"
contentItem: Text {
text: "×"
color: parent.hovered ? Theme.err : Theme.textFaint
font.pixelSize: 15
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: srow.removeRequested(srow.entry.id)
}
}
}
+76
View File
@@ -19,10 +19,15 @@ Rectangle {
property bool running: false
property string status: ""
property string todo: ""
property var sessions: [] // what the store holds (AgentCore::sessions)
property string sessionId: ""
signal modeRequested(string mode)
signal approvalRequested(int approval)
signal planModeRequested(bool on)
signal sessionRequested(string id)
signal sessionNewRequested()
signal sessionDeleteRequested(string id)
readonly property alias contentColumn: col
@@ -108,6 +113,14 @@ Rectangle {
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: side.sessionId.length > 0
text: "session " + side.sessionId
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
}
Text {
Layout.fillWidth: true
visible: side.status.length > 0
@@ -118,6 +131,69 @@ Rectangle {
}
}
// --- which conversation ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
text: "Sessions"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Item { Layout.fillWidth: true }
// Starting a session while a turn runs would move the
// engine out from under it: the whole block is off then.
ToolButton {
objectName: "newSessionButton"
implicitWidth: 62
implicitHeight: Theme.segmentHeight
enabled: !side.running
ToolTip.visible: hovered
ToolTip.text: "Start a new session"
contentItem: Text {
text: "+ new"
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"
border.width: 1
border.color: Theme.border
}
onClicked: side.sessionNewRequested()
}
}
Repeater {
model: side.sessions
delegate: SessionRow {
Layout.fillWidth: true
entry: modelData
onActivated: function (id) { side.sessionRequested(id) }
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
}
}
Text {
Layout.fillWidth: true
visible: side.sessions.length === 0
text: "nothing stored here yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- how it frames itself ---
ColumnLayout {
Layout.fillWidth: true
+6 -5
View File
@@ -31,6 +31,12 @@ Rectangle {
RowLayout {
spacing: Theme.gap
/* A handler, not a MouseArea: a layout decides the geometry of its
* children, and anchoring one of them is undefined behavior. */
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: block.expanded = !block.expanded
}
Text {
text: block.expanded ? "▾" : "▸"
color: Theme.textDim
@@ -50,11 +56,6 @@ Rectangle {
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: block.expanded = !block.expanded
}
}
Text {
Layout.fillWidth: true
+3
View File
@@ -8,6 +8,7 @@ Rectangle {
id: card
property string name: ""
property string state: "pending" // pending | ready | running | done | denied
// | unfinished (replayed, no result)
property string detail: ""
property string output: ""
property int exitCode: 0
@@ -16,6 +17,7 @@ Rectangle {
readonly property bool running: state === "pending" || state === "ready" || state === "running"
readonly property color stateColor: state === "denied" ? Theme.err
: state === "unfinished" ? Theme.textFaint
: running ? Theme.warn
: hasResult && exitCode !== 0 ? Theme.err
: Theme.ok
@@ -23,6 +25,7 @@ Rectangle {
: state === "ready" ? "queued"
: state === "running" ? "running"
: state === "denied" ? "denied"
: state === "unfinished" ? "no result"
: hasResult && exitCode !== 0 ? "failed" : "done"
function lines() {