From cb16fa6c17298a6f2f4aa390c01ff8eacd53a996 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 15:28:48 +0200 Subject: fix(assistant): keep state across reloads, refusals and long replies From review. A shell reload reconnected with lastActive at 0, so the next SUPER+Home started a new conversation thirty seconds into one; hello with turns now counts as activity. show() could send new under a reply still running, which stops it unsaved; it waits while busy. ask() cleared the chips before the daemon accepted them, so a refused file (unreadable, too big, a model without vision) dropped them and collapsed the card to the entry line; the user event clears them now, and chatting holds the layout while a PDF is read. In the picker, folders ignore name filters and sorted first, so filter then Enter opened a folder; folders hide while filtering. Folder URLs are encoded, so a '#' in a name no longer cuts the path, and glob characters typed in the filter are taken literally. The card says the assistant is not running in every state, panels sit under the chat header instead of over it, and History and the picker no longer open together. Dropped file://localhost/ URLs are accepted, other hosts refused, and the island receptor keeps the sliver's width at rest so it takes no clicks beside it. Co-Authored-By: Claude Opus 5.5 --- desktop/assistant/Assistant.qml | 19 ++++++++++++++++--- desktop/assistant/Card.qml | 13 +++++++++---- desktop/assistant/Files.js | 9 +++++---- desktop/assistant/Picker.qml | 9 +++++++-- desktop/assistant/test_files.js | 2 ++ 5 files changed, 39 insertions(+), 13 deletions(-) (limited to 'desktop/assistant') diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml index b038dd6..977b4dd 100644 --- a/desktop/assistant/Assistant.qml +++ b/desktop/assistant/Assistant.qml @@ -78,7 +78,9 @@ Scope { // A conversation idle for 10 minutes is over: opening starts a new one, // the old one staying in History. ponytail: a constant, not a setting. function show() { - if (turns.count && Date.now() - root.lastActive > 10 * 60 * 1000) root.fresh(); + // Never while a reply runs: new would stop it, and a stopped turn is + // not saved. + if (turns.count && !root.busy && Date.now() - root.lastActive > 10 * 60 * 1000) root.fresh(); root.open = true; } @@ -120,15 +122,20 @@ Scope { link.item.flush(); } + // The chips stay until the daemon's user event takes them: a refused + // file (unreadable, too big, a model without vision) comes back as an + // 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; root.notice = ""; + root.chatting = true; root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) }); - root.staged = []; return true; } function showHistory() { + root.pickerOpen = false; root.historyOpen = true; root.send({ type: "list" }); } @@ -172,15 +179,21 @@ Scope { root.ctxUsed = e.ctx_used; root.ctxMax = e.ctx_max; root.load(e.id, e.turns, e.pending); - if (e.pending) root.lastActive = Date.now(); + // A restart or reload counts as active: the conversation it + // reconnects to is not stale just because this process is new. + if (e.pending || e.turns.length) root.lastActive = Date.now(); break; case "conversation": root.load(e.id, e.turns, null); // Opened from History on purpose: it counts as active now. if (e.turns.length) root.lastActive = Date.now(); + // Emptied (New, or the current one deleted) with nothing staged: + // back to the entry line. + else if (!root.staged.length) root.chatting = false; break; case "user": turns.append(root.row({ user: e.text, files: e.files }, true)); + root.staged = []; root.lastActive = Date.now(); break; case "think": root.bufThink += e.delta; break; diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml index 477f430..228cfdb 100644 --- a/desktop/assistant/Card.qml +++ b/desktop/assistant/Card.qml @@ -244,7 +244,10 @@ Rectangle { height: box.height text: "" font.family: Theme.iconFamily - onClicked: card.canvas.pickerOpen = !card.canvas.pickerOpen + onClicked: { + card.canvas.historyOpen = false; + card.canvas.pickerOpen = !card.canvas.pickerOpen; + } } Rectangle { @@ -298,7 +301,8 @@ Rectangle { anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 } visible: input.text === "" elide: Text.ElideRight - text: card.mode === "entry" ? "How can I help you?" + text: !card.canvas.connected ? "assistant not running" + : card.mode === "entry" ? "How can I help you?" : card.canvas.staged.length ? `Ask about ${card.canvas.staged[0].name}…` : "Message the assistant" color: Theme.overlay @@ -332,12 +336,13 @@ Rectangle { } } - // --- panels, over everything above the input --- + // --- panels, over the transcript (under the header in chat) --- Item { id: panelArea anchors { - top: parent.top; topMargin: 14; bottom: chips.visible ? chips.top : footer.top + top: header.visible ? header.bottom : parent.top; topMargin: header.visible ? 8 : 14 + bottom: chips.visible ? chips.top : footer.top bottomMargin: 8; left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14 } diff --git a/desktop/assistant/Files.js b/desktop/assistant/Files.js index b7f8c2b..bcfe1f0 100644 --- a/desktop/assistant/Files.js +++ b/desktop/assistant/Files.js @@ -45,11 +45,12 @@ function kind(path) { function chats(k) { return CHAT.includes(k); } -// A dropped URL to a local path, or "" for anything that is not a file. +// A dropped URL to a local path, or "" for anything that is not a local +// file: file:///path, or file://localhost/path; any other host is remote. function fromUrl(url) { - const s = String(url); - if (!s.startsWith("file://")) return ""; - try { return decodeURIComponent(s.slice(7)); } catch (e) { return ""; } + const m = /^file:\/\/(localhost)?(\/.*)$/.exec(String(url)); + if (!m) return ""; + try { return decodeURIComponent(m[2]); } catch (e) { return ""; } } function file(path) { return { path: path, name: name(path), kind: kind(path) }; } diff --git a/desktop/assistant/Picker.qml b/desktop/assistant/Picker.qml index 1465d1b..6d7b8ad 100644 --- a/desktop/assistant/Picker.qml +++ b/desktop/assistant/Picker.qml @@ -49,13 +49,18 @@ Rectangle { FolderListModel { id: files - folder: "file://" + picker.dir + // Encoded: a raw "#" or "?" in a folder name would end the path. + folder: "file://" + picker.dir.split("/").map(encodeURIComponent).join("/") showDirsFirst: true + // Name filters never apply to folders, so while filtering they would + // sit first and catch Enter. ponytail: no filtering to a folder. + showDirs: field.text === "" showDotAndDotDot: false // Hidden entries only on asking for them. showHidden: field.text.startsWith(".") caseSensitive: false - nameFilters: field.text ? ["*" + field.text + "*"] : ["*"] + // Glob characters are taken out: typed, they are meant literally. + nameFilters: field.text ? ["*" + field.text.replace(/[*?\[\]]/g, "") + "*"] : ["*"] sortCaseSensitive: false } diff --git a/desktop/assistant/test_files.js b/desktop/assistant/test_files.js index a15495b..5ece64f 100644 --- a/desktop/assistant/test_files.js +++ b/desktop/assistant/test_files.js @@ -34,6 +34,8 @@ assert.ok(!F.chats("video") && !F.chats("archive") && !F.chats("other")); assert.strictEqual(F.fromUrl("file:///home/you/My%20Docs/caf%C3%A9.pdf"), "/home/you/My Docs/café.pdf"); assert.strictEqual(F.fromUrl("file:///tmp/a.txt"), "/tmp/a.txt"); assert.strictEqual(F.fromUrl("https://example.org/a.pdf"), ""); +assert.strictEqual(F.fromUrl("file://localhost/tmp/a.pdf"), "/tmp/a.pdf"); +assert.strictEqual(F.fromUrl("file://otherhost/tmp/a.pdf"), ""); assert.strictEqual(F.fromUrl("file:///bad%E0%A4%A"), ""); // malformed escape assert.deepStrictEqual(F.file("/x/y/a b.pdf"), { path: "/x/y/a b.pdf", name: "a b.pdf", kind: "pdf" }); -- cgit v1.2.3