# 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 `. 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. // // 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. // // 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. // // 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. // // 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. // // 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. // // 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 `/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 `` 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(["/mixed.pdf", "/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 /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//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.