aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/Button.qml1
-rw-r--r--desktop/assistant/Assistant.qml80
-rw-r--r--desktop/assistant/Card.qml170
-rw-r--r--desktop/assistant/Message.qml27
-rw-r--r--desktop/assistant/Picker.qml143
-rw-r--r--desktop/assistant/README.md23
6 files changed, 415 insertions, 29 deletions
diff --git a/desktop/Button.qml b/desktop/Button.qml
index 3e0d63f..b93a926 100644
--- a/desktop/Button.qml
+++ b/desktop/Button.qml
@@ -14,6 +14,7 @@ import QtQuick
Rectangle {
id: btn
property alias text: label.text
+ property alias font: label.font
property bool danger: false
property bool enabled: true
signal clicked
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index d143219..b038dd6 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -14,6 +14,7 @@ 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
@@ -23,8 +24,25 @@ 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"
@@ -55,10 +73,46 @@ Scope {
readonly property var screenObj:
Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
- function toggle() { if (root.open) root.close(); else root.open = true; }
- function close() { root.open = false; root.historyOpen = false; }
- // Escape: the history panel first, then the card.
- function back() { if (root.historyOpen) root.historyOpen = false; else root.close(); }
+ 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;
@@ -67,9 +121,10 @@ Scope {
}
function ask(text) {
- if (!root.connected || text.trim() === "") return false;
+ if (!root.connected || root.blocked || (text.trim() === "" && !root.staged.length)) return false;
root.notice = "";
- root.send({ type: "ask", text: text });
+ root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) });
+ root.staged = [];
return true;
}
@@ -81,6 +136,8 @@ Scope {
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 };
}
@@ -115,12 +172,16 @@ Scope {
root.ctxUsed = e.ctx_used;
root.ctxMax = e.ctx_max;
root.load(e.id, e.turns, e.pending);
+ if (e.pending) root.lastActive = Date.now();
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 }, true));
+ 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;
@@ -138,6 +199,7 @@ Scope {
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,
@@ -175,7 +237,7 @@ Scope {
id: link
active: true
Socket {
- path: `${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/desktop-assistant.sock`
+ path: root.socketPath
connected: true
parser: SplitParser { onRead: line => root.handle(line) }
}
@@ -205,7 +267,7 @@ Scope {
Card {
anchors.centerIn: parent
width: Math.min(760, parent.width - 48)
- height: Math.round(parent.height * 0.7)
+ fullHeight: Math.round(parent.height * 0.7)
canvas: root
turnModel: turns
}
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index ab80d1b..477f430 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -11,14 +11,29 @@
import QtQuick
import ".."
+import "Files.js" as Files
-// The card: header, transcript, input, and the history panel over the
-// transcript. All state lives in the canvas (Assistant.qml), which outlives
-// this card, so closing and reopening loses nothing.
+// 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
@@ -26,7 +41,7 @@ Rectangle {
border.color: Qt.alpha(Theme.text, 0.12)
// Keys reach a focused item, never the window (AGENTS.md); the card is
- // the ancestor of both text fields, so their Escape bubbles here.
+ // 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.
@@ -34,7 +49,7 @@ Rectangle {
Component.onCompleted: input.forceActiveFocus()
- // Focus falls out of the card when its holder goes: the history field
+ // 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 {
@@ -47,12 +62,15 @@ Rectangle {
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 ---
+ // --- header (chat) ---
Item {
id: header
+ visible: card.mode === "chat"
anchors { top: parent.top; left: parent.left; right: parent.right; margins: 14 }
height: 32
@@ -85,17 +103,19 @@ Rectangle {
Button {
text: "New"
enabled: card.canvas.connected
- onClicked: { card.canvas.send({ type: "new" }); card.canvas.historyOpen = false; input.forceActiveFocus(); }
+ onClicked: { card.canvas.fresh(); card.canvas.historyOpen = false; input.forceActiveFocus(); }
}
}
}
- // --- transcript ---
+ // --- transcript (chat) ---
ListView {
id: list
+ visible: card.mode === "chat"
anchors {
- top: header.bottom; topMargin: 8; bottom: footer.top; bottomMargin: 8
+ 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
@@ -125,6 +145,78 @@ Rectangle {
}
}
+ // --- 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 {
@@ -143,9 +235,24 @@ Rectangle {
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: parent.left; right: send.left; rightMargin: 8 }
+ 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
@@ -188,21 +295,35 @@ Rectangle {
}
Text {
- anchors { left: parent.left; top: parent.top; margins: 8 }
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 8 }
visible: input.text === ""
- text: "Message the assistant"
+ 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
+ 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 = "";
@@ -211,9 +332,24 @@ Rectangle {
}
}
- History {
- anchors.fill: list
- visible: card.canvas.historyOpen
- canvas: card.canvas
+ // --- 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
+ }
}
}
diff --git a/desktop/assistant/Message.qml b/desktop/assistant/Message.qml
index c724c2e..fcf77ec 100644
--- a/desktop/assistant/Message.qml
+++ b/desktop/assistant/Message.qml
@@ -22,6 +22,7 @@ Column {
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
@@ -43,6 +44,32 @@ Column {
}
}
+ // 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 !== ""
diff --git a/desktop/assistant/Picker.qml b/desktop/assistant/Picker.qml
new file mode 100644
index 0000000..1465d1b
--- /dev/null
+++ b/desktop/assistant/Picker.qml
@@ -0,0 +1,143 @@
+// 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 }
+ }
+ }
+}
diff --git a/desktop/assistant/README.md b/desktop/assistant/README.md
index 8fc200b..aabce2d 100644
--- a/desktop/assistant/README.md
+++ b/desktop/assistant/README.md
@@ -17,10 +17,20 @@ what is typed. The protocol is specified in that repo,
| `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 |
-Keys: Enter sends, Shift+Enter is a newline, Escape closes the history
-panel and then the card. Stop replaces Send while a reply streams.
+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:
@@ -49,4 +59,11 @@ Notes:
are stripped first, since a match inside a code span would show the
markers literally.
-Test the splitter: `node desktop/assistant/test_md.js`.
+- **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`.