aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/assistant/Assistant.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 16:52:45 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 16:52:45 +0200
commitbb9442a07cebe70c3492cca31f31a8270bb83134 (patch)
tree2dff25cd4c3715b5b54d4c65ee6619e896f170aa /desktop/assistant/Assistant.qml
parent193cd8c2e1cc7e16b1389b95036bf7f4f99f97e0 (diff)
downloadquickshell-bb9442a07cebe70c3492cca31f31a8270bb83134.tar.gz
quickshell-bb9442a07cebe70c3492cca31f31a8270bb83134.zip
fix(assistant): review of the Do row and fileops
The canvas: a To .md ended any other way than its own Stop (the footer Stop, a question, a drop, a daemon restart, an older daemon refusing the command) left the card at Working forever. The footer Stop goes through stopOp(), asking and dropping end the operation first, a disconnect ends it, and an error while nothing else runs is taken as its outcome. Each fileops run gets its own Process, so a stopped run's late answer is never read as the next one's. To .md takes one PDF, as one file is written. Compress... no longer flips back when file(1) refines a kind, long errors are elided, and a desktop with no documents folder gets Documents/Assistant rather than ~/Assistant. fileops: an animated GIF to PNG or JPEG left one file per frame under names the rename never found; the first frame is taken. JPEG flattens transparency onto white. Zipping a folder holding the output folder read its own growing archive without end; only regular files outside it are added, which also skips FIFOs and dangling links. qpdf warnings no longer fail a merge. A folder's free name keeps its dots, a lone .gz or .xz is decompressed rather than refused, and a dotfile's archive is not hidden. Tools die with fileops even when it is killed outright, a killed run's leftovers are swept on the next run, a stop between fork and bookkeeping cannot miss the child, and a late stop cannot add a second answer. Error lines lose bsdtar's doubled prefix and its delayed-exit noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/assistant/Assistant.qml')
-rw-r--r--desktop/assistant/Assistant.qml61
1 files changed, 42 insertions, 19 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index b1329b1..f76c38d 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -73,7 +73,11 @@ Scope {
readonly property bool busy: root.state !== "idle"
// A daemon gone mid-reply sends no idle; its hello on return redraws.
- onConnectedChanged: if (!root.connected) { root.state = "idle"; root.runningTool = ""; }
+ onConnectedChanged: if (!root.connected) {
+ root.state = "idle";
+ root.runningTool = "";
+ if (root.opRunning && root.opDaemon) { root.opRunning = false; root.opError = "assistant gone"; }
+ }
// One row per turn. tools is a comma-joined string: a ListModel role
// holding an array becomes a nested model, which a Repeater can read but
@@ -117,6 +121,7 @@ Scope {
function attach(paths) {
const files = paths.filter(p => p !== "").map(Files.file);
if (!files.length) return;
+ if (root.opRunning) root.stopOp(); // a drop is a new task
root.fresh();
root.clearOp();
root.staged = files;
@@ -151,31 +156,42 @@ Scope {
root.send({ type: "write", files: paths, prompt: root.mdPrompt, dir: root.outDir, name: name });
} else {
root.opDaemon = false;
- ops.command = [root.fileops, o.op, root.outDir].concat(paths);
- ops.running = false;
- ops.running = true;
+ // A Process per run: a stopped run's late answer must not be
+ // read as the next run's.
+ root.opProc = opRun.createObject(root, { command: [root.fileops, o.op, root.outDir].concat(paths) });
}
}
+ property var opProc: null
+
function stopOp() {
if (!root.opRunning) return;
if (root.opDaemon) root.send({ type: "stop" });
- else ops.running = false; // SIGTERM: fileops kills its tool, removes the partial
+ else if (root.opProc) {
+ root.opProc.running = false; // fileops kills its tool, removes the partial
+ root.opProc = null;
+ }
root.opRunning = false;
root.opError = "stopped";
}
- Process {
- id: ops
- stdout: StdioCollector {
- onStreamFinished: {
- if (!root.opRunning || root.opDaemon) return;
- root.opRunning = false;
- try {
- const r = JSON.parse(text.trim().split("\n").pop());
- if (r.ok) root.opOut = r.out; else root.opError = r.error;
- } catch (e) {
- root.opError = "fileops gave no answer";
+ Component {
+ id: opRun
+ Process {
+ id: proc
+ running: true
+ stdout: StdioCollector {
+ onStreamFinished: {
+ if (root.opProc !== proc) { proc.destroy(); return; } // stopped, or superseded
+ root.opProc = null;
+ root.opRunning = false;
+ try {
+ const r = JSON.parse(text.trim().split("\n").pop());
+ if (r.ok) root.opOut = r.out; else root.opError = r.error;
+ } catch (e) {
+ root.opError = "fileops gave no answer";
+ }
+ proc.destroy();
}
}
}
@@ -185,7 +201,11 @@ Scope {
running: true
command: ["xdg-user-dir", "DOCUMENTS"]
stdout: StdioCollector {
- onStreamFinished: if (text.trim().startsWith("/")) root.outDir = text.trim() + "/Assistant"
+ // With no documents folder set, xdg-user-dir answers $HOME itself.
+ onStreamFinished: {
+ const d = text.trim();
+ if (d.startsWith("/") && d !== Quickshell.env("HOME")) root.outDir = d + "/Assistant";
+ }
}
}
@@ -232,7 +252,8 @@ Scope {
// error, and the chips must still be there to fix it. chatting keeps the
// chat layout meanwhile, so the card does not shrink while a PDF is read.
function ask(text) {
- if (!root.connected || root.blocked || (text.trim() === "" && !root.staged.length)) return false;
+ if (!root.connected || root.blocked || root.opRunning
+ || (text.trim() === "" && !root.staged.length)) return false;
root.notice = "";
root.chatting = true;
root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) });
@@ -324,7 +345,9 @@ Scope {
if (root.opDaemon && root.opRunning) { root.opRunning = false; root.opOut = [e.path]; }
break;
case "error":
- if (e.turn && root.opDaemon && root.opRunning) {
+ // A write's outcome. An older daemon answers it with a plain
+ // error and no turn; while nothing else runs, that is the write's.
+ if (root.opDaemon && root.opRunning && (e.turn || root.state === "idle")) {
root.opRunning = false;
root.opError = e.message;
break;