aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-10-16-file-drop.md1345
1 files changed, 1345 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-file-drop.md b/docs/superpowers/plans/2026-10-16-file-drop.md
new file mode 100644
index 0000000..be755bb
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-16-file-drop.md
@@ -0,0 +1,1345 @@
+# File Drop and Entry Line Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** The canvas opens as one line, gains a file state for staged files, attaches files from an in-card picker, and the island accepts a file drop.
+
+**Architecture:** `Assistant.qml` derives `mode` (entry, file, chat) from the turn model, `staged` and `chatting`, and owns `attach`, `stage`, `unstage` and the 10-minute resume rule. `Card.qml` sizes itself per mode and shows chips, the Chat about it row, the paperclip and the panels. `Picker.qml` browses with `Qt.labs.folderlistmodel`. `Files.js` classifies paths and decodes dropped URLs. `Island.qml` gains a `DropArea` receptor that grows into a bubble during a drag.
+
+**Tech Stack:** Quickshell 0.3.1 QML, `Qt.labs.folderlistmodel`, node for the JS checks.
+
+**Spec:** `docs/superpowers/specs/2026-10-16-file-drop-design.md`; daemon side: desktop-assistant `docs/superpowers/specs/2026-10-16-attachments-design.md` (implemented).
+
+**Conventions:** this repo's `AGENTS.md` first. The process is `qs`: never `pkill -f`, never `pkill -x qs`; stop a check's shell with `qs kill -p <dir>`. No em dashes, no home paths or personal data in committed files, GPLv2 header in new source files, GPG-signed commits with a `Co-Authored-By:` trailer naming the committing model, `feat(assistant): ...` style subjects, master. Visual checks are the user's. `reload.sh` and `assistant.sh` are run by the user.
+
+**Verified while planning:** the files below ran in a scratch harness (a `Card` in a 1x1 click-through window, the canvas pointed at a scratch daemon through `socketPath`) with no QML warnings. Logged: entry mode, card 63 px; after `attach` of a PDF and an image, file mode, 2 staged, 141 px; staging a video set `blocked` and `ask` refused; the picker took the full 700 px and `back()` closed it; the ask moved to chat with both file chips on the turn. The model's answer to that two-file question first came back empty: llama-server's model process died on a 2048x1024 page render, which led to desktop-assistant `b81d322` (renders never upscale; a non-stream answer is an error). Retried after it: "Tuesday, 900 euro, Budget: 900 euro and BLUE 42.", first token at 8.4 s. The island receptor cannot be exercised by a harness (it needs a real drag); it is checked by the user.
+
+**Decided while planning:**
+- A History button sits in the input row in the entry and file states (the header, which has it in chat, is hidden there), so History stays reachable from every state.
+- `Button.qml` gains `property alias font: label.font`, for the paperclip glyph in `Theme.iconFamily`.
+- `Assistant.qml` gains `socketPath`, so a check can point it at a scratch daemon.
+- `files` is a JSON string role in the turn model, for the same reason `tools` is a string.
+- Opening a conversation from History marks it active, so it is not reset on the next SUPER+Home.
+- `Files.js` lists the daemon's text suffixes; a `text/*` file with an unlisted suffix shows as "other" (a ponytail note in the file).
+
+---
+
+### Task 1: File kinds
+
+**Files:**
+- Create: `desktop/assistant/test_files.js`, `desktop/assistant/Files.js`
+
+- [ ] **Step 1: Write the failing test**, `desktop/assistant/test_files.js`:
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// Run: node desktop/assistant/test_files.js
+// Files.js is a QML library, so its .pragma line is stripped before node
+// evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/Files.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const F = new Function(src + "\nreturn { kind, chats, fromUrl, file, name };")();
+
+const cases = {
+ "/a/report.pdf": "pdf", "/a/Report.PDF": "pdf", "/a/x.png": "image", "/a/x.JPEG": "image",
+ "/a/clip.mp4": "video", "/a/c.MKV": "video", "/a/src.tar.gz": "archive", "/a/b.7z": "archive",
+ "/a/main.py": "text", "/a/notes.md": "text", "/a/pkg.SlackBuild": "text",
+ "/a/Makefile": "other", "/a/.bashrc": "other", "/a/x.bin": "other", "/a/dir.d/noext": "other",
+};
+for (const [p, k] of Object.entries(cases)) assert.strictEqual(F.kind(p), k, p);
+
+assert.ok(F.chats("pdf") && F.chats("image") && F.chats("text"));
+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:///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" });
+
+console.log("ok");
+```
+
+- [ ] **Step 2: Run it.** `node desktop/assistant/test_files.js` → expected `ENOENT` for `Files.js`.
+
+- [ ] **Step 3: Write `desktop/assistant/Files.js`:**
+
+```js
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+.pragma library
+
+// What a staged file is, for its chip and the actions offered on it. The
+// text and image lists match desktop-assistant's, which decides what chat
+// accepts; this only decides what the card offers.
+// ponytail: the daemon also takes any text/* mime type; a file of such a
+// type with a suffix not listed here reads as "other" and is not offered
+// chat. Add the suffix when one shows up.
+const TEXT = [".py", ".c", ".h", ".cpp", ".hpp", ".rs", ".go", ".js", ".ts", ".jsx",
+ ".tsx", ".sh", ".bash", ".zsh", ".lua", ".rb", ".pl", ".php", ".java",
+ ".kt", ".swift", ".sql", ".toml", ".yaml", ".yml", ".json", ".ini",
+ ".cfg", ".conf", ".md", ".rst", ".txt", ".csv", ".xml", ".html", ".css",
+ ".diff", ".patch", ".slackbuild", ".info", ".desktop", ".service", ".qml"];
+const IMAGE = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
+const VIDEO = [".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v"];
+const ARCHIVE = [".zip", ".tar", ".gz", ".tgz", ".xz", ".txz", ".bz2", ".zst", ".7z", ".rar"];
+
+// Kinds chat takes; the rest only get local actions.
+const CHAT = ["text", "image", "pdf"];
+
+function name(path) { return path.slice(path.lastIndexOf("/") + 1); }
+
+function kind(path) {
+ const n = name(path).toLowerCase();
+ const dot = n.lastIndexOf(".");
+ const ext = dot > 0 ? n.slice(dot) : "";
+ if (ext === ".pdf") return "pdf";
+ if (IMAGE.includes(ext)) return "image";
+ if (VIDEO.includes(ext)) return "video";
+ if (ARCHIVE.includes(ext)) return "archive";
+ if (TEXT.includes(ext)) return "text";
+ return "other";
+}
+
+function chats(k) { return CHAT.includes(k); }
+
+// A dropped URL to a local path, or "" for anything that is not a file.
+function fromUrl(url) {
+ const s = String(url);
+ if (!s.startsWith("file://")) return "";
+ try { return decodeURIComponent(s.slice(7)); } catch (e) { return ""; }
+}
+
+function file(path) { return { path: path, name: name(path), kind: kind(path) }; }
+```
+
+- [ ] **Step 4: Run it.** `node desktop/assistant/test_files.js` → `ok`.
+
+- [ ] **Step 5: Commit**: `feat(assistant): file kinds and dropped URLs` with a body saying the kinds decide what the card offers while the daemon decides what chat accepts.
+
+---
+
+### Task 2: States, staging and the picker
+
+**Files:**
+- Modify: `desktop/Button.qml`, `desktop/assistant/Assistant.qml`, `Card.qml`, `Message.qml`, `README.md`
+- Create: `desktop/assistant/Picker.qml`
+
+- [ ] **Step 1: `desktop/Button.qml`**, apply:
+
+```diff
+--- a/desktop/Button.qml 2026-09-19 10:06:20.947855764 +0200
++++ b/desktop/Button.qml 2026-10-16 15:14:47.642060245 +0200
+@@ -14,6 +14,7 @@
+ Rectangle {
+ id: btn
+ property alias text: label.text
++ property alias font: label.font
+ property bool danger: false
+ property bool enabled: true
+ signal clicked
+```
+
+- [ ] **Step 2: Replace `desktop/assistant/Assistant.qml` with:**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import QtQuick
+import ".."
+import "Files.js" as Files
+
+// The assistant canvas: a view over desktop-assistant's socket. The daemon
+// owns the model, the tools and the history; this keeps the conversation it
+// was told about, so the card reopens where it was, and sends what is typed.
+// Protocol: desktop-assistant, docs/superpowers/specs/2026-10-16-text-daemon-design.md.
+Scope {
+ id: root
+
+ property string monitor: "DP-1"
+ // The daemon's socket; a check against a scratch daemon points it elsewhere.
+ property string socketPath: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock`
+ property bool open: false
+ property bool historyOpen: false
+ property bool pickerOpen: false
+ property string pickDir: "" // where the picker opened last; "" is home
+
+ // Files waiting to go with the next question: [{path, name, kind}].
+ property var staged: []
+ // Chat about it: the chat layout before any turn exists.
+ property bool chatting: false
+ // When the conversation last moved, for the resume rule in show().
+ property double lastActive: 0
+
+ // entry: one line. file: files staged, nothing asked yet. chat: turns.
+ readonly property string mode: turns.count || root.chatting ? "chat"
+ : root.staged.length ? "file" : "entry"
+ // A staged kind chat does not take holds the question back.
+ readonly property bool blocked: root.staged.some(f => !Files.chats(f.kind))
+
+ readonly property bool connected: link.item?.connected ?? false
+ property string state: "idle"
+ property string model: ""
+ property int ctxUsed: 0
+ property int ctxMax: 0
+ property var convId: null
+ property string notice: "" // an error with no turn to hang it on
+ property string runningTool: ""
+ property var convs: []
+ property var results: []
+ 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 = ""; }
+
+ // 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
+ // setProperty cannot append to.
+ ListModel { id: turns }
+ readonly property alias turnsModel: turns // read by the load check harness
+
+ // Tokens arrive faster than markdown is worth re-rendering, so they
+ // collect here and reach the model on a 50 ms tick.
+ property string bufReply: ""
+ property string bufThink: ""
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ function toggle() { if (root.open) root.close(); else root.show(); }
+
+ // 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();
+ root.open = true;
+ }
+
+ function close() { root.open = false; root.historyOpen = false; root.pickerOpen = false; }
+ // Escape: the picker, then the history panel, then the card.
+ function back() {
+ if (root.pickerOpen) root.pickerOpen = false;
+ else if (root.historyOpen) root.historyOpen = false;
+ else root.close();
+ }
+
+ function fresh() {
+ root.send({ type: "new" });
+ root.chatting = false;
+ }
+
+ // A drop is a new task: a new conversation with these files staged.
+ function attach(paths) {
+ const files = paths.filter(p => p !== "").map(Files.file);
+ if (!files.length) return;
+ root.fresh();
+ root.staged = files;
+ root.historyOpen = root.pickerOpen = false;
+ root.open = true;
+ }
+
+ function stage(path) {
+ if (!root.staged.some(f => f.path === path)) root.staged = root.staged.concat([Files.file(path)]);
+ }
+ function unstage(i) {
+ const s = root.staged.slice();
+ s.splice(i, 1);
+ root.staged = s;
+ }
+
+ function send(cmd) {
+ if (!link.item) return;
+ link.item.write(JSON.stringify(cmd) + "\n");
+ link.item.flush();
+ }
+
+ function ask(text) {
+ if (!root.connected || root.blocked || (text.trim() === "" && !root.staged.length)) return false;
+ root.notice = "";
+ root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) });
+ root.staged = [];
+ return true;
+ }
+
+ function showHistory() {
+ root.historyOpen = true;
+ root.send({ type: "list" });
+ }
+
+ function row(t, live) {
+ return { user: t.user ?? "", reply: t.reply ?? "", think: t.think ?? "",
+ tools: (t.tools ?? []).join(","), error: "", live: live,
+ // JSON for the same reason tools is a string.
+ files: JSON.stringify(t.files ?? []),
+ stopped: false, expanded: false };
+ }
+
+ function load(id, list, pending) {
+ root.bufReply = root.bufThink = "";
+ turns.clear();
+ for (const t of list) turns.append(root.row(t, false));
+ if (pending) turns.append(root.row(pending, true));
+ root.convId = id;
+ root.notice = "";
+ }
+
+ function last() { return turns.count ? turns.get(turns.count - 1) : null; }
+
+ function flush() {
+ const t = root.last();
+ if (!t || !t.live) { root.bufReply = root.bufThink = ""; return; }
+ if (root.bufThink) turns.setProperty(turns.count - 1, "think", t.think + root.bufThink);
+ if (root.bufReply) turns.setProperty(turns.count - 1, "reply", t.reply + root.bufReply);
+ root.bufReply = root.bufThink = "";
+ }
+
+ function handle(line) {
+ let e;
+ try { e = JSON.parse(line); } catch (err) { return; }
+ const i = turns.count - 1;
+ switch (e.type) {
+ case "hello":
+ root.state = e.state;
+ root.runningTool = e.state === "tool" ? e.tool : "";
+ root.model = e.model ?? "";
+ 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();
+ 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();
+ break;
+ case "user":
+ turns.append(root.row({ user: e.text, files: e.files }, true));
+ root.lastActive = Date.now();
+ break;
+ case "think": root.bufThink += e.delta; break;
+ case "token": root.bufReply += e.delta; break;
+ case "tool":
+ root.flush();
+ if (!e.done && i >= 0 && turns.get(i).live) {
+ const t = turns.get(i).tools;
+ turns.setProperty(i, "tools", t ? t + "," + e.name : e.name);
+ }
+ break;
+ case "done":
+ root.flush();
+ if (i >= 0 && turns.get(i).live) {
+ turns.setProperty(i, "live", false);
+ turns.setProperty(i, "stopped", e.stopped === true);
+ }
+ if (!e.stopped) root.convId = e.id;
+ root.lastActive = Date.now();
+ break;
+ case "error":
+ // Only an error marked turn ended the reply. A command error,
+ // perhaps another client's, leaves a streaming reply alone.
+ if (e.turn && i >= 0 && turns.get(i).live) {
+ root.flush();
+ turns.setProperty(i, "error", e.message);
+ turns.setProperty(i, "live", false);
+ } else {
+ root.notice = e.message;
+ }
+ break;
+ case "state":
+ root.state = e.state;
+ root.runningTool = e.state === "tool" ? e.tool : "";
+ break;
+ case "ctx": root.ctxUsed = e.used; root.ctxMax = e.max; break;
+ case "conversations": root.convs = e.items; break;
+ case "results": root.results = e.items; break;
+ }
+ }
+
+ Timer {
+ interval: 50
+ repeat: true
+ running: root.bufReply !== "" || root.bufThink !== ""
+ onTriggered: root.flush()
+ }
+
+ // The daemon may start after this shell, or restart: keep trying with a
+ // fresh Socket each time. Quickshell 0.3.1 keeps the QLocalSocket of a
+ // failed connect, so setting `connected` again on the same Socket never
+ // retries. See AGENTS.md.
+ LazyLoader {
+ id: link
+ active: true
+ Socket {
+ path: root.socketPath
+ connected: true
+ parser: SplitParser { onRead: line => root.handle(line) }
+ }
+ }
+
+ Timer {
+ interval: 2000
+ repeat: true
+ running: !root.connected
+ onTriggered: { link.active = false; link.active = true; }
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ screen: root.screenObj
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: Qt.alpha(Theme.crust, 0.35)
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-assistant"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ MouseArea { anchors.fill: parent; onClicked: root.close() }
+
+ Card {
+ anchors.centerIn: parent
+ width: Math.min(760, parent.width - 48)
+ fullHeight: Math.round(parent.height * 0.7)
+ canvas: root
+ turnModel: turns
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Replace `desktop/assistant/Card.qml` with:**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import ".."
+import "Files.js" as Files
+
+// The card in its three states (Assistant.qml, mode): entry is the input
+// line alone; file adds the staged chips and what can be done with them;
+// chat adds the header and the transcript. A panel (history, picker) takes
+// the full height whatever the state. All state lives in the canvas, which
+// outlives this card, so closing and reopening loses nothing.
+Rectangle {
+ id: card
+ property var canvas
+ property var turnModel
+ property real fullHeight: 600 // the chat state's height, set by the window
+
+ readonly property string mode: card.canvas.mode
+ readonly property bool panel: card.canvas.historyOpen || card.canvas.pickerOpen
+ readonly property bool tall: card.mode === "chat" || card.panel
+
+ // Bottom-up: the input, then the actions (file state), then the chips.
+ height: card.tall ? card.fullHeight
+ : footer.height + 28 + (chips.visible ? chips.height + 10 : 0)
+ + (actions.visible ? actions.height + 10 : 0)
+ Behavior on height { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } }
+ clip: true
+
+ color: Qt.alpha(Theme.base, 0.82)
+ radius: 14
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ // Keys reach a focused item, never the window (AGENTS.md); the card is
+ // the ancestor of every text field, so their Escape bubbles here.
+ Keys.onEscapePressed: card.canvas.back()
+
+ // Clicks on the card must not reach the close-on-click catcher behind it.
+ MouseArea { anchors.fill: parent }
+
+ Component.onCompleted: input.forceActiveFocus()
+
+ // Focus falls out of the card when its holder goes: a panel's field
+ // hiding, or a streaming reply's text rebuilt under a click. With the
+ // keyboard grabbed, nothing would then reach Escape. Give it back.
+ Connections {
+ target: card.Window.window
+ function onActiveFocusItemChanged() {
+ const f = card.Window.activeFocusItem;
+ if (!f || f === card.Window.contentItem) Qt.callLater(() => input.forceActiveFocus());
+ }
+ }
+ Connections {
+ target: card.canvas
+ function onHistoryOpenChanged() { if (!card.canvas.historyOpen) input.forceActiveFocus(); }
+ function onPickerOpenChanged() { if (!card.canvas.pickerOpen) input.forceActiveFocus(); }
+ function onChattingChanged() { input.forceActiveFocus(); }
+ }
+
+ // --- header (chat) ---
+
+ Item {
+ id: header
+ visible: card.mode === "chat"
+ anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 }
+ height: 32
+
+ Text {
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ text: !card.canvas.connected ? "assistant not running"
+ : card.canvas.model || "assistant"
+ color: card.canvas.connected ? Theme.text : Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+
+ Row {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ // How full the model's context is, as llamachat shows it: amber
+ // at 75%, red at 90%.
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.ctxMax > 0
+ readonly property real used: card.canvas.ctxUsed / Math.max(1, card.canvas.ctxMax)
+ text: `${card.canvas.ctxUsed} / ${card.canvas.ctxMax}`
+ color: used >= 0.9 ? Theme.red : used >= 0.75 ? Theme.yellow : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Button {
+ text: "History"
+ onClicked: card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory()
+ }
+ Button {
+ text: "New"
+ enabled: card.canvas.connected
+ onClicked: { card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); }
+ }
+ }
+ }
+
+ // --- transcript (chat) ---
+
+ ListView {
+ id: list
+ visible: card.mode === "chat"
+ anchors {
+ top: header.bottom; topMargin: 8
+ bottom: chips.visible ? chips.top : footer.top; bottomMargin: 8
+ left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+ clip: true
+ spacing: 14
+ boundsBehavior: Flickable.StopAtBounds
+ model: card.turnModel
+
+ // Follow the bottom while a reply streams, unless the user scrolled
+ // up to read; scrolling back to the end resumes following.
+ property bool stick: true
+ onMovementStarted: stick = false
+ onMovementEnded: stick = atYEnd
+ onContentHeightChanged: if (stick) Qt.callLater(positionViewAtEnd)
+ Component.onCompleted: positionViewAtEnd()
+
+ delegate: Message {
+ width: list.width
+ running: card.canvas.runningTool
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: list.count === 0
+ text: card.canvas.connected ? "Ask anything." : "Start it with assistant.sh start."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize }
+ }
+ }
+
+ // --- staged files (file, chat) ---
+
+ Flow {
+ id: chips
+ visible: card.canvas.staged.length > 0
+ anchors {
+ bottom: actions.visible ? actions.top : footer.top; bottomMargin: 10
+ left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+ spacing: 6
+
+ Repeater {
+ model: card.canvas.staged
+ Rectangle {
+ id: chip
+ required property var modelData
+ required property int index
+ readonly property bool ok: Files.chats(modelData.kind)
+ width: label.implicitWidth + cross.implicitWidth + 24
+ height: label.implicitHeight + 10
+ radius: height / 2
+ color: Qt.alpha(Theme.surface, 0.9)
+ border.width: 1
+ border.color: Qt.alpha(chip.ok ? Theme.text : Theme.red, 0.25)
+
+ Text {
+ id: label
+ anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: `${chip.modelData.kind} · ${chip.modelData.name}`
+ color: chip.ok ? Theme.text : Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ id: cross
+ anchors { right: parent.right; rightMargin: 9; verticalCenter: parent.verticalCenter }
+ text: "×"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ MouseArea {
+ anchors { fill: parent; margins: -4 }
+ cursorShape: Qt.PointingHandCursor
+ onClicked: card.canvas.unstage(chip.index)
+ }
+ }
+ }
+ }
+ }
+
+ // --- what to do with them (file) ---
+ // Piece 1 offers Chat about it; the Ask and Do rows (model prompts and
+ // local operations) are their own specs and land here.
+
+ Row {
+ id: actions
+ visible: card.mode === "file"
+ anchors { bottom: footer.top; bottomMargin: 10; left: parent.left; leftMargin: 14 }
+ spacing: 10
+
+ Button {
+ text: "Chat about it"
+ enabled: card.canvas.connected && !card.canvas.blocked
+ onClicked: card.canvas.chatting = true
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.canvas.blocked
+ text: "chat supports text, images and PDF"
+ color: Theme.red
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ }
+
+ // --- input ---
+
+ Item {
+ id: footer
+ anchors { bottom: parent.bottom; left: parent.left; right: parent.right; margins: 14 }
+ height: notice.height + box.height + (notice.visible ? 6 : 0)
+
+ Text {
+ id: notice
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ visible: card.canvas.notice !== ""
+ height: visible ? implicitHeight : 0
+ text: card.canvas.notice
+ color: Theme.red
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ // The paperclip, nf-fa-paperclip, checked in Inconsolata Nerd Font.
+ Button {
+ id: attach
+ visible: card.mode !== "entry"
+ width: visible ? implicitWidth : 0
+ anchors { bottom: parent.bottom; left: parent.left }
+ height: box.height
+ text: ""
+ font.family: Theme.iconFamily
+ onClicked: card.canvas.pickerOpen = !card.canvas.pickerOpen
+ }
+
+ Rectangle {
+ id: box
+ anchors {
+ bottom: parent.bottom; left: attach.right; leftMargin: attach.visible ? 8 : 0
+ right: history.left; rightMargin: 8
+ }
+ // Grows with the text up to six lines, then scrolls.
+ height: Math.min(input.contentHeight, 6 * input.font.pixelSize * 1.4) + 16
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.9)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, input.activeFocus ? 0.3 : 0.15)
+
+ Flickable {
+ id: scroller
+ anchors { fill: parent; margins: 8 }
+ contentHeight: input.contentHeight
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ function follow(r) {
+ if (r.y < contentY) contentY = r.y;
+ else if (r.y + r.height > contentY + height) contentY = r.y + r.height - height;
+ }
+
+ TextEdit {
+ id: input
+ width: scroller.width
+ wrapMode: TextEdit.Wrap
+ selectByMouse: true
+ // readOnly, not disabled: a disabled item cannot hold the
+ // focus that Escape needs to reach the card.
+ readOnly: !card.canvas.connected
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ onCursorRectangleChanged: scroller.follow(cursorRectangle)
+
+ // Enter sends, Shift+Enter is a newline: an unaccepted
+ // key goes on to the TextEdit, which inserts it.
+ function submit(event) {
+ if (event.modifiers & Qt.ShiftModifier) { event.accepted = false; return; }
+ if (card.canvas.ask(text)) text = "";
+ }
+ Keys.onReturnPressed: event => submit(event)
+ Keys.onEnterPressed: event => submit(event)
+ }
+ }
+
+ Text {
+ 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?"
+ : card.canvas.staged.length ? `Ask about ${card.canvas.staged[0].name}…`
+ : "Message the assistant"
+ color: Theme.overlay
+ font: input.font
+ }
+ }
+
+ // History from every state; in chat the header has it too.
+ Button {
+ id: history
+ visible: card.mode !== "chat"
+ width: visible ? implicitWidth : 0
+ anchors { bottom: parent.bottom; right: send.left; rightMargin: visible ? 8 : 0 }
+ height: box.height
+ text: "History"
+ onClicked: card.canvas.historyOpen ? card.canvas.back() : card.canvas.showHistory()
+ }
+
+ Button {
+ id: send
+ anchors { bottom: parent.bottom; right: parent.right }
+ height: box.height
+ text: card.canvas.busy ? "Stop" : "Send"
+ danger: card.canvas.busy
+ enabled: card.canvas.connected && (card.canvas.busy || !card.canvas.blocked)
+ onClicked: {
+ if (card.canvas.busy) card.canvas.send({ type: "stop" });
+ else if (card.canvas.ask(input.text)) input.text = "";
+ input.forceActiveFocus();
+ }
+ }
+ }
+
+ // --- panels, over everything above the input ---
+
+ Item {
+ id: panelArea
+ anchors {
+ top: parent.top; topMargin: 14; bottom: chips.visible ? chips.top : footer.top
+ bottomMargin: 8; left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
+ }
+
+ History {
+ anchors.fill: parent
+ visible: card.canvas.historyOpen
+ canvas: card.canvas
+ }
+ Picker {
+ anchors.fill: parent
+ visible: card.canvas.pickerOpen
+ canvas: card.canvas
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Replace `desktop/assistant/Message.qml` with:**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import ".."
+
+// One turn: the question, then the reply with its thinking line, the tool
+// chips, and an error or stopped mark. Both texts go through Segments, so
+// the question renders as markdown too.
+Column {
+ id: msg
+ required property int index
+ required property string user
+ required property string reply
+ required property string think
+ required property string tools
+ required property string files // JSON: [{name, kind, pages, truncated}]
+ required property string error
+ required property bool live
+ required property bool stopped
+ required property bool expanded
+ required property var model // the row, for setting expanded
+ property string running: "" // the tool running now, if any
+
+ spacing: 6
+
+ Rectangle {
+ width: msg.width
+ height: question.height + 16
+ radius: 8
+ color: Qt.alpha(Theme.surface, 0.7)
+ Segments {
+ id: question
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 }
+ text: msg.user
+ }
+ }
+
+ // The files that went with the question, as the daemon read them.
+ Flow {
+ width: msg.width
+ spacing: 6
+ visible: msg.files !== "[]"
+ Repeater {
+ model: JSON.parse(msg.files)
+ Rectangle {
+ required property var modelData
+ width: f.implicitWidth + 16
+ height: f.implicitHeight + 6
+ radius: height / 2
+ color: Qt.alpha(Theme.surface, 0.6)
+ Text {
+ id: f
+ anchors.centerIn: parent
+ text: parent.modelData.name
+ + (parent.modelData.pages ? ` · ${parent.modelData.pages} pages` : "")
+ + (parent.modelData.truncated ? " · truncated" : "")
+ color: parent.modelData.truncated ? Theme.yellow : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+ }
+ }
+
+ // Collapsed by default; a click shows the reasoning as plain text.
+ Text {
+ visible: msg.think !== ""
+ text: (msg.expanded ? "▾ " : "▸ ") + (msg.live && msg.reply === "" ? "thinking…" : "thinking")
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: msg.model.expanded = !msg.expanded
+ }
+ }
+ Text {
+ width: msg.width
+ visible: msg.expanded && msg.think !== ""
+ text: msg.think
+ textFormat: Text.PlainText
+ wrapMode: Text.Wrap
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+
+ Flow {
+ width: msg.width
+ spacing: 6
+ visible: msg.tools !== ""
+ Repeater {
+ model: msg.tools ? msg.tools.split(",") : []
+ Rectangle {
+ required property string modelData
+ readonly property bool on: msg.live && msg.running === modelData
+ width: chip.implicitWidth + 14
+ height: chip.implicitHeight + 6
+ radius: height / 2
+ color: Qt.alpha(Theme.surface, 0.9)
+ opacity: on ? 1 : 0.55
+ Text {
+ id: chip
+ anchors.centerIn: parent
+ text: parent.modelData
+ color: parent.on ? Theme.text : Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 }
+ }
+ }
+ }
+ }
+
+ Segments {
+ width: msg.width
+ text: msg.reply
+ visible: msg.reply !== ""
+ }
+
+ Text {
+ visible: msg.error !== "" || msg.stopped
+ width: msg.width
+ text: msg.error !== "" ? msg.error : "stopped"
+ color: msg.error !== "" ? Theme.red : Theme.overlay
+ wrapMode: Text.Wrap
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+}
+```
+
+- [ ] **Step 5: Create `desktop/assistant/Picker.qml`:**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import QtQuick
+import QtQuick.Controls
+import Qt.labs.folderlistmodel
+import ".."
+
+// A file picker drawn in the card. A system dialog cannot be used: the card
+// is an overlay layer surface holding the keyboard, so a dialog window would
+// open beneath it, out of reach. Click a file to stage it and close,
+// Ctrl+click to stage and stay; the filter narrows this directory.
+Rectangle {
+ id: picker
+ property var canvas
+
+ readonly property string home: Quickshell.env("HOME") || "/"
+ property string dir: home
+
+ color: Qt.alpha(Theme.base, 0.97)
+ radius: 8
+
+ onVisibleChanged: if (visible) {
+ dir = canvas.pickDir || home;
+ field.text = "";
+ field.forceActiveFocus();
+ }
+
+ MouseArea { anchors.fill: parent } // keep clicks off what is beneath
+
+ function enter(path) { dir = path; field.text = ""; rows.currentIndex = 0; }
+ function up() { if (dir !== "/") enter(dir.slice(0, dir.lastIndexOf("/")) || "/"); }
+ function choose(i, keep) {
+ const isDir = files.get(i, "fileIsDir"), path = files.get(i, "filePath");
+ if (isDir) { enter(path); return; }
+ canvas.pickDir = dir;
+ canvas.stage(path);
+ if (!keep) canvas.pickerOpen = false;
+ }
+
+ FolderListModel {
+ id: files
+ folder: "file://" + picker.dir
+ showDirsFirst: true
+ showDotAndDotDot: false
+ // Hidden entries only on asking for them.
+ showHidden: field.text.startsWith(".")
+ caseSensitive: false
+ nameFilters: field.text ? ["*" + field.text + "*"] : ["*"]
+ sortCaseSensitive: false
+ }
+
+ Row {
+ id: bar
+ anchors { top: parent.top; left: parent.left; right: parent.right }
+ spacing: 6
+ Button { text: "Home"; onClicked: picker.enter(picker.home) }
+ Button { text: "Up"; enabled: picker.dir !== "/"; onClicked: picker.up() }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: bar.width - x
+ text: picker.dir.startsWith(picker.home) ? "~" + picker.dir.slice(picker.home.length) : picker.dir
+ elide: Text.ElideLeft
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ }
+
+ TextField {
+ id: field
+ anchors { top: bar.bottom; topMargin: 8; left: parent.left; right: parent.right }
+ height: 32
+ placeholderText: "Filter"
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ background: Rectangle {
+ radius: 6
+ color: Qt.alpha(Theme.surface, 0.9)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ }
+ onTextChanged: rows.currentIndex = 0
+ Keys.onUpPressed: rows.decrementCurrentIndex()
+ Keys.onDownPressed: rows.incrementCurrentIndex()
+ Keys.onReturnPressed: event => { if (rows.count) picker.choose(rows.currentIndex, event.modifiers & Qt.ControlModifier); }
+ Keys.onEnterPressed: event => { if (rows.count) picker.choose(rows.currentIndex, event.modifiers & Qt.ControlModifier); }
+ }
+
+ ListView {
+ id: rows
+ anchors { top: field.bottom; topMargin: 8; left: parent.left; right: parent.right; bottom: parent.bottom }
+ clip: true
+ spacing: 2
+ boundsBehavior: Flickable.StopAtBounds
+ model: files
+ highlightMoveDuration: 0
+
+ delegate: Rectangle {
+ id: r
+ required property int index
+ required property string fileName
+ required property bool fileIsDir
+ readonly property bool staged: picker.canvas.staged.some(f => f.path === picker.dir.replace(/\/$/, "") + "/" + r.fileName)
+ width: rows.width
+ height: name.implicitHeight + 10
+ radius: 6
+ color: rows.currentIndex === r.index ? Qt.alpha(Theme.accent, 0.18)
+ : hover.hovered ? Qt.alpha(Theme.surface, 0.9) : "transparent"
+
+ HoverHandler { id: hover }
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: mouse => { rows.currentIndex = r.index; picker.choose(r.index, mouse.modifiers & Qt.ControlModifier); }
+ }
+ Text {
+ id: name
+ anchors { left: parent.left; leftMargin: 8; right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ text: (r.fileIsDir ? r.fileName + "/" : r.fileName) + (r.staged ? " ✓" : "")
+ elide: Text.ElideMiddle
+ color: r.fileIsDir ? Theme.accent : Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: rows.count === 0
+ text: files.status === FolderListModel.Loading ? "Loading…" : "Nothing here"
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Replace `desktop/assistant/README.md` with:**
+
+```markdown
+# assistant
+
+The assistant canvas: a centred card on DP-1, on SUPER+Home, for typed
+conversations with the local text assistant. It is only a view.
+desktop-assistant (the repo next to this one) is the daemon that owns the
+model, the tools and the history; this connects to its socket,
+`$XDG_RUNTIME_DIR/desktop-assistant.sock`, draws what it is told and sends
+what is typed. The protocol is specified in that repo,
+`docs/superpowers/specs/2026-10-16-text-daemon-design.md`.
+
+ qs -p ./desktop ipc call assistant toggle
+
+| File | Does |
+| --- | --- |
+| `Assistant.qml` | the socket, the turn model, the window |
+| `Card.qml` | header, transcript, input, the history panel's place |
+| `Message.qml` | one turn: question, thinking line, tool chips, reply |
+| `Segments.qml` | markdown with a copy button per code block |
+| `History.qml` | conversation list and full-text search |
+| `Picker.qml` | the in-card file picker behind the paperclip |
+| `Md.js` | splits a message into prose and fenced code |
+| `Files.js` | a path's kind, and a dropped URL to a path |
+
+Three states (`Assistant.qml`, `mode`): **entry**, one line, "How can I
+help you?"; **file**, files staged and nothing asked yet, with what can be
+done with them; **chat**, the transcript. SUPER+Home resumes a conversation
+active in the last 10 minutes and otherwise starts a new one. A file
+dropped on the island (`../island/`) starts a new one in the file state.
+The paperclip opens a picker drawn in the card: a system dialog would open
+beneath the overlay that holds the keyboard.
+
+Keys: Enter sends, Shift+Enter is a newline, Escape closes the picker, then
+the history panel, then the card. Stop replaces Send while a reply streams.
+
+Notes:
+
+- **The state outlives the card.** The socket and the turn model live in
+ `Assistant.qml`, outside the window's loader, so closing the card loses
+ nothing, and a reply keeps streaming into it while it is closed.
+- **Tokens are batched.** They collect in a buffer and reach the model on a
+ 50 ms tick, so markdown is re-rendered twenty times a second at most
+ rather than per token.
+- **Prose goes through `Notify.sanitize`**, which blocks remote images.
+ Model output can quote a search result, and an image URL in it would be
+ fetched the moment it rendered.
+- **A code block's copy button appears once the fence closes.** A
+ half-streamed block is not the command the model meant.
+- **The daemon may be down.** The card says so and disables the input; the
+ socket retries every 2 s with a fresh `Socket` (see AGENTS.md), logging a
+ `ServerNotFoundError` warning each time.
+- **`tools` is a comma-joined string role.** A ListModel role holding an
+ array becomes a nested model, which `setProperty` cannot append to.
+- **A stopped reply is shown, not kept.** The daemon does not save a
+ partial turn, so it is gone after the next reopen or restart.
+- **Only an error marked `turn` ends a reply.** Every client sees every
+ event, so a command error (another canvas opening a deleted
+ conversation) shows above the input instead.
+- **Search snippets** mark matches with `**`, rendered as bold; backticks
+ are stripped first, since a match inside a code span would show the
+ markers literally.
+
+- **File kinds are decided twice.** `Files.js` decides what the card
+ offers; the daemon decides what chat accepts. The lists match, but the
+ daemon also takes any `text/*` file, which the card may show as "other".
+- **`socketPath`** is a property so a check can point the canvas at a
+ scratch daemon; the desktop uses the default.
+
+Test the splitter and the kinds: `node desktop/assistant/test_md.js`,
+`node desktop/assistant/test_files.js`.
+```
+
+- [ ] **Step 7: Load check.** Build `<scratchpad>/harness2` mirroring `desktop/` with links (`Theme.qml`, `Glow.qml`, `Notify.qml` from `shared/`, `Button.qml` from `desktop/`, `assistant` to `desktop/assistant`), and this `shell.qml`, with `<D>` replaced by a directory holding `mixed.pdf` (a text page and an image-only page) and `sign.png`:
+
+```qml
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+import "assistant"
+
+// Prototype harness: drives the canvas over a scratch daemon, with a Card
+// in a 1x1 window so every component resolves and nothing shows.
+ShellRoot {
+ Assistant { id: a; socketPath: "/tmp/claude-1000/dar/desktop-assistant.sock" }
+
+ PanelWindow {
+ anchors { top: true; left: true }
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ mask: Region {}
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ Card { id: c; width: 760; fullHeight: 700; canvas: a; turnModel: a.turnsModel }
+ }
+
+ property int step: 0
+ Timer {
+ interval: 500; repeat: true; running: true
+ onTriggered: {
+ if (!a.connected) return;
+ if (step === 0) {
+ console.log("PROTO entry mode=" + a.mode + " h=" + c.height);
+ a.attach(["<D>/mixed.pdf", "<D>/sign.png"]); step = 1;
+ } else if (step === 1) {
+ console.log("PROTO file mode=" + a.mode + " staged=" + a.staged.length + " h=" + c.height);
+ a.stage("/tmp/clip.mp4");
+ console.log("PROTO blocked=" + a.blocked + " ask=" + a.ask("x"));
+ a.unstage(2);
+ console.log("PROTO unblocked=" + a.blocked);
+ a.pickerOpen = true; step = 2;
+ } else if (step === 2) {
+ console.log("PROTO picker h=" + c.height); a.back();
+ console.log("PROTO picker closed=" + !a.pickerOpen);
+ a.ask("Meeting day, budget, and the text in the image? One line."); step = 3;
+ } else if (step === 3 && !a.busy && a.turnsModel.count === 1 && !a.turnsModel.get(0).live) {
+ const t = a.turnsModel.get(0);
+ console.log("PROTO chat mode=" + a.mode + " files=" + t.files + " reply=" + JSON.stringify(t.reply) + " h=" + c.height);
+ step = 4;
+ }
+ }
+ }
+}
+```
+
+Run a scratch daemon on a short socket directory (`/tmp/claude-1000/dar`; AF_UNIX paths stop at 108 bytes) with a scratch `XDG_DATA_HOME`, then `qs -p <scratchpad>/harness2`, both as harness-owned background commands. Expected: the `PROTO` lines `entry mode=entry`, `file mode=file staged=2`, `blocked=true ask=false`, `unblocked=false`, `picker h=700`, `picker closed=true`, `chat mode=chat files=[...two chips...]`, and no QML warnings. The chat line's reply needs the model; ask the user before that step loads it. Stop both afterwards (`qs kill -p`, the daemon by PID).
+
+- [ ] **Step 8: Commit**: `feat(assistant): entry line, file state, staged files and a picker`, body covering the three states, the resume rule, why the picker is drawn in the card, and the Button font alias.
+
+---
+
+### Task 3: The island receptacle
+
+**Files:**
+- Modify: `desktop/island/Island.qml`, `desktop/island/README.md`, `desktop/shell.qml`
+
+- [ ] **Step 1: `desktop/island/Island.qml`**, apply:
+
+```diff
+--- a/desktop/island/Island.qml 2026-10-16 11:43:22.237810713 +0200
++++ b/desktop/island/Island.qml 2026-10-16 15:22:09.919668155 +0200
+@@ -17,6 +17,7 @@
+ import ".."
+ import "../modules/sound"
+ import "Logic.js" as Logic
++import "../assistant/Files.js" as Files
+
+ // The island: a sliver at the top centre of DP-1 that the two pills slide
+ // out of. The window is a fixed overlay strip that never resizes; the pills
+@@ -27,6 +28,7 @@
+
+ required property QtObject sound // the sound module's Service
+ required property QtObject drawer // Drawer, for the sound page link
++ property QtObject asst: null // the assistant canvas, for dropped files
+
+ screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0]
+ anchors { top: true; left: true; right: true }
+@@ -147,6 +149,7 @@
+
+ mask: Region {
+ item: sliverHit
++ Region { item: receptor; intersection: Intersection.Combine }
+ Region { item: clock; intersection: Intersection.Combine }
+ Region { item: status; intersection: Intersection.Combine }
+ Region { item: media.visible ? media : null; intersection: Intersection.Combine }
+@@ -187,6 +190,57 @@
+ hoverEnabled: true
+ }
+
++ // --- file drop ---------------------------------------------------------
++
++ // A file dragged onto the sliver opens a bubble to drop it on; the drop
++ // hands it to the assistant canvas. Wayland reports a drag only once it
++ // enters an input region, so the receptor is the sliver's width at rest
++ // and grows to hold the bubble while a drag is over it.
++ property bool dropHold: false
++
++ Item {
++ id: receptor
++ x: win.cx - width / 2
++ y: 0
++ z: 10
++ width: 260
++ height: win.dropHold ? 100 : 10
++
++ DropArea {
++ anchors.fill: parent
++ enabled: win.asst !== null
++ onEntered: drag => {
++ drag.accepted = drag.hasUrls && (drag.supportedActions & Qt.CopyAction);
++ if (drag.accepted) { win.dropHold = true; drag.accept(Qt.CopyAction); }
++ }
++ onExited: win.dropHold = false
++ onDropped: drop => {
++ win.dropHold = false;
++ const paths = drop.urls.map(u => Files.fromUrl(u)).filter(p => p !== "");
++ if (!paths.length) { drop.accepted = false; return; }
++ win.asst.attach(paths);
++ drop.accept(Qt.CopyAction);
++ }
++ }
++
++ Rectangle {
++ visible: win.dropHold
++ anchors { horizontalCenter: parent.horizontalCenter; top: parent.top; topMargin: 14 }
++ width: parent.width
++ height: 72
++ radius: 16
++ color: Qt.alpha(Theme.base, 0.9)
++ border.width: 1
++ border.color: Qt.alpha(Theme.accent, 0.6)
++ Text {
++ anchors.centerIn: parent
++ text: "Drop for the assistant"
++ color: Theme.text
++ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
++ }
++ }
++ }
++
+ // Both pills start overlapped at the sliver, above the screen edge, and
+ // travel down and apart. At reveal 1 the clock ends gap/2 left of the
+ // centre and the status gap/2 right of it; the status pill's left edge
+```
+
+- [ ] **Step 2: `desktop/shell.qml`**: in the `Island { ... }` block add the line `asst: assistant` after `drawer: drawer`. Never name the property after the canvas's id (AGENTS.md: it would bind to itself).
+
+- [ ] **Step 3: `desktop/island/README.md`**: append:
+
+```markdown
+## File drop
+
+A file dragged onto the sliver opens a bubble, "Drop for the assistant";
+dropping on it hands the file to the assistant canvas, which opens in its
+file state with a new conversation. Wayland reports a drag only once it
+enters one of the surface's input regions, so the receptor is the sliver's
+width at rest and grows to hold the bubble while a drag is over it. Only
+`file://` URLs are taken.
+```
+
+- [ ] **Step 4: Commit**: `feat(island): take a file drop for the assistant`.
+
+---
+
+### Task 4: Handover and the user's check
+
+- [ ] **Step 1:** Push. Ask the user to run, from their terminal, `~/Programming/GIT/desktop-assistant/assistant.sh restart` (the running daemon predates attachments) and `~/Programming/GIT/quickshell/reload.sh desktop` (new files in `desktop/assistant/`).
+- [ ] **Step 2:** Read the restarted desktop shell's log (`/run/user/<uid>/quickshell/by-id/*/log.qslog` of the newest desktop instance, through `qs log` if available) for `ReferenceError`, `TypeError`, `is not a type`, `Cannot assign`.
+- [ ] **Step 3:** The user checks: SUPER+Home after a long pause shows the one line; a question grows it into the chat; within 10 minutes SUPER+Home resumes it. Dragging a file over the sliver shows the bubble; dropping opens the file state with its chip; × returns to entry; Chat about it moves to the chat layout with the chip; asking about a PDF and an image answers from both; the transcript shows the file chips; a video chip disables Send with the note; the paperclip picker stages by click and by Ctrl+click; Escape closes picker, then History, then the card.
+- [ ] **Step 4:** Mark both specs implemented and commit.