aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/assistant/Assistant.qml19
-rw-r--r--desktop/assistant/Card.qml13
-rw-r--r--desktop/assistant/Files.js9
-rw-r--r--desktop/assistant/Picker.qml9
-rw-r--r--desktop/assistant/test_files.js2
-rw-r--r--desktop/island/Island.qml3
6 files changed, 41 insertions, 14 deletions
diff --git a/desktop/assistant/Assistant.qml b/desktop/assistant/Assistant.qml
index b038dd6..977b4dd 100644
--- a/desktop/assistant/Assistant.qml
+++ b/desktop/assistant/Assistant.qml
@@ -78,7 +78,9 @@ Scope {
// 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();
+ // Never while a reply runs: new would stop it, and a stopped turn is
+ // not saved.
+ if (turns.count && !root.busy && Date.now() - root.lastActive > 10 * 60 * 1000) root.fresh();
root.open = true;
}
@@ -120,15 +122,20 @@ Scope {
link.item.flush();
}
+ // The chips stay until the daemon's user event takes them: a refused
+ // file (unreadable, too big, a model without vision) comes back as an
+ // error, and the chips must still be there to fix it. chatting keeps the
+ // chat layout meanwhile, so the card does not shrink while a PDF is read.
function ask(text) {
if (!root.connected || root.blocked || (text.trim() === "" && !root.staged.length)) return false;
root.notice = "";
+ root.chatting = true;
root.send({ type: "ask", text: text, files: root.staged.map(f => f.path) });
- root.staged = [];
return true;
}
function showHistory() {
+ root.pickerOpen = false;
root.historyOpen = true;
root.send({ type: "list" });
}
@@ -172,15 +179,21 @@ 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();
+ // A restart or reload counts as active: the conversation it
+ // reconnects to is not stale just because this process is new.
+ if (e.pending || e.turns.length) 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();
+ // Emptied (New, or the current one deleted) with nothing staged:
+ // back to the entry line.
+ else if (!root.staged.length) root.chatting = false;
break;
case "user":
turns.append(root.row({ user: e.text, files: e.files }, true));
+ root.staged = [];
root.lastActive = Date.now();
break;
case "think": root.bufThink += e.delta; break;
diff --git a/desktop/assistant/Card.qml b/desktop/assistant/Card.qml
index 477f430..228cfdb 100644
--- a/desktop/assistant/Card.qml
+++ b/desktop/assistant/Card.qml
@@ -244,7 +244,10 @@ Rectangle {
height: box.height
text: ""
font.family: Theme.iconFamily
- onClicked: card.canvas.pickerOpen = !card.canvas.pickerOpen
+ onClicked: {
+ card.canvas.historyOpen = false;
+ card.canvas.pickerOpen = !card.canvas.pickerOpen;
+ }
}
Rectangle {
@@ -298,7 +301,8 @@ Rectangle {
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?"
+ text: !card.canvas.connected ? "assistant not running"
+ : 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
@@ -332,12 +336,13 @@ Rectangle {
}
}
- // --- panels, over everything above the input ---
+ // --- panels, over the transcript (under the header in chat) ---
Item {
id: panelArea
anchors {
- top: parent.top; topMargin: 14; bottom: chips.visible ? chips.top : footer.top
+ top: header.visible ? header.bottom : parent.top; topMargin: header.visible ? 8 : 14
+ bottom: chips.visible ? chips.top : footer.top
bottomMargin: 8; left: parent.left; right: parent.right; leftMargin: 14; rightMargin: 14
}
diff --git a/desktop/assistant/Files.js b/desktop/assistant/Files.js
index b7f8c2b..bcfe1f0 100644
--- a/desktop/assistant/Files.js
+++ b/desktop/assistant/Files.js
@@ -45,11 +45,12 @@ function kind(path) {
function chats(k) { return CHAT.includes(k); }
-// A dropped URL to a local path, or "" for anything that is not a file.
+// A dropped URL to a local path, or "" for anything that is not a local
+// file: file:///path, or file://localhost/path; any other host is remote.
function fromUrl(url) {
- const s = String(url);
- if (!s.startsWith("file://")) return "";
- try { return decodeURIComponent(s.slice(7)); } catch (e) { return ""; }
+ const m = /^file:\/\/(localhost)?(\/.*)$/.exec(String(url));
+ if (!m) return "";
+ try { return decodeURIComponent(m[2]); } catch (e) { return ""; }
}
function file(path) { return { path: path, name: name(path), kind: kind(path) }; }
diff --git a/desktop/assistant/Picker.qml b/desktop/assistant/Picker.qml
index 1465d1b..6d7b8ad 100644
--- a/desktop/assistant/Picker.qml
+++ b/desktop/assistant/Picker.qml
@@ -49,13 +49,18 @@ Rectangle {
FolderListModel {
id: files
- folder: "file://" + picker.dir
+ // Encoded: a raw "#" or "?" in a folder name would end the path.
+ folder: "file://" + picker.dir.split("/").map(encodeURIComponent).join("/")
showDirsFirst: true
+ // Name filters never apply to folders, so while filtering they would
+ // sit first and catch Enter. ponytail: no filtering to a folder.
+ showDirs: field.text === ""
showDotAndDotDot: false
// Hidden entries only on asking for them.
showHidden: field.text.startsWith(".")
caseSensitive: false
- nameFilters: field.text ? ["*" + field.text + "*"] : ["*"]
+ // Glob characters are taken out: typed, they are meant literally.
+ nameFilters: field.text ? ["*" + field.text.replace(/[*?\[\]]/g, "") + "*"] : ["*"]
sortCaseSensitive: false
}
diff --git a/desktop/assistant/test_files.js b/desktop/assistant/test_files.js
index a15495b..5ece64f 100644
--- a/desktop/assistant/test_files.js
+++ b/desktop/assistant/test_files.js
@@ -34,6 +34,8 @@ 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://localhost/tmp/a.pdf"), "/tmp/a.pdf");
+assert.strictEqual(F.fromUrl("file://otherhost/tmp/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" });
diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml
index 4fba033..412cc58 100644
--- a/desktop/island/Island.qml
+++ b/desktop/island/Island.qml
@@ -203,7 +203,8 @@ PanelWindow {
x: win.cx - width / 2
y: 0
z: 10
- width: 260
+ // The sliver's hit width at rest, so it takes no clicks beside it.
+ width: win.dropHold ? 260 : 220
height: win.dropHold ? 100 : 10
DropArea {