diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-10-16-file-drop.md | 1345 |
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. |
