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.
311 lines
12 KiB
QML
311 lines
12 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
// What the engine is set to: where it runs, how it frames itself, when it
|
|
// asks, whether it is planning. Everything here writes straight into the
|
|
// engine's config (AgentCore), never into a copy the engine cannot see.
|
|
//
|
|
// The panel scrolls (a phone-sized window cannot show it all) and every
|
|
// control keeps a finger-sized hit area.
|
|
Rectangle {
|
|
id: side
|
|
property string workspace: ""
|
|
property string provider: ""
|
|
property string model: ""
|
|
property string mode: "coding"
|
|
property int approval: 1 // cst_approval_t
|
|
property bool planMode: false
|
|
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
|
|
|
|
color: Theme.surface
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
implicitHeight: col.implicitHeight + Theme.pad * 2
|
|
|
|
// One selectable row of a segmented control.
|
|
component Seg: Button {
|
|
id: seg
|
|
property bool current: false
|
|
flat: true
|
|
implicitHeight: Theme.segmentHeight
|
|
padding: 0
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
|
|
border.width: 1
|
|
border.color: seg.current ? Theme.accent : Theme.border
|
|
}
|
|
contentItem: Text {
|
|
text: seg.text
|
|
color: seg.current ? Theme.text : Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
Flickable {
|
|
anchors.fill: parent
|
|
contentWidth: width
|
|
contentHeight: col.implicitHeight + Theme.pad * 2
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
ScrollBar.vertical: ScrollBar {}
|
|
|
|
ColumnLayout {
|
|
id: col
|
|
x: Theme.pad
|
|
y: Theme.pad
|
|
width: parent.width - Theme.pad * 2
|
|
spacing: Theme.gapL
|
|
|
|
// --- where ---
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
RowLayout {
|
|
spacing: Theme.gap
|
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
|
Text {
|
|
text: "CST-agent"
|
|
color: Theme.text
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Text {
|
|
visible: side.running
|
|
text: "turn running"
|
|
color: Theme.warn
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: side.workspace.length ? side.workspace : "(no workspace)"
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
elide: Text.ElideMiddle
|
|
ToolTip.visible: wsHover.hovered
|
|
ToolTip.text: side.workspace
|
|
HoverHandler { id: wsHover }
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: side.provider.length > 0 || side.model.length > 0
|
|
text: (side.provider.length ? side.provider : "?") +
|
|
(side.model.length ? " · " + side.model : "")
|
|
color: Theme.textFaint
|
|
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
|
|
text: side.status
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
spacing: 4
|
|
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
Seg {
|
|
objectName: "modeCoding"
|
|
Layout.fillWidth: true
|
|
text: "coding"
|
|
current: side.mode !== "general"
|
|
onClicked: side.modeRequested("coding")
|
|
}
|
|
Seg {
|
|
objectName: "modeGeneral"
|
|
Layout.fillWidth: true
|
|
text: "general"
|
|
current: side.mode === "general"
|
|
onClicked: side.modeRequested("general")
|
|
}
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: side.mode === "general" ? "documents, data, everyday files"
|
|
: "code framed: repos, builds, tests"
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
}
|
|
|
|
// --- when it asks ---
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
Seg {
|
|
Layout.fillWidth: true
|
|
text: "ask"
|
|
current: side.approval === 0
|
|
onClicked: side.approvalRequested(0)
|
|
}
|
|
Seg {
|
|
Layout.fillWidth: true
|
|
text: "auto"
|
|
current: side.approval === 1
|
|
onClicked: side.approvalRequested(1)
|
|
}
|
|
Seg {
|
|
Layout.fillWidth: true
|
|
text: "yolo"
|
|
current: side.approval === 2
|
|
onClicked: side.approvalRequested(2)
|
|
}
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
text: side.approval === 0 ? "every tool asks first"
|
|
: side.approval === 2 ? "never asks — writes go through"
|
|
: "sandbox and rules decide, asks on doubt"
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: 2
|
|
spacing: Theme.gap
|
|
Switch {
|
|
id: planSwitch
|
|
objectName: "planSwitch"
|
|
checked: side.planMode
|
|
implicitHeight: Theme.touchTarget
|
|
onClicked: side.planModeRequested(checked)
|
|
}
|
|
Text {
|
|
text: "Plan mode"
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: side.planMode
|
|
text: "read-only tools and a planning note"
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
wrapMode: Text.Wrap
|
|
}
|
|
}
|
|
|
|
// --- the plan the engine keeps ---
|
|
TodoPanel {
|
|
Layout.fillWidth: true
|
|
text: side.todo
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Theme.gap
|
|
text: "engine in-process · v" + Qt.application.version
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
}
|