aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-16 15:28:48 +0200
committerDanilo M. <danix@danix.xyz>2026-10-16 15:28:48 +0200
commitcb16fa6c17298a6f2f4aa390c01ff8eacd53a996 (patch)
tree61841e10189d8ca4576455adc959c49bdf6d8161
parent1f596b4e2c59c1fa069d2b549c1d8ae6d5a876cf (diff)
downloadquickshell-cb16fa6c17298a6f2f4aa390c01ff8eacd53a996.tar.gz
quickshell-cb16fa6c17298a6f2f4aa390c01ff8eacd53a996.zip
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 <noreply@anthropic.com>
-rw-r--r--desktop/assistant/Assistant.qml19
-rw-r--r--desktop/assistant/Card.qml13
-rw-r--r--desktop/assistant/Files.js9
-rw-r--r--desktop/assistant/Picker.qml9
-rw-r--r--desktop/assistant/test_files.js2
-rw-r--r--desktop/island/Island.qml3
6 files changed, 41 insertions, 14 deletions
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" });
diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml
index 4fba033..412cc58 100644
--- a/desktop/island/Island.qml
+++ b/desktop/island/Island.qml
@@ -203,7 +203,8 @@ PanelWindow {
x: win.cx - width / 2
y: 0
z: 10
- width: 260
+ // The sliver's hit width at rest, so it takes no clicks beside it.
+ width: win.dropHold ? 260 : 220
height: win.dropHold ? 100 : 10
DropArea {