From f5da8d8ee2cb4ace1fa6841437b4798e7799c100 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 15:23:41 +0200 Subject: feat(assistant): entry line, file state, staged files and a picker The card has three states: an entry line, a file state for staged files with Chat about it, and the chat. SUPER+Home resumes a conversation active in the last 10 minutes and otherwise starts a new one. The paperclip opens a picker drawn in the card, since a system dialog would open beneath the overlay that holds the keyboard. Button gains a font alias for the paperclip glyph, and the canvas a socketPath property so a check can point it at a scratch daemon. Co-Authored-By: Claude Haiku 5.5 --- desktop/assistant/Card.qml | 170 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 17 deletions(-) (limited to 'desktop/assistant/Card.qml') 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 + } } } -- cgit v1.2.3