feat: add v2 task control and asynchronous isolated workers
This commit is contained in:
+2
-1
@@ -200,7 +200,7 @@ if(CST_APP_GUI)
|
||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||
# requires absolute URLs" on every start).
|
||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog TaskPanel
|
||||
ThemeIcon Theme ImageStrip)
|
||||
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||
endforeach()
|
||||
@@ -218,6 +218,7 @@ if(CST_APP_GUI)
|
||||
qml/NoteLine.qml
|
||||
qml/TodoPanel.qml
|
||||
qml/Composer.qml
|
||||
qml/TaskPanel.qml
|
||||
qml/ConsentDialog.qml
|
||||
qml/AskDialog.qml
|
||||
qml/SettingsDialog.qml
|
||||
|
||||
@@ -22,7 +22,7 @@ Rectangle {
|
||||
|
||||
function submit() {
|
||||
const t = input.text.trim()
|
||||
if (!t.length || running)
|
||||
if (!t.length)
|
||||
return
|
||||
input.text = ""
|
||||
submitted(t)
|
||||
@@ -142,7 +142,7 @@ Rectangle {
|
||||
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
||||
Math.max(56, contentHeight + 12))
|
||||
placeholderText: root.running
|
||||
? "the turn is running — Esc stops it"
|
||||
? "Send a correction now — Esc pauses execution"
|
||||
: (root.compact
|
||||
? "Ask for something…"
|
||||
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
||||
@@ -155,7 +155,7 @@ Rectangle {
|
||||
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
||||
Qt.ImhNoAutoUppercase
|
||||
background: Rectangle { color: "transparent" }
|
||||
enabled: !root.running
|
||||
enabled: true
|
||||
|
||||
Keys.onPressed: function (ev) {
|
||||
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
||||
@@ -206,9 +206,9 @@ Rectangle {
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? 68 : 88
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
text: root.running ? "Stop" : "Send"
|
||||
text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
|
||||
enabled: root.running || input.text.trim().length > 0
|
||||
onClicked: root.running ? agent.cancel() : root.submit()
|
||||
onClicked: root.running && !input.text.trim().length ? agent.cancel() : root.submit()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: !action.enabled ? Theme.surfaceAlt
|
||||
|
||||
@@ -24,6 +24,7 @@ ApplicationWindow {
|
||||
property string statusText: ""
|
||||
property var sessionsView: [] // the store, for the sidebar
|
||||
property bool navOpen: false // the sliding sidebar, compact only
|
||||
TaskPanel { id: taskPanel; anchors.centerIn: parent }
|
||||
property int sidebarWidth: 0 // 0 = follow the window width
|
||||
readonly property bool compact: width < Theme.compactBreakpoint
|
||||
readonly property int sidebarWide: sidebarWidth > 0
|
||||
@@ -318,6 +319,12 @@ ApplicationWindow {
|
||||
win.statusText = ""
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "taskPanelButton"
|
||||
text: "Tasks"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: taskPanel.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Dialog {
|
||||
id: root
|
||||
objectName: "taskControlDialog"
|
||||
title: "Task control and jobs"
|
||||
modal: true
|
||||
standardButtons: Dialog.Close
|
||||
width: Math.min(760, parent ? parent.width - 24 : 760)
|
||||
height: Math.min(640, parent ? parent.height - 24 : 640)
|
||||
property var state: ({})
|
||||
function refresh() {
|
||||
try { state = JSON.parse(agent.taskState()) } catch (e) { state = ({}) }
|
||||
}
|
||||
onOpened: { refresh(); config.text = agent.workerConfig() }
|
||||
Timer { interval: 500; repeat: true; running: root.visible; onTriggered: root.refresh() }
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
TabBar {
|
||||
id: tabs
|
||||
Layout.fillWidth: true
|
||||
TabButton { text: "Task / Jobs" }
|
||||
TabButton { text: "Models" }
|
||||
TabButton { text: "Integration" }
|
||||
}
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: tabs.currentIndex
|
||||
ColumnLayout {
|
||||
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (root.state.goal || "No active goal") }
|
||||
Label { text: "Revision " + (root.state.revision || 0) + " · " + (root.state.status || "idle") + " · " + (root.state.cancel_source || "") }
|
||||
RowLayout {
|
||||
CheckBox {
|
||||
id: readonly
|
||||
objectName: "taskReadOnly"
|
||||
text: "Read only"
|
||||
checked: !!root.state.read_only
|
||||
onClicked: agent.taskControl(checked, !direct.checked, !!root.state.paused)
|
||||
}
|
||||
CheckBox {
|
||||
id: direct
|
||||
objectName: "taskMainOnly"
|
||||
text: "Main agent only"
|
||||
checked: root.state.delegation === false
|
||||
onClicked: agent.taskControl(readonly.checked, !checked, !!root.state.paused)
|
||||
}
|
||||
Button { text: root.state.paused ? "Resume" : "Pause"; onClicked: agent.taskControl(readonly.checked, !direct.checked, !root.state.paused) }
|
||||
}
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
Repeater {
|
||||
model: root.state.jobs || []
|
||||
Frame {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
Label { text: modelData.job_id + " · " + modelData.kind + " · " + modelData.status }
|
||||
Label { Layout.fillWidth: true; text: modelData.resource || ""; elide: Text.ElideMiddle }
|
||||
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (modelData.output || "").slice(-1000) }
|
||||
RowLayout {
|
||||
Button { text: "Cancel"; enabled: modelData.status === "running" || modelData.status === "queued"; onClicked: agent.cancelJob(modelData.job_id, false) }
|
||||
Button { text: "Force stop"; visible: modelData.status === "running_protected"; onClicked: agent.cancelJob(modelData.job_id, true) }
|
||||
Button { text: "Review delta"; visible: (modelData.resource || "").indexOf("/.agent/v2/wt-") >= 0; onClicked: { worktree.text = modelData.resource; tabs.currentIndex = 2 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Role model profiles and budgets (provider credentials stay separate)"; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: config; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||
Label { id: configStatus; Layout.fillWidth: true; wrapMode: Text.Wrap }
|
||||
Button { text: "Save model configuration"; enabled: !agent.running; onClicked: { const e = agent.saveWorkerConfig(config.text); configStatus.text = e.length ? e : "Saved" } }
|
||||
}
|
||||
ColumnLayout {
|
||||
TextField { id: worktree; Layout.fillWidth: true; placeholderText: "Worker worktree path" }
|
||||
RowLayout {
|
||||
Button { text: "Preview"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, false) }
|
||||
Button { text: "Integrate reviewed delta"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, true) }
|
||||
}
|
||||
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: delta; readOnly: true; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_models.h"
|
||||
#include "cst_runtime.h"
|
||||
#include "cst_subagent.h"
|
||||
#include "cst_worktree.h"
|
||||
#include "cst_provider_switch.h"
|
||||
#include "cst_web_md.h"
|
||||
|
||||
@@ -233,8 +236,13 @@ QString firstLine(const QString &s) {
|
||||
} // namespace
|
||||
|
||||
void AgentCore::send(const QString &prompt) {
|
||||
if (!m_agent || m_running || prompt.trimmed().isEmpty())
|
||||
if (!m_agent || prompt.trimmed().isEmpty())
|
||||
return;
|
||||
if (m_running) {
|
||||
cst_agent_steer(m_agent, prompt.toUtf8().constData(), -1, -1);
|
||||
emit statusChanged(QStringLiteral("Applying latest instruction…"));
|
||||
return;
|
||||
}
|
||||
/* The prompt takes the pending images with it: the view shows them on the
|
||||
* message the composer just appended, before the turn can say anything. */
|
||||
const QString sending = pendingImages();
|
||||
@@ -255,6 +263,36 @@ void AgentCore::cancel() {
|
||||
if (m_agent)
|
||||
cst_agent_request_cancel(m_agent);
|
||||
}
|
||||
QString AgentCore::taskState() const {
|
||||
if (!m_agent) return QStringLiteral("{}");
|
||||
char *s = cst_runtime_json(cst_agent_config(m_agent)->runtime);
|
||||
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
|
||||
}
|
||||
void AgentCore::taskControl(bool ro, bool delegation, bool paused) {
|
||||
if (m_agent) cst_agent_control(m_agent, ro, delegation, paused);
|
||||
}
|
||||
void AgentCore::cancelJob(const QString &id, bool force) {
|
||||
if (m_agent) cst_job_cancel(cst_agent_config(m_agent)->runtime, id.toUtf8().constData(), force, "user");
|
||||
}
|
||||
QString AgentCore::workerConfig() const {
|
||||
if (!m_agent) return QStringLiteral("{}");
|
||||
char *s = cst_workers_config_get(cst_agent_config(m_agent)->workspace_root);
|
||||
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
|
||||
}
|
||||
QString AgentCore::saveWorkerConfig(const QString &json) {
|
||||
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before changing model profiles.");
|
||||
char err[256];
|
||||
return cst_workers_config_set(cst_agent_config(m_agent)->workspace_root, json.toUtf8().constData(), err, sizeof(err)) ? QString::fromUtf8(err) : QString();
|
||||
}
|
||||
QString AgentCore::worktreeDiff(const QString &path, bool integrate) {
|
||||
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before reviewing or integrating changes.");
|
||||
const auto *cfg = cst_agent_config(m_agent);
|
||||
if (cst_runtime_jobs_pending(cfg->runtime) || (integrate && !cst_runtime_check(cfg->runtime, 0, 1, 0)))
|
||||
return QStringLiteral("Integration is blocked by active jobs or task controls.");
|
||||
int code;
|
||||
char *s = cst_worktree_diff(cst_agent_config(m_agent)->workspace_root, path.toUtf8().constData(), integrate, &code);
|
||||
QString result = QString::fromUtf8(s ? s : "error"); free(s); return result;
|
||||
}
|
||||
|
||||
/* Qt suspends the application when the phone puts it away, and a desktop
|
||||
* window can be gone too (--uitest runs headless). Either way a consent
|
||||
|
||||
@@ -157,6 +157,12 @@ class AgentCore : public QObject {
|
||||
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
||||
Q_INVOKABLE void send(const QString &prompt);
|
||||
Q_INVOKABLE void cancel();
|
||||
Q_INVOKABLE QString taskState() const;
|
||||
Q_INVOKABLE void taskControl(bool readOnly, bool delegation, bool paused);
|
||||
Q_INVOKABLE void cancelJob(const QString &id, bool force);
|
||||
Q_INVOKABLE QString workerConfig() const;
|
||||
Q_INVOKABLE QString saveWorkerConfig(const QString &json);
|
||||
Q_INVOKABLE QString worktreeDiff(const QString &path, bool integrate);
|
||||
|
||||
/* Consent: answers map to CST_PERM_*. */
|
||||
Q_INVOKABLE void answerPermission(int choice);
|
||||
|
||||
+18
-1
@@ -461,6 +461,22 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
clickMouse(win, QStringLiteral("planSwitch"));
|
||||
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
|
||||
|
||||
/* Task controls must reach the same runtime as the model and HTTP API. */
|
||||
clickMouse(win, QStringLiteral("taskPanelButton"));
|
||||
QTest::qWait(150);
|
||||
QObject *taskPanel = win->findChild<QObject *>(QStringLiteral("taskControlDialog"));
|
||||
check(taskPanel && taskPanel->property("visible").toBool(),
|
||||
QStringLiteral("Tasks opens the runtime control panel"));
|
||||
clickMouse(win, QStringLiteral("taskReadOnly"));
|
||||
clickMouse(win, QStringLiteral("taskMainOnly"));
|
||||
QJsonObject taskState = QJsonDocument::fromJson(core.taskState().toUtf8()).object();
|
||||
check(taskState.value(QStringLiteral("read_only")).toBool() &&
|
||||
!taskState.value(QStringLiteral("delegation")).toBool(),
|
||||
QStringLiteral("task panel enforces read-only and main-agent-only controls"));
|
||||
core.taskControl(false, true, false);
|
||||
if (taskPanel) QMetaObject::invokeMethod(taskPanel, "close");
|
||||
QTest::qWait(150);
|
||||
|
||||
/* --- keyboard: typing in the composer arms Send --- */
|
||||
QQuickItem *send = item(win, QStringLiteral("sendButton"));
|
||||
if (composer && send) {
|
||||
@@ -570,13 +586,14 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
}
|
||||
const QString rel =
|
||||
f.toMap().value(QStringLiteral("rel")).toString();
|
||||
if (rel.isEmpty() ||
|
||||
if (f.toMap().value(QStringLiteral("kind")).toString() == QLatin1String("img") || rel.isEmpty() ||
|
||||
!QFileInfo::exists(attachDir + QLatin1Char('/') + rel))
|
||||
continue;
|
||||
QList<QQuickItem *> hits;
|
||||
collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits);
|
||||
if (!hits.isEmpty()) {
|
||||
openButton = hits.first();
|
||||
hits.clear();
|
||||
collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits);
|
||||
shareButton = hits.isEmpty() ? nullptr : hits.first();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user