Public: anyone can read this; the crew can change it.

/talk searches across a person's conversations #9691

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8823-talk-searches-across-a-person-s-conversa → main 9 commits fork 4 threads · 4 unresolved

  • Merged. scout/8823-talk-searches-across-a-person-s-conversa was merged into main as 99e50b5.

Review

Sign in to review this change.
11 files changed +409 −28
docs/design/areas/talk.md +2
@@ -34,3 +34,5 @@
3434
3535 Under a reply, `.talk-cost` is one quiet line with the turn's dollars; above the log, `.talk-context` says how full the context is and, from 80 %, suggests a new conversation. Both are `small muted` text, no new colour.
3636 - `.talk-queued` — a message waiting for the running turn: the person bubble, dimmed with a dashed border, its "Queued" line and Cancel under it.
37
38 Search (#8823) adds no component class: the form is a `.bar` with `.input` and `.btn`, and the results and the conversations list are `.list` of `row()`s (an "archived" `badge` as the trail, the match as the row's meta). The turn a result opens at is marked by a muted hairline on its left edge, `.talk-turn:target`, the one new rule, which replaces the two `.talk-chats li a` rules the rows made unnecessary.
docs/features/talk.md +9
@@ -97,3 +97,12 @@ An agent reply is Markdown, drawn on the server by the same renderer as a README
9797 A reply arrives as one finished turn, never as growing text, and the page only appends a turn whose id it has not drawn (a repeat is ignored). Turns already on the page are not touched, so nothing above a new reply moves, and the page follows the end only if you were already there (otherwise "Latest" appears). An unfinished fence in a stored reply is closed for drawing. The page's own bytes are held by a test: the stylesheet under 3.5 KB, the script under 7.4 KB, and a page with a code reply under 40 KB.
9898
9999 **Copy:** each code block and each message has a Copy button, which copies the text, not the markup. The buttons are drawn by the script: with JavaScript off there are none, and the code is still there to select.
100
101 ## Search (#8823)
102
103 Finds a conversation by a word in its title or in what was said, among the person's own conversations only, archived ones included (labelled "archived").
104
105 - **Web:** the search box above the list is a plain GET form, `/talk?find=<words>`, so it works without the script. Every word must be present and the last may be a prefix (`narwha` finds "narwhal"); at most 8 words and 100 characters count, and punctuation is only a separator. A result opens at its turn, `/talk/<id>?turn=<turn id>#t-<turn id>`: the page that holds it, the turn outlined. A hit in the last few turns opens the latest page, live as usual.
106 - **Terminal:** in Conversations, `/` asks for words and the results replace the list (archived ones marked); Enter opens the chat on the page around the match, that turn marked `▶`; `r` goes to the newest turns; esc leaves the results.
107 - **How:** an FTS5 table, `conversation_find` (title and standing turns, kept in step by triggers; a turn set aside is left out), with the owner as an indexed column that the query names in its MATCH, so the engine's 200-row limit counts only the person's rows. `findConversations` and `pageAround` in `src/core/conversations.ts` are the one door for both screens.
108 - **Left out:** the API and MCP do not search yet; no ranking beyond FTS5's, no highlighting of the words inside a turn.
src/core/conversations.ts +74
@@ -326,3 +326,77 @@ export function setClaudeSession(
326326 .run(conversationId, userId, sessionId, new Date().toISOString()).changes;
327327 return changed === 0 ? err("not_found") : ok();
328328 }
329
330 /** Most conversations one search answers. */
331 export const FIND_MAX = 20;
332 /** Most words of a query used; the rest are dropped. */
333 const FIND_WORDS = 8;
334
335 /** Turns shown after a search hit. */
336 const AFTER_HIT = 9;
337
338 export interface Found {
339 readonly conversation: Conversation;
340 /** The best-matching turn, or undefined when only the title matched. */
341 readonly turnId: string | undefined;
342 /** The matching words in context, plain text (the page escapes it). */
343 readonly snippet: string;
344 }
345
346 /**
347 * The FTS5 query for what a person typed: letters and digits only, every word
348 * must be present, the last as a prefix so a search can follow the typing.
349 * Nothing else a person types reaches the engine's syntax.
350 */
351 export function findQuery(typed: string): string | undefined {
352 const words = (typed.normalize("NFKC").match(/[\p{L}\p{N}]+/gu) ?? []).slice(0, FIND_WORDS).map((w) => w.slice(0, 40));
353 if (words.length === 0) return undefined;
354 return words.map((w, i) => `"${w}"${i === words.length - 1 ? "*" : ""}`).join(" ");
355 }
356
357 /**
358 * The person's conversations whose title or standing turns hold every word of
359 * `typed`, best match first, each with the turn to open it at. Answered from
360 * the `conversation_find` index, never by reading turns; only the person's
361 * own conversations are ever looked at.
362 */
363 export function findConversations(db: Database, userId: string, typed: string): Found[] {
364 const query = findQuery(typed);
365 if (query === undefined) return [];
366 const rows = db
367 .query<{ conversation_id: string; turn_id: string; snip: string }, [string]>(
368 `SELECT conversation_id, turn_id, snippet(conversation_find, 0, '', '', '…', 12) AS snip
369 FROM conversation_find WHERE conversation_find MATCH ?1 ORDER BY rank LIMIT 200`,
370 )
371 // The owner is in the MATCH, so the limit counts this person's rows only.
372 .all(`user_id : "${userId.replace(/[^0-9A-Za-z]/g, "")}" AND text : (${query})`);
373 const seen = new Map<string, { turnId: string; snip: string }>();
374 for (const r of rows) {
375 const had = seen.get(r.conversation_id);
376 // A turn is where to open; the title row only says the title matched.
377 if (had === undefined || (had.turnId === "" && r.turn_id !== "")) seen.set(r.conversation_id, { turnId: r.turn_id, snip: r.snip });
378 }
379 const out: Found[] = [];
380 for (const [conversationId, hit] of seen) {
381 const conversation = getConversation(db, userId, conversationId);
382 if (conversation === undefined) continue;
383 out.push({ conversation, turnId: hit.turnId === "" ? undefined : hit.turnId, snippet: hit.snip });
384 if (out.length >= FIND_MAX) break;
385 }
386 return out;
387 }
388
389 /**
390 * The `before` that shows the turn `turnId` with a few turns after it: the
391 * turn `AFTER_HIT` places past it, or undefined when it is near enough to the
392 * end for the latest page to hold it. Undefined too for a turn not here.
393 */
394 export function pageAround(db: Database, userId: string, conversationId: string, turnId: string): string | undefined {
395 if (!owned(db, userId, conversationId)) return undefined;
396 const row = db
397 .query<{ id: string }, [string, string, number]>(
398 "SELECT id FROM conversation_turn WHERE conversation_id = ?1 AND rowid > (SELECT rowid FROM conversation_turn WHERE id = ?2 AND conversation_id = ?1) ORDER BY rowid LIMIT 1 OFFSET ?3",
399 )
400 .get(conversationId, turnId, AFTER_HIT);
401 return row?.id;
402 }
src/core/migration-pins/20261108110000-conversation-find.pin added +1
@@ -0,0 +1,1 @@
1 57a8d005a9ce13bcd0fbf1176afd9e0fae21d02a868ef1ea42a57045671fd2c6
src/core/migrations/20261108110000-conversation-find.ts added +57
@@ -0,0 +1,57 @@
1 // Finding a conversation by its title or the words in its turns (#8823). One

Triggers delete by UNINDEXED turn_id / conversation_id: a full scan of the whole FTS table (all users) on every rename, prune and delete. Key deletes by rowid.

2 // FTS5 table holds a row per standing turn and one per non-empty title; the
3 // triggers keep it in step with every write, including the cascade that removes
4 // a person's conversations and the prune that keeps a conversation's newest
5 // turns, so no code path has to remember it. A turn set aside (replaced) is
6 // left out. The owner is an indexed column, so a search puts it in the MATCH and
7 // the engine's own limit applies to the person's rows alone. Deletes are keyed by
8 // rowid, never by the UNINDEXED columns (that would scan every person's rows): a
9 // turn's row is 2 * the turn's rowid, a title's is 2 * the conversation's rowid + 1,
10 // so every trigger writes in O(1). New table and triggers only; the turns already
11 // stored are indexed here.
12 export default `
13 CREATE VIRTUAL TABLE conversation_find USING fts5(
14 text,
15 user_id,
16 conversation_id UNINDEXED,
17 turn_id UNINDEXED,
18 tokenize = 'unicode61 remove_diacritics 2'
19 );
20 INSERT INTO conversation_find (rowid, text, user_id, conversation_id, turn_id)
21 SELECT t.rowid * 2, t.text, c.user_id, t.conversation_id, t.id
22 FROM conversation_turn t JOIN conversation c ON c.id = t.conversation_id
23 WHERE t.replaced_at IS NULL AND t.role IN ('person', 'agent');
24 INSERT INTO conversation_find (rowid, text, user_id, conversation_id, turn_id)
25 SELECT rowid * 2 + 1, title, user_id, id, '' FROM conversation WHERE title <> '';
26 CREATE TRIGGER conversation_find_turn_add AFTER INSERT ON conversation_turn
27 WHEN new.replaced_at IS NULL AND new.role IN ('person', 'agent')
28 BEGIN
29 INSERT INTO conversation_find (rowid, text, user_id, conversation_id, turn_id)
30 SELECT new.rowid * 2, new.text, user_id, new.conversation_id, new.id FROM conversation WHERE id = new.conversation_id;
31 END;
32 CREATE TRIGGER conversation_find_turn_drop AFTER DELETE ON conversation_turn
33 BEGIN
34 DELETE FROM conversation_find WHERE rowid = old.rowid * 2;
35 END;
36 CREATE TRIGGER conversation_find_turn_aside AFTER UPDATE OF replaced_at ON conversation_turn
37 WHEN new.replaced_at IS NOT NULL
38 BEGIN
39 DELETE FROM conversation_find WHERE rowid = old.rowid * 2;
40 END;
41 CREATE TRIGGER conversation_find_turn_back AFTER UPDATE OF replaced_at ON conversation_turn
42 WHEN new.replaced_at IS NULL AND old.replaced_at IS NOT NULL AND new.role IN ('person', 'agent')
43 BEGIN
44 INSERT INTO conversation_find (rowid, text, user_id, conversation_id, turn_id)
45 SELECT new.rowid * 2, new.text, user_id, new.conversation_id, new.id FROM conversation WHERE id = new.conversation_id;
46 END;
47 CREATE TRIGGER conversation_find_title AFTER UPDATE OF title ON conversation
48 BEGIN
49 DELETE FROM conversation_find WHERE rowid = old.rowid * 2 + 1;
50 INSERT INTO conversation_find (rowid, text, user_id, conversation_id, turn_id)
51 SELECT new.rowid * 2 + 1, new.title, new.user_id, new.id, '' WHERE new.title <> '';
52 END;
53 CREATE TRIGGER conversation_find_gone AFTER DELETE ON conversation
54 BEGIN
55 DELETE FROM conversation_find WHERE rowid = old.rowid * 2 + 1;
56 END;
57 `;
src/tui/screens/talk_chats.ts +73 −14
@@ -10,6 +10,9 @@ import { TALK_WORDS, talk } from "../../actions/talk.ts";
1010 import {
1111 archiveConversation,
1212 createConversation,
13 findConversations,
14 pageAround,
15 type Found,
1316 listConversations,
1417 listTurns,
1518 renameConversation,
@@ -29,23 +32,38 @@ const BAD_TITLE = `A title is up to ${TITLE_MAX} characters, on one line.`;
2932
3033 const WHO: Readonly<Record<string, string>> = { person: "You", agent: "Forge", tool: "Tool", confirm: "Asks" };
3134
32 /** One conversation, newest turns last: j/k scroll, s says something in it, r reloads. */
33 function chatScreen(f: Session, ctx: Ctx, chat: Conversation): Screen {
35 /**
36 * One conversation, newest turns last: j/k scroll, s says something in it, r
outdated

Two stacked JSDoc blocks; merge them. The flatMap body below is mis-indented.

37 * reloads. `hit` is a search result's turn: the view opens on the page around
38 * it, that turn marked ▶.
39 */
40 function chatScreen(f: Session, ctx: Ctx, chat: Conversation, hit?: string): Screen {
3441 const scroll = new Scroll();
3542 let note = "";
3643 let width = 80;
3744 // Opened, and after r, the view sits on the newest turns.
38 let follow = true;
45 let follow = hit === undefined;
46 // The page around a search hit, until r goes back to the newest; `mark` is the hit's first line.
47 let around = hit;
48 let mark = 0;
49 let placed = false;
3950 const lines = (): { text: string; style: ReturnType<typeof style> }[][] => {
4051 const c = ctx.theme.c;
41 const listed = f.user === undefined ? undefined : listTurns(f.app.db, f.user.id, chat.id, { limit: 50 });
52 const before = f.user === undefined || around === undefined ? undefined : pageAround(f.app.db, f.user.id, chat.id, around);
53 const listed = f.user === undefined ? undefined : listTurns(f.app.db, f.user.id, chat.id, { limit: 50, ...(before === undefined ? {} : { before }) });
4254 const turns = listed?.ok ? listed.value : [];
4355 if (turns.length === 0) return [[{ text: "Nothing said yet; s says something.", style: style(c.mutedFg, c.bg) }]];
44 return turns.flatMap((t) => [
45 [{ text: `${WHO[t.role] ?? t.role}${t.replaced ? " · replaced" : ""} · ${ago(t.createdAt)}`, style: style(c.mutedFg, c.bg) }],
46 ...t.text.split("\n").flatMap((l) => wrap(l, Math.max(10, width - 2))).map((l) => [{ text: l, style: style(c.fg, c.bg) }]),
47 [],
48 ]);
56 let n = 0;
57 return turns.flatMap((t) => {
58 if (t.id === around) mark = n;
59 const own = t.text.split("\n").flatMap((l) => wrap(l, Math.max(10, width - 2)));
60 n += own.length + 2;
61 return [
62 [{ text: `${t.id === around ? "▶ " : ""}${WHO[t.role] ?? t.role}${t.replaced ? " · replaced" : ""} · ${ago(t.createdAt)}`, style: style(c.mutedFg, c.bg) }],
63 ...own.map((l) => [{ text: l, style: style(c.fg, c.bg) }]),
64 [],
65 ];
66 });
4967 };
5068 const screen: Screen = {
5169 get title() {
@@ -60,6 +78,10 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation): Screen {
6078 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
6179 const all = lines();
6280 if (follow) scroll.to(all.length - body!.h, all.length);
81 else if (around !== undefined && !placed) {
82 scroll.to(mark, all.length);
83 placed = true;
84 }
6385 drawLines(s, body!, c, scroll, all);
6486 },
6587 key(e) {
@@ -76,6 +98,7 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation): Screen {
7698 if (e.key === "r") {
7799 note = "";
78100 follow = true;
101 around = undefined;
79102 ctx.invalidate();
80103 return true;
81104 }
@@ -105,10 +128,20 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen {
105128 const state = new ListState();
106129 let archived = false;
107130 let note = "";
108 const items = (): Conversation[] => (f.user === undefined ? [] : listConversations(f.app.db, f.user.id, { archived }));
131 // A search's results replace the list until esc; each opens its chat at the matching turn.
132 let found: { typed: string; hits: Found[] } | undefined;
133 const items = (): Conversation[] =>
134 found !== undefined ? found.hits.map((h) => h.conversation) : f.user === undefined ? [] : listConversations(f.app.db, f.user.id, { archived });
109135 const rows = (): ListRow[] =>
110 items().map((c) => ({ title: c.title === "" ? "Untitled" : c.title, secondary: `/talk/${c.id}`, meta: `active ${ago(c.updatedAt)}` }));
136 found !== undefined
137 ? found.hits.map((h) => ({
138 title: h.conversation.title === "" ? "Untitled" : h.conversation.title,
139 secondary: `${h.conversation.archivedAt === null ? "" : "archived · "}${h.snippet.replace(/\s+/g, " ")}`,
140 meta: `/talk/${h.conversation.id}`,
141 }))
142 : items().map((c) => ({ title: c.title === "" ? "Untitled" : c.title, secondary: `/talk/${c.id}`, meta: `active ${ago(c.updatedAt)}` }));
111143 const say = (text: string): void => {
144 found = undefined;
112145 note = text;
113146 ctx.invalidate();
114147 };
@@ -124,6 +157,7 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen {
124157 binding(["r"], "rename"),
125158 binding(["x"], "archive or restore"),
126159 binding(["a"], "archived"),
160 binding(["/"], "search"),
127161 ],
128162 render(area, s) {
129163 const c = ctx.theme.c;
@@ -131,7 +165,9 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen {
131165 const text =
132166 note !== ""
133167 ? note
134 : items().length === 0
168 : found !== undefined
169 ? found.hits.length === 0 ? `Nothing found for "${found.typed}"; esc goes back.` : `Found for "${found.typed}" (archived ones too); enter opens at the match, esc goes back.`
170 : items().length === 0
135171 ? archived ? "Nothing archived." : "No conversations yet; n starts one."
136172 : archived ? "Archived; x brings one back." : "Your talks with the forge's agent; each opens at its /talk address.";
137173 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
@@ -142,7 +178,30 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen {
142178 const user = f.user;
143179 if (user === undefined || e.ctrl || e.alt) return false;
144180 const here = items()[state.index];
145 if (e.key === "a") {
181 if (e.key === "esc" && found !== undefined) {
182 found = undefined;
183 state.index = 0;
184 ctx.invalidate();
185 return true;
186 }
187 if (e.key === "/") {
188 ctx.push(
189 promptScreen(ctx, screen, {
190 title: "Search conversations",
191 label: "words in a title or in what was said",
192 onSubmit: (typed) => {
193 const hits = findConversations(f.app.db, user.id, typed);
194 found = typed.trim() === "" ? undefined : { typed: typed.trim(), hits };
195 state.index = 0;
196 note = "";
197 ctx.invalidate();
198 return undefined;
199 },
200 }),
201 );
202 return true;
203 }
204 if (e.key === "a" && found === undefined) {
146205 archived = !archived;
147206 state.index = 0;
148207 note = "";
@@ -169,7 +228,7 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen {
169228 }
170229 if (here === undefined) return false;
171230 if (e.key === "enter") {
172 ctx.push(chatScreen(f, ctx, here));
231 ctx.push(chatScreen(f, ctx, here, found?.hits[state.index]?.turnId));
173232 return true;
174233 }
175234 if (e.key === "r") {
src/web/pages/talk.ts +40 −10
@@ -22,6 +22,8 @@ import {
2222 archiveConversation,
2323 type Conversation,
2424 DEFAULT_PAGE,
25 findConversations,
26 pageAround,
2527 lastExchange,
2628 getConversation,
2729 listConversations,
@@ -50,7 +52,7 @@ import { readForm } from "../form.ts";
5052 import { html, type Html } from "../html.ts";
5153 import { flashTo, page, redirect } from "../layout.ts";
5254 import { renderMarkdown } from "../markdown.ts";
53 import { btn, time } from "../ui.ts";
55 import { badge, btn, row, time } from "../ui.ts";
5456 import { talkCss, talkJs } from "./talk_client.ts";
5557
5658 /** A stream lasts this long at most; the client reconnects from its last turn id. */
@@ -64,6 +66,8 @@ export const clock = { streamMs: STREAM_MS, running: turnRunning };
6466 const open = new Map<string, number>();
6567 const TURN_ID = /^[0-9a-z]{0,40}$/;
6668 const HERE = "/talk";
69 /** Longest search typed, in characters. */
70 const FIND_CHARS = 100;
6771 const CONVERSATION_ID = /^[0-9a-z]{1,40}$/;
6872
6973 /** What a conversation is called in a list: its title, else the day it began. */
@@ -210,8 +214,9 @@ export function turnHtml(turn: Turn, waiting: ReadonlySet<string>, cost?: number
210214 }
211215
212216 /** The conversations list, then this one's name with Rename and Archive: each a plain form, so none needs the script. */
213 function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean): Html {
217 function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean, typed: string): Html {
214218 const user = ctx.auth.user!;
219 const found = typed === "" ? undefined : findConversations(ctx.app.db, user.id, typed);
215220 const shown = listConversations(ctx.app.db, user.id, { archived: showArchived });
216221 const put = listConversations(ctx.app.db, user.id, { archived: !showArchived }).length;
217222 const archived = conversation.archivedAt !== null;
@@ -225,10 +230,28 @@ function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean): Htm
225230 ? html`<a class="small" href="${HERE}?archived=1">Archived (${put})</a>`
226231 : null}
227232 </div>
228 <ul>
233 <form class="bar" method="get" action="${HERE}" role="search">
234 <input class="input" type="search" name="find" value="${typed}" maxlength="${FIND_CHARS}" aria-label="Search conversations" placeholder="Search conversations" />
235 ${btn("Search")}
236 </form>
237 ${found === undefined
238 ? null
239 : html`<ul class="list" aria-label="Search results">
240 ${found.length === 0
241 ? html`<li class="muted small">Nothing found for "${typed}".</li>`
242 : found.map((f) =>
243 row({

Principle 8: say what was searched: Nothing found for the typed words, as the terminal does.

244 href: `${urlOf(f.conversation)}${f.turnId === undefined ? "" : `?turn=${f.turnId}#t-${f.turnId}`}`,
245 title: nameOf(f.conversation),
246 meta: html`${f.snippet}`,
247 ...(f.conversation.archivedAt === null ? {} : { trail: badge("archived") }),
248 }),
249 )}
250 </ul>`}
251 <ul class="list">
229252 ${shown.length === 0
230253 ? html`<li class="muted small">${showArchived ? "Nothing archived." : "No conversations yet."}</li>`
231 : shown.map((c) => html`<li ${c.id === conversation.id ? html`aria-current="page"` : null}><a href="${urlOf(c)}"><span>${nameOf(c)}</span><span class="muted small">${time(c.updatedAt)}</span></a></li>`)}
254 : shown.map((c) => row({ href: urlOf(c), title: nameOf(c), meta: html`${time(c.updatedAt)}`, current: c.id === conversation.id }))}
232255 </ul>
233256 </nav>
234257 <div class="talk-here bar">
@@ -247,10 +270,17 @@ function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean): Htm
247270 </div>`;
248271 }
249272
250 function talkPage(ctx: Ctx, conversation: Conversation, before: string | undefined, prefill: string, latest: boolean, edit: boolean): Response {
273 function talkPage(ctx: Ctx, conversation: Conversation, before0: string | undefined, prefill: string, latest: boolean, edit: boolean): Response {
251274 const user = ctx.auth.user!;
275 let before = before0;
252276 const here = latest ? HERE : urlOf(conversation);
253277 const showArchived = latest && ctx.url.searchParams.get("archived") === "1";
278 const typed = (ctx.url.searchParams.get("find") ?? "").slice(0, FIND_CHARS);
279 // A search result names its turn: show the page that holds it, and let the fragment scroll there.
280 const focus = ctx.url.searchParams.get("turn");
281 // Near the end pageAround has no `before`: that is the latest page, unpaged, and the fragment still lands.
282 if (before === undefined && focus !== null && focus !== "" && TURN_ID.test(focus)) before = pageAround(ctx.app.db, user.id, conversation.id, focus);
283 const paged = before !== undefined;
254284 const listed = listTurns(ctx.app.db, user.id, conversation.id, {
255285 limit: DEFAULT_PAGE,
256286 ...(before === undefined ? {} : { before }),
@@ -265,24 +295,24 @@ function talkPage(ctx: Ctx, conversation: Conversation, before: string | undefin
265295 const works = worksOf(ctx.app.db, user.id, conversation.id, turns);
266296 // Paging back, the log ends before the newest turn: it does not follow.
267297 const last = turns.at(-1)?.id ?? "";
268 const acts = before === undefined ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>();
298 const acts = !paged ? exchangeActs(ctx.app.db, user.id, conversation.id, running, here) : new Map<string, Html>();
269299 // Edit shows the composer holding the last message, posting to Edit instead of Send.
270300 const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined;
271301 const body = html`<div class="page-head"><h1>Talk</h1></div>
272 ${chats(ctx, conversation, showArchived)}
273 <div class="talk" data-talk data-user="${user.id}" data-c="${conversation.id}" data-here="${here}" data-paged="${before === undefined ? "0" : "1"}" data-running="${running && before === undefined ? "1" : "0"}">
302 ${chats(ctx, conversation, showArchived, typed)}
303 <div class="talk" data-talk data-user="${user.id}" data-c="${conversation.id}" data-here="${here}" data-paged="${paged ? "1" : "0"}" data-running="${running && !paged ? "1" : "0"}">
274304 <div>
275305 ${contextLine(context)}
276306 ${turns.length === DEFAULT_PAGE && turns[0] !== undefined
277307 ? html`<p class="talk-more small"><a href="${here}?before=${turns[0].id}">Earlier</a></p>`
278308 : null}
279 ${before === undefined ? null : html`<p class="talk-more small"><a href="${here}">Latest</a></p>`}
309 ${!paged ? null : html`<p class="talk-more small"><a href="${here}">Latest</a></p>`}
280310 <ol class="talk-log" role="log" data-last="${last}" aria-label="Conversation with the forge">
281311 ${turns.length === 0
282312 ? html`<li class="muted talk-turn">Ask the forge anything, or tell it what to change. It acts as you, with your permissions, and asks before anything hard to undo.
283313 <span class="talk-suggest">${SUGGESTIONS.map((q) => html`<a class="btn" href="${here}?q=${encodeURIComponent(q)}" data-suggest="${q}">${q}</a>`)}</span></li>`
284314 : turns.map((t) => turnHtml(t, waiting, costs.get(t.id), acts.get(t.id), works.get(t.id), sent.get(t.id), expired))}
285 ${before === undefined && running ? queuedTalk(ctx.app, user, conversation.id).map(queuedHtml) : null}
315 ${!paged && running ? queuedTalk(ctx.app, user, conversation.id).map(queuedHtml) : null}
286316 </ol>
287317 <div id="end"></div>
288318 </div>
src/web/pages/talk_client.ts +1 −2
@@ -48,8 +48,7 @@ export const talkCss = /* css */ `
4848 .talk-files { justify-content: flex-end; }
4949 .talk-more { text-align: center; }
5050 .talk-chats, .talk-here { margin-bottom: 12px; }
51 .talk-chats li a { display: flex; justify-content: space-between; }
52 .talk-chats [aria-current] { font-weight: 600; }
51 .talk-turn:target { box-shadow: -2px 0 0 var(--muted); scroll-margin-top: 4rem; }
outdated

Principles 5 and 6: an accent outline box around a non-interactive turn. Use a quiet marker (muted hairline or token tint), not --accent.

5352 .talk-replaced { opacity: 0.55; }
5453 .talk-failed form { margin-top: 6px; }
5554 .talk-failed .btn, .talk-act { min-height: 2.75rem; }
test/conversations.test.ts +89
@@ -6,6 +6,9 @@ import { migrations } from "../src/core/schema.ts";
66 import {
77 appendTurn,
88 archiveConversation,
9 findConversations,
10 findQuery,
11 pageAround,
912 getConversation,
1013 listConversations,
1114 newConversation,
@@ -184,4 +187,90 @@ describe("many conversations (#8814)", () => {
184187 expect(listConversations(db, "u2")).toEqual([]);
185188 expect(listTurns(db, "u2", mine.id)).toEqual({ ok: false, error: "not_found" });
186189 });
190
191 test("a word in an old turn finds its conversation, with the turn to open (#8823)", () => {
192 const db = open("find.db");
193 const c = newConversation(db, "u1");
194 const old = appendTurn(db, "u1", c.id, { role: "person", text: "how do I rotate the zebrafish keys?" });
195 for (let i = 0; i < 80; i++) appendTurn(db, "u1", c.id, { role: "agent", text: `filler ${i}` });
196 const other = newConversation(db, "u1");
197 appendTurn(db, "u1", other.id, { role: "person", text: "nothing relevant" });
198 const hits = findConversations(db, "u1", "zebra");
199 expect(hits.map((h) => h.conversation.id)).toEqual([c.id]);
200 const turn = old.ok ? old.value.id : "";
201 expect(hits[0]!.turnId).toBe(turn);
202 // The page that holds it is the one before a later turn, so it is not the latest.
203 const before = pageAround(db, "u1", c.id, turn);
204 expect(before).toBeDefined();
205 const page = listTurns(db, "u1", c.id, { before: before! });
206 expect(page.ok && page.value.some((t) => t.id === turn)).toBe(true);
207 db.close();
208 });
209
210 test("a title is found, and a renamed one is found by the new name only", () => {
211 const db = open("find-title.db");
212 const c = newConversation(db, "u1");
213 renameConversation(db, "u1", c.id, "Quarterly invoices");
214 expect(findConversations(db, "u1", "invoic").map((h) => h.conversation.id)).toEqual([c.id]);
215 expect(findConversations(db, "u1", "invoices")[0]!.turnId).toBeUndefined();
216 renameConversation(db, "u1", c.id, "Holiday");
217 expect(findConversations(db, "u1", "invoices")).toEqual([]);
218 expect(findConversations(db, "u1", "holiday")).toHaveLength(1);
219 db.close();
220 });
221
222 test("another person's conversations never appear", () => {
223 const db = open("find-owner.db");
224 const mine = newConversation(db, "u1");
225 const theirs = newConversation(db, "u2");
226 appendTurn(db, "u2", theirs.id, { role: "person", text: "the secret platypus plan" });
227 renameConversation(db, "u2", theirs.id, "platypus");
228 appendTurn(db, "u1", mine.id, { role: "person", text: "my own platypus" });
229 const hits = findConversations(db, "u1", "platypus");
230 expect(hits.map((h) => h.conversation.id)).toEqual([mine.id]);
231 expect(findConversations(db, "u1", "secret")).toEqual([]);
232 db.close();
233 });
234
235 test("another person's many matches cannot crowd out this person's one", () => {
236 const db = open("find-crowd.db");
237 const theirs = newConversation(db, "u2");
238 // Rows from u2 that rank above u1's single short row for the same word.
239 for (let i = 0; i < 250; i++) appendTurn(db, "u2", theirs.id, { role: "person", text: "walrus" });
240 const mine = newConversation(db, "u1");
241 appendTurn(db, "u1", mine.id, { role: "person", text: "a long line of words that ends with the walrus and then goes on a while more" });
242 expect(findConversations(db, "u1", "walrus").map((h) => h.conversation.id)).toEqual([mine.id]);
243 db.close();
244 });
245
246 test("set-aside and removed turns stop matching; restored ones match again", () => {
247 const db = open("find-aside.db");
248 const c = newConversation(db, "u1");
249 const t = appendTurn(db, "u1", c.id, { role: "person", text: "quokka" });
250 const id = t.ok ? t.value.id : "";
251 db.query("UPDATE conversation_turn SET replaced_at = 'now' WHERE id = ?1").run(id);
252 expect(findConversations(db, "u1", "quokka")).toEqual([]);
253 db.query("UPDATE conversation_turn SET replaced_at = NULL WHERE id = ?1").run(id);
254 expect(findConversations(db, "u1", "quokka")).toHaveLength(1);
255 db.query("DELETE FROM conversation_turn WHERE id = ?1").run(id);
256 expect(findConversations(db, "u1", "quokka")).toEqual([]);
257 db.close();
258 });
259
260 test("typed search syntax never reaches the engine", () => {
261 expect(findQuery('"" OR * NEAR( x:y')).toBe('"OR" "NEAR" "x" "y"*');
262 expect(findQuery(' ()"* ')).toBeUndefined();
263 const db = open("find-syntax.db");
264 expect(findConversations(db, "u1", '" OR ( AND *')).toEqual([]);
265 db.close();
266 });
267 });
268
269 describe("conversation_find writes", () => {
270 test("every delete is keyed by rowid, never by an UNINDEXED column (a scan of every person's rows)", async () => {
271 const sql = (await import("../src/core/migrations/20261108110000-conversation-find.ts")).default;
272 const deletes = sql.match(/DELETE FROM conversation_find[^;]*;/g) ?? [];
273 expect(deletes.length).toBeGreaterThan(0);
274 for (const d of deletes) expect(d).toMatch(/WHERE rowid = /);
275 });
187276 });
test/talk-conversations.test.ts +35 −1
@@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { appendTurn, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, newConversation, openConversation, renameConversation } from "../src/core/conversations.ts";
12 import { appendTurn, archiveConversation, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, newConversation, openConversation, renameConversation } from "../src/core/conversations.ts";
1313 import { createSession } from "../src/core/sessions.ts";
1414 import { createToken } from "../src/core/tokens.ts";
1515 import { migrations } from "../src/core/schema.ts";
@@ -288,3 +288,37 @@ describe("the MCP rename", () => {
288288 expect(listConversations(app.db, alice.id).find((x) => x.id === made.id)?.title).toBe("Keep me");
289289 });
290290 });
291
292 describe("search (#8823)", () => {
293 test("a result links to its conversation at the matching turn, the page shows that turn, and nothing of another person's", async () => {
294 const c = newConversation(app.db, alice.id);
295 const t = appendTurn(app.db, alice.id, c.id, { role: "person", text: "remember the narwhal budget" });
296 for (let i = 0; i < 70; i++) appendTurn(app.db, alice.id, c.id, { role: "agent", text: `later ${i}` });
297 const turn = t.ok ? t.value.id : "";
298 const theirs = newConversation(app.db, bob.id);
299 appendTurn(app.db, bob.id, theirs.id, { role: "person", text: "bob's narwhal secret" });
300 const found = await (await get("/talk?find=narwha", alice)).text();
301 expect(found).toContain(`href="/talk/${c.id}?turn=${turn}#t-${turn}"`);
302 expect(found).not.toContain(theirs.id);
303 expect(found).not.toContain("secret");
304 const opened = await (await get(`/talk/${c.id}?turn=${turn}`, alice)).text();
305 expect(opened).toContain(`id="t-${turn}"`);
306 expect(opened).toContain('data-paged="1"');
307 expect((await get("/talk?find=zzzqqq", alice).then((r) => r.text()))).toContain("Nothing found");
308 });
309
310 test("a hit in the last turns is the latest page: unpaged, no Latest link; archived results are labelled", async () => {
311 const c = newConversation(app.db, alice.id);
312 const t = appendTurn(app.db, alice.id, c.id, { role: "person", text: "the kraken ledger" });
313 appendTurn(app.db, alice.id, c.id, { role: "agent", text: "noted" });
314 const turn = t.ok ? t.value.id : "";
315 const opened = await (await get(`/talk/${c.id}?turn=${turn}`, alice)).text();
316 expect(opened).toContain('data-paged="0"');
317 expect(opened).not.toContain(">Latest</a>");
318 expect(opened).toContain(`id="t-${turn}"`);
319 archiveConversation(app.db, alice.id, c.id, true);
320 const found = await (await get("/talk?find=kraken", alice)).text();
321 expect(found).toContain("archived");
322 expect(found).toContain('class="row"');
323 });
324 });
test/tui-talk-chats.test.ts +28 −1
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
11 import { appendTurn, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, renameConversation } from "../src/core/conversations.ts";
11 import { appendTurn, archiveConversation, CONVERSATIONS_MAX, createConversation, listConversations, listTurns, renameConversation } from "../src/core/conversations.ts";
1212 import { createUser, type User } from "../src/core/users.ts";
1313 import { createForgeApp } from "../src/tui/forge.ts";
1414 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -32,6 +32,8 @@ async function drive(user: User, keys: string[]): Promise<string> {
3232 await settleApp(tui);
3333 for (const k of keys) {
3434 terminal.feed(k);
35 // A lone escape is held back a moment to tell it from a key sequence.
36 if (k === "\x1b") await Bun.sleep(150);
3537 await settleApp(tui);
3638 }
3739 const text = tui.screen.toText();
@@ -106,4 +108,29 @@ describe("the terminal's conversations screen", () => {
106108 expect(text).toContain("the most there can be");
107109 expect((app.db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(alice.id) as { n: number }).n).toBe(CONVERSATIONS_MAX);
108110 });
111
112 test("/ searches the person's own conversations, archived too, and Enter opens at the match", async () => {
113 const mine = createConversation(app.db, bob.id);
114 // Alice is at the cap from the test above: her newest conversation is the other person's.
115 const theirs = { ok: true as const, value: listConversations(app.db, alice.id)[0]! };
116 if (!mine.ok) throw new Error("conversation");
117 renameConversation(app.db, bob.id, mine.value.id, "Harbour notes");
118 const hit = appendTurn(app.db, bob.id, mine.value.id, { role: "person", text: "the quokka migration plan" });
119 for (let i = 0; i < 70; i++) appendTurn(app.db, bob.id, mine.value.id, { role: "agent", text: `later ${i}` });
120 appendTurn(app.db, alice.id, theirs.value.id, { role: "person", text: "alice quokka secret" });
121 archiveConversation(app.db, bob.id, mine.value.id, true);
122 const found = await drive(bob, [...open, "/", "quokk", "\r"]);
123 expect(found).toContain("Harbour notes");
124 expect(found).toContain("archived");
125 expect(found).not.toContain("secret");
126 expect(found).not.toContain(theirs.value.id);
127 const opened = await drive(bob, [...open, "/", "quokk", "\r", "\r"]);
128 expect(opened).toContain("the quokka migration plan");
129 expect(opened).toContain("▶");
130 expect(hit.ok).toBe(true);
131 const none = await drive(bob, [...open, "/", "zzzqqq", "\r"]);
132 expect(none).toContain("Nothing found");
133 const back = await drive(bob, [...open, "/", "quokk", "\r", "\x1b"]);
134 expect(back).not.toContain("Found for");
135 });
109136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs