diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index ae962bf..0a9cead 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -102,8 +102,8 @@ if(CST_APP_GUI) # Sources live in qml/, but a module whose files sit in a subdirectory of # the URI makes Qt register the types with relative URLs ("qmlRegisterType # requires absolute URLs" on every start). - foreach(_qml Main ChatView Sidebar MessageCard ToolCard ThinkBlock NoteLine - TodoPanel Composer ConsentDialog AskDialog Theme) + foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock + NoteLine TodoPanel Composer ConsentDialog AskDialog Theme) set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml) endforeach() qt_add_qml_module(cst-agent-app @@ -113,6 +113,7 @@ if(CST_APP_GUI) qml/Main.qml qml/ChatView.qml qml/Sidebar.qml + qml/SessionRow.qml qml/MessageCard.qml qml/ToolCard.qml qml/ThinkBlock.qml diff --git a/app/README.md b/app/README.md index eb31b92..bf2b854 100644 --- a/app/README.md +++ b/app/README.md @@ -80,9 +80,9 @@ One shell, three sizes (everything is styled from `qml/Theme.qml`): `--uitest` checks this without a human: it loads the window, clicks with synthesized **mouse** events, taps with a synthesized **touch** device, types -into the composer, then narrows the window to 460 px to assert the dock → -drawer switch and opens/closes the drawer. One line per check, non-zero exit -on failure: +into the composer, drives the session list, then narrows the window to 460 px +to assert the dock → drawer switch and opens/closes the drawer. One line per +check, non-zero exit on failure: ```sh QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest @@ -92,6 +92,36 @@ QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest # ... ``` +## Sessions + +A GUI session is a file in the workspace's store (`.agent/sessions/.jsonl`), +the same one the CLI, the TUI and the WebUI use — `AgentCore::open()` resolves +the workspace's current session before the engine starts, so nothing is written +next to the process's working directory. + +- The sidebar lists the store (newest first): title or first prompt, message + count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×` + deletes one (the engine first moves off a session it has open, so it never + sits on a file that is gone), and a click switches. +- Switching **replays** the transcript: `AgentCore::history()` walks the stored + messages and returns the same rows the live signals build — messages, + thoughts, notes, and one card per tool call, with its result paired by name + (a call with no result keeps state `unfinished`, so no spinner runs + forever). A reopened session therefore renders like the turn that wrote it. +- Session controls are disabled while a turn runs: switching would move the + engine out from under it. + +`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small +session through the store API (a tool call with its result), 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 `×` (the open one stays), and +switch back. + +`--uitest` uses a scratch workspace unless `--workspace` is given, since it +creates and deletes sessions. On a machine without a screen, +`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is +done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds. + ## Tools the app provides A tool implemented in the front end is registered with `cst_tool_register()` @@ -147,7 +177,8 @@ The build flag that keeps Unix helpers out of the picture is - [x] QML shell: transcript cards, tool cards with diff coloring, thinking blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout, `--uitest` for mouse, touch and small-screen checks +- [x] session list in the sidebar: new / switch / delete, transcript replay - [ ] a real turn driven through the window (needs a provider key) -- [ ] session list / switch, attachments, settings page +- [ ] attachments (drag in, paste), settings page (provider, model, key) - [ ] Kirigami (Breeze look), tray, global shortcuts - [ ] Android APK (engine in-process, JNI platform tools) diff --git a/app/qml/ChatView.qml b/app/qml/ChatView.qml index 183b8f5..083a5ab 100644 --- a/app/qml/ChatView.qml +++ b/app/qml/ChatView.qml @@ -12,6 +12,7 @@ Item { ListView { id: list + objectName: "transcript" anchors.fill: parent anchors.leftMargin: Theme.gapL anchors.rightMargin: Theme.gapL diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 1394564..c73d73b 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -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) } diff --git a/app/qml/SessionRow.qml b/app/qml/SessionRow.qml new file mode 100644 index 0000000..1f86f75 --- /dev/null +++ b/app/qml/SessionRow.qml @@ -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) + } + } +} diff --git a/app/qml/Sidebar.qml b/app/qml/Sidebar.qml index b41eba1..276d659 100644 --- a/app/qml/Sidebar.qml +++ b/app/qml/Sidebar.qml @@ -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 diff --git a/app/qml/ThinkBlock.qml b/app/qml/ThinkBlock.qml index e59253e..9677606 100644 --- a/app/qml/ThinkBlock.qml +++ b/app/qml/ThinkBlock.qml @@ -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 diff --git a/app/qml/ToolCard.qml b/app/qml/ToolCard.qml index e05429b..03e3723 100644 --- a/app/qml/ToolCard.qml +++ b/app/qml/ToolCard.qml @@ -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() { diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 80672fb..099fe4a 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -3,7 +3,12 @@ #include #include #include +#include +#include +#include +#include #include +#include #include @@ -71,6 +76,14 @@ bool AgentCore::open(const QString &workspace, const QString &provider, if (!apiKey.isEmpty()) snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", apiKey.toUtf8().constData()); + /* Sessions live in the workspace's store -- the same files the CLI and the + * WebUI use. Without this the engine would open session.jsonl in the + * process's working directory, next to a session list that stays empty. */ + char sid[128] = ""; + if (cst_agent_store_resolve_default(m_cfg.workspace_root, m_cfg.session_path, + sizeof(m_cfg.session_path), sid, + sizeof(sid)) != 0) + m_cfg.session_path[0] = 0; /* fall back to the engine's default */ /* The GUI answers itself: never let the engine decide behind its back. */ m_cfg.auto_approve = 0; m_cfg.headless = 0; @@ -243,6 +256,231 @@ void AgentCore::setPlanMode(bool on) { emit planModeChanged(); } +/* --- sessions ------------------------------------------------------------ */ + +namespace { + +QString utf8(const char *s) { return QString::fromUtf8(s ? s : ""); } + +/* A transcript row in the shape Main.qml appends live (see addRow()). */ +QVariantMap sessionRow(const QString &kind, const QString &role) { + QVariantMap r; + r["kind"] = kind; + r["role"] = role; + r["text"] = QString(); + r["complete"] = false; + r["name"] = QString(); + r["state"] = QString(); + r["detail"] = QString(); + r["output"] = QString(); + r["exitCode"] = 0; + return r; +} + +/* The last tool row with this name that is still open, -1 when none: the + * pairing findTool() does for the live stream, so a replayed result lands on + * the card of its own call. An unmatched call keeps state "unfinished": an + * interrupted turn should not show a spinner that will never stop. */ +int findOpenTool(const QVariantList &rows, const QString &name) { + for (int i = rows.size() - 1; i >= 0; i--) { + const QVariantMap r = rows.at(i).toMap(); + if (r.value(QStringLiteral("kind")).toString() != QLatin1String("tool")) + continue; + if (r.value(QStringLiteral("complete")).toBool()) + return -1; + if (r.value(QStringLiteral("name")).toString() == name) + return i; + } + return -1; +} + +} // namespace + +QString AgentCore::sessionId() const { + if (!m_agent) + return QString(); + char id[128] = ""; + cst_agent_session_id(m_agent, id, sizeof(id)); + return utf8(id); +} + +QVariantList AgentCore::sessions() const { + QVariantList out; + if (m_workspace.isEmpty()) + return out; + const QByteArray ws = m_workspace.toUtf8(); + const int kMax = 200; + char(*ids)[128] = new char[kMax][128]; + const int n = cst_agent_store_list(ws.constData(), ids, kMax); + const QString current = sessionId(); + for (int i = 0; i < n; i++) { + cst_session_info_t in; + memset(&in, 0, sizeof(in)); + if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0) + continue; + const QString id = utf8(in.id[0] ? in.id : ids[i]); + QVariantMap m; + m["id"] = id; + m["title"] = utf8(in.title); + m["firstUser"] = utf8(in.first_user); + m["messages"] = in.messages; + m["mtime"] = static_cast(in.mtime) * 1000; /* ms, for JS Date */ + m["current"] = (id == current); + out.append(m); + } + delete[] ids; + return out; +} + +QVariantList AgentCore::history() const { + QVariantList rows; + cst_session_t *s = m_agent ? cst_agent_session(m_agent) : nullptr; + if (!s) + return rows; + cst_session_lock(s); + const int n = cst_session_count(s); + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(s, i); + if (!m || m->role == CST_ROLE_SYSTEM) + continue; /* the prompt is the engine's, not the conversation */ + if (m->role == CST_ROLE_TOOL || cst_message_is_tool_result(m)) { + /* Tool output: role=tool in the native protocol, a user message in the + * text ones. Both belong on the card of the call they answer. */ + const QString name = utf8(m->tool_name); + const int at = findOpenTool(rows, name); + if (at < 0) { + QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool")); + r["name"] = name; + r["output"] = utf8(m->content); + r["state"] = "done"; + r["complete"] = true; + rows.append(r); + } else { + QVariantMap r = rows.at(at).toMap(); + r["output"] = utf8(m->content); + r["state"] = "done"; + r["complete"] = true; + rows[at] = r; + } + continue; + } + if (cst_message_is_note(m)) { + QVariantMap r = sessionRow(QStringLiteral("note"), QStringLiteral("engine")); + r["text"] = utf8(m->content); + r["complete"] = true; + rows.append(r); + continue; + } + if (m->role == CST_ROLE_USER) { + QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("user")); + QString text = utf8(m->content); + if (m->nattach > 0) { + QStringList names; + for (int a = 0; a < m->nattach; a++) + names << utf8(m->attach[a].name); + text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", ")); + } + r["text"] = text; + r["complete"] = true; + rows.append(r); + continue; + } + if (m->role != CST_ROLE_ASSISTANT) + continue; + if (m->reasoning && m->reasoning[0]) { + QVariantMap r = sessionRow(QStringLiteral("think"), QStringLiteral("agent")); + r["text"] = utf8(m->reasoning); + r["complete"] = true; + rows.append(r); + } + if (m->content && m->content[0]) { + QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("agent")); + r["text"] = utf8(m->content); + r["complete"] = true; + rows.append(r); + } + if (m->tool_calls_json) { + const QJsonArray calls = + QJsonDocument::fromJson(QByteArray(m->tool_calls_json)).array(); + for (const QJsonValue &v : calls) { + const QJsonObject fn = v.toObject().value(QStringLiteral("function")).toObject(); + QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool")); + r["name"] = fn.value(QStringLiteral("name")).toString(); + r["detail"] = fn.value(QStringLiteral("arguments")).toString(); + r["state"] = "unfinished"; /* until its result shows up below */ + rows.append(r); + } + } + } + cst_session_unlock(s); + return rows; +} + +void AgentCore::afterSessionChange() { + const QString todo = utf8(cst_agent_todo(m_agent)); + emit sessionChanged(); + emit sessionListChanged(); + if (todo != m_todo) { + m_todo = todo; + emit todoChanged(todo); + } +} + +bool AgentCore::newSession() { + if (!m_agent || m_running) + return false; + char id[128] = ""; + if (cst_agent_new_session(m_agent, id, sizeof(id)) != 0) + return false; + afterSessionChange(); + return true; +} + +bool AgentCore::switchSession(const QString &id) { + if (!m_agent || m_running || id.isEmpty()) + return false; + if (id == sessionId()) + return true; + if (cst_agent_switch_session(m_agent, id.toUtf8().constData()) != 0) + return false; + afterSessionChange(); + return true; +} + +bool AgentCore::deleteSession(const QString &id) { + if (!m_agent || m_running || id.isEmpty() || m_workspace.isEmpty()) + return false; + const bool wasOpen = (id == sessionId()); + if (wasOpen) { + /* The engine must not sit on a transcript that is about to vanish: it + * moves to a fresh session first, exactly like the WebUI does. */ + char fresh[128] = ""; + if (cst_agent_new_session(m_agent, fresh, sizeof(fresh)) != 0) + return false; + } + const QByteArray ws = m_workspace.toUtf8(); + const QByteArray sid = id.toUtf8(); + if (cst_agent_store_delete(ws.constData(), sid.constData()) != 0) + return false; + if (wasOpen) + afterSessionChange(); + else + emit sessionListChanged(); + return true; +} + +bool AgentCore::renameSession(const QString &id, const QString &title) { + if (id.isEmpty() || m_workspace.isEmpty()) + return false; + const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(), + t = title.toUtf8(); + if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) != + 0) + return false; + emit sessionListChanged(); + return true; +} + /* --- tools the front end provides ---------------------------------------- */ bool AgentCore::registerTool(const cst_tool_def_t *def) { diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index d5ac545..caf5715 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "cst_agent.h" @@ -29,6 +30,7 @@ class AgentCore : public QObject { Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged) Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged) Q_PROPERTY(QString todo READ todo NOTIFY todoChanged) + Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged) public: explicit AgentCore(QObject *parent = nullptr); @@ -48,11 +50,27 @@ class AgentCore : public QObject { int approval() const { return m_cfg.approval; } bool planMode() const { return m_cfg.plan_mode != 0; } QString todo() const { return m_todo; } + QString sessionId() const; /* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a * message looks the same here as it does in the WebUI. */ Q_INVOKABLE QString renderMarkdown(const QString &text) const; + /* --- sessions (the workspace's .agent/sessions store) ------------------- */ + + /* Stored sessions, newest first; one map each: id, title (a rename, else + * empty), firstUser, messages, mtime (ms since the epoch), current. */ + Q_INVOKABLE QVariantList sessions() const; + /* The open session as transcript rows, in exactly the shape the live + * signals build (kind, role, text, name, state, detail, output, exitCode, + * complete), so a reopened session renders like the turn that wrote it. */ + Q_INVOKABLE QVariantList history() const; + /* None of these run during a turn; all return false when one does. */ + Q_INVOKABLE bool newSession(); + Q_INVOKABLE bool switchSession(const QString &id); + Q_INVOKABLE bool deleteSession(const QString &id); + Q_INVOKABLE bool renameSession(const QString &id, const QString &title); + /* The engine, for the parts that need it (tool registration). */ cst_agent_t *agent() const { return m_agent; } const cst_agent_config_t *config() const { return &m_cfg; } @@ -85,6 +103,8 @@ class AgentCore : public QObject { void configChanged(); void approvalChanged(); void planModeChanged(); + void sessionChanged(); /* another session is open now */ + void sessionListChanged(); /* the store gained, lost or renamed one */ void statusChanged(const QString &text); void assistantDelta(const QString &text); void thinkDelta(const QString &text); @@ -104,6 +124,8 @@ class AgentCore : public QObject { friend class Turn; void finishTurn(int rc, const QString &err); void setRunning(bool on); + /* Re-read what a session switch changed (id, todo) and say so. */ + void afterSessionChange(); /* Hook trampolines (C, run on the worker thread). */ static void hookStatus(const char *msg, void *ud); diff --git a/app/src/main.cpp b/app/src/main.cpp index 14638d0..50a7c93 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -22,6 +22,7 @@ #include #include +#include #include "core/AgentCore.h" #include "core/ToolProvider.h" @@ -143,6 +144,16 @@ int main(int argc, char **argv) { ws = QDir::currentPath(); ws = QFileInfo(ws).absoluteFilePath(); + /* --uitest drives a scratch workspace when none was given: the session + * checks create and delete sessions, which is not something to do in + * whatever directory the app happened to be started from. */ + std::unique_ptr scratch; + if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) { + scratch.reset(new QTemporaryDir); + if (scratch->isValid()) + ws = scratch->path(); + } + AgentCore core; QString err; if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt), diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 4b9079f..d057556 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -15,7 +15,12 @@ #include #include +#include + #include +#include + +#include "cst_agent.h" namespace app { namespace { @@ -29,8 +34,35 @@ void check(bool ok, const QString &what) { g_fail++; } +QQuickItem *byName(QQuickItem *root, const QString &name) { + if (!root) + return nullptr; + if (root->objectName() == name) + return root; + const QList kids = root->childItems(); + for (QQuickItem *c : kids) + if (QQuickItem *hit = byName(c, name)) + return hit; + return nullptr; +} + +int countNamed(QQuickItem *root, const QString &prefix) { + if (!root) + return 0; + int n = root->objectName().startsWith(prefix) ? 1 : 0; + const QList kids = root->childItems(); + for (QQuickItem *c : kids) + n += countNamed(c, prefix); + return n; +} + +/* An item by objectName. The QObject tree is tried first (it holds the named + * controls); a view's delegates can be owned by the model instead, so the + * visual tree -- what a finger can actually reach -- is walked as well. */ QQuickItem *item(QQuickWindow *win, const QString &name) { - return win->findChild(name); + if (QQuickItem *it = win->findChild(name)) + return it; + return byName(win->contentItem(), name); } /* The centre of an item, in window coordinates. */ @@ -64,6 +96,45 @@ void resizeTo(QQuickWindow *win, int w, int h) { QTest::qWait(250); /* let the layouts settle */ } +/* A stored session with a tool call in it, written through the store's own + * API, so the file is exactly what the engine reads back. */ +QString writeFixtureSession(const QString &ws, const QString &id) { + const QByteArray w = ws.toUtf8(); + char path[4096] = ""; + if (cst_agent_store_path_for_id(w.constData(), id.toUtf8().constData(), path, + sizeof(path)) != 0) + return QString(); + cst_session_t *s = cst_session_open(path); + if (!s) + return QString(); + cst_message_t m; + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_USER; + m.content = strdup("what is here?"); + cst_session_append(s, &m); + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_ASSISTANT; + m.content = strdup("let me look. "); + m.tool_calls_json = strdup( + "[{\"id\":\"call_1\",\"type\":\"function\",\"function\":" + "{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":" + "\\\"README.md\\\"}\"}}]"); + cst_session_append(s, &m); + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_TOOL; + m.content = strdup("hello\n"); + snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1"); + snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file"); + cst_session_append(s, &m); + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_ASSISTANT; + m.reasoning = strdup("checked the file"); + m.content = strdup("a repo."); + cst_session_append(s, &m); + cst_session_close(s); + return id; +} + } // namespace int runUiTest(AgentCore &core, const QString &size) { @@ -141,6 +212,72 @@ int runUiTest(AgentCore &core, const QString &size) { check(false, QStringLiteral("composer and send button are reachable")); } + /* --- sessions: the sidebar is the store's face ------------------------ */ + QQuickItem *list = item(win, QStringLiteral("transcript")); + const QString fixture = + writeFixtureSession(core.workspace(), QStringLiteral("20260101-120000-fix")); + check(!fixture.isEmpty() && list != nullptr, + QStringLiteral("a session fixture with a tool call is in the store")); + QMetaObject::invokeMethod(win, "reloadSessions"); + QTest::qWait(150); + int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:")); + if (qEnvironmentVariableIsSet("CST_APP_DUMP")) + std::printf("note: %d session rows in the visual tree\n", rowsSeen); + const int listed = win->property("sessionsView").toList().size(); + check(item(win, QStringLiteral("sessionRow:") + fixture) != nullptr, + QStringLiteral("the stored session shows up in the sidebar (%1 rows, %2 listed)") + .arg(rowsSeen) + .arg(listed)); + check(core.sessions().size() >= 2, + QStringLiteral("the store lists it next to the open one")); + + /* the fixture is: user, agent + call, result, thought, agent */ + clickMouse(win, QStringLiteral("sessionRow:") + fixture); + check(core.sessionId() == fixture, + QStringLiteral("a click on a row switches the engine")); + check(list && list->property("count").toInt() == 5, + QStringLiteral("the reopened transcript is replayed into the view")); + { + bool toolOk = false; + const QVariantList rows = core.history(); + for (const QVariant &v : rows) { + const QVariantMap r = v.toMap(); + if (r.value(QStringLiteral("kind")).toString() == QLatin1String("tool") && + r.value(QStringLiteral("name")).toString() == QLatin1String("read_file")) + toolOk = r.value(QStringLiteral("complete")).toBool() && + r.value(QStringLiteral("output")).toString() == + QLatin1String("hello\n"); + } + check(toolOk, QStringLiteral("a tool result is paired with its call")); + } + + /* new sessions: the engine moves, the view empties, the store grows */ + const int before = core.sessions().size(); + clickMouse(win, QStringLiteral("newSessionButton")); + const QString first = core.sessionId(); + check(first != fixture, QStringLiteral("New starts another session")); + clickMouse(win, QStringLiteral("newSessionButton")); + const QString second = core.sessionId(); + check(second != first, QStringLiteral("New starts another one each time")); + check(core.sessions().size() == before + 2, + QStringLiteral("both new sessions are in the store")); + check(list && list->property("count").toInt() == 0, + QStringLiteral("a fresh session starts with an empty view")); + + /* the x drops a stored session and leaves the open one alone */ + QTest::qWait(150); + clickMouse(win, QStringLiteral("sessionDelete:") + first); + check(core.sessions().size() == before + 1, + QStringLiteral("the x drops a stored session")); + check(core.sessionId() == second, + QStringLiteral("deleting another session leaves the open one alone")); + + /* and back to the fixture, which still has its five rows */ + QTest::qWait(150); + clickMouse(win, QStringLiteral("sessionRow:") + fixture); + check(core.sessionId() == fixture && list && list->property("count").toInt() == 5, + QStringLiteral("switching back replays it again")); + /* --- a narrow window: dock -> drawer, and the button opens it --- */ resizeTo(win, 460, 900); check(docked && !docked->isVisible(), @@ -158,6 +295,16 @@ int runUiTest(AgentCore &core, const QString &size) { check(docked && docked->isVisible(), QStringLiteral("widening again docks the sidebar back")); + /* A picture of the last state on request: the only way to look at the + * layout on a machine without a screen + * (CST_APP_SHOT=shot.png cst-agent-app --uitest). */ + if (qEnvironmentVariableIsSet("CST_APP_SHOT")) { + const QString shot = qEnvironmentVariable("CST_APP_SHOT"); + std::printf("%s %s\n", + win->grabWindow().save(shot) ? "note wrote" : "note cannot write", + qPrintable(shot)); + } + std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed"); return g_fail ? 1 : 0; } diff --git a/include/cst_version.h b/include/cst_version.h index ce98b36..6a9800d 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 8 +#define CST_VERSION_PATCH 9 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x)