Public: anyone can read this; the crew can change it.
/talk searches across a person's conversations #9691
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8823-talk-searches-across-a-person-s-conversa → main 9 commits fork 4 threads · 4 unresolved
11 files
changed +409
−28
docs/design/areas/talk.md +2
| @@ -34,3 +34,5 @@ | ||
| 34 | 34 | |
| 35 | 35 | 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. |
| 36 | 36 | - `.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 | ||
| 97 | 97 | 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. |
| 98 | 98 | |
| 99 | 99 | **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( | ||
| 326 | 326 | .run(conversationId, userId, sessionId, new Date().toISOString()).changes; |
| 327 | 327 | return changed === 0 ? err("not_found") : ok(); |
| 328 | 328 | } |
| 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 | |
| 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"; | ||
| 10 | 10 | import { |
| 11 | 11 | archiveConversation, |
| 12 | 12 | createConversation, |
| 13 | findConversations, | |
| 14 | pageAround, | |
| 15 | type Found, | |
| 13 | 16 | listConversations, |
| 14 | 17 | listTurns, |
| 15 | 18 | renameConversation, |
| @@ -29,23 +32,38 @@ const BAD_TITLE = `A title is up to ${TITLE_MAX} characters, on one line.`; | ||
| 29 | 32 | |
| 30 | 33 | const WHO: Readonly<Record<string, string>> = { person: "You", agent: "Forge", tool: "Tool", confirm: "Asks" }; |
| 31 | 34 | |
| 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 { | |
| 34 | 41 | const scroll = new Scroll(); |
| 35 | 42 | let note = ""; |
| 36 | 43 | let width = 80; |
| 37 | 44 | // 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; | |
| 39 | 50 | const lines = (): { text: string; style: ReturnType<typeof style> }[][] => { |
| 40 | 51 | 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 }) }); | |
| 42 | 54 | const turns = listed?.ok ? listed.value : []; |
| 43 | 55 | 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 | }); | |
| 49 | 67 | }; |
| 50 | 68 | const screen: Screen = { |
| 51 | 69 | get title() { |
| @@ -60,6 +78,10 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation): Screen { | ||
| 60 | 78 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 61 | 79 | const all = lines(); |
| 62 | 80 | 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 | } | |
| 63 | 85 | drawLines(s, body!, c, scroll, all); |
| 64 | 86 | }, |
| 65 | 87 | key(e) { |
| @@ -76,6 +98,7 @@ function chatScreen(f: Session, ctx: Ctx, chat: Conversation): Screen { | ||
| 76 | 98 | if (e.key === "r") { |
| 77 | 99 | note = ""; |
| 78 | 100 | follow = true; |
| 101 | around = undefined; | |
| 79 | 102 | ctx.invalidate(); |
| 80 | 103 | return true; |
| 81 | 104 | } |
| @@ -105,10 +128,20 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen { | ||
| 105 | 128 | const state = new ListState(); |
| 106 | 129 | let archived = false; |
| 107 | 130 | 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 }); | |
| 109 | 135 | 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)}` })); | |
| 111 | 143 | const say = (text: string): void => { |
| 144 | found = undefined; | |
| 112 | 145 | note = text; |
| 113 | 146 | ctx.invalidate(); |
| 114 | 147 | }; |
| @@ -124,6 +157,7 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen { | ||
| 124 | 157 | binding(["r"], "rename"), |
| 125 | 158 | binding(["x"], "archive or restore"), |
| 126 | 159 | binding(["a"], "archived"), |
| 160 | binding(["/"], "search"), | |
| 127 | 161 | ], |
| 128 | 162 | render(area, s) { |
| 129 | 163 | const c = ctx.theme.c; |
| @@ -131,7 +165,9 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen { | ||
| 131 | 165 | const text = |
| 132 | 166 | note !== "" |
| 133 | 167 | ? 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 | |
| 135 | 171 | ? archived ? "Nothing archived." : "No conversations yet; n starts one." |
| 136 | 172 | : archived ? "Archived; x brings one back." : "Your talks with the forge's agent; each opens at its /talk address."; |
| 137 | 173 | 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 { | ||
| 142 | 178 | const user = f.user; |
| 143 | 179 | if (user === undefined || e.ctrl || e.alt) return false; |
| 144 | 180 | 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) { | |
| 146 | 205 | archived = !archived; |
| 147 | 206 | state.index = 0; |
| 148 | 207 | note = ""; |
| @@ -169,7 +228,7 @@ export function talkChatsScreen(f: Session, ctx: Ctx): Screen { | ||
| 169 | 228 | } |
| 170 | 229 | if (here === undefined) return false; |
| 171 | 230 | if (e.key === "enter") { |
| 172 | ctx.push(chatScreen(f, ctx, here)); | |
| 231 | ctx.push(chatScreen(f, ctx, here, found?.hits[state.index]?.turnId)); | |
| 173 | 232 | return true; |
| 174 | 233 | } |
| 175 | 234 | if (e.key === "r") { |
src/web/pages/talk.ts +40 −10
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | archiveConversation, |
| 23 | 23 | type Conversation, |
| 24 | 24 | DEFAULT_PAGE, |
| 25 | findConversations, | |
| 26 | pageAround, | |
| 25 | 27 | lastExchange, |
| 26 | 28 | getConversation, |
| 27 | 29 | listConversations, |
| @@ -50,7 +52,7 @@ import { readForm } from "../form.ts"; | ||
| 50 | 52 | import { html, type Html } from "../html.ts"; |
| 51 | 53 | import { flashTo, page, redirect } from "../layout.ts"; |
| 52 | 54 | import { renderMarkdown } from "../markdown.ts"; |
| 53 | import { btn, time } from "../ui.ts"; | |
| 55 | import { badge, btn, row, time } from "../ui.ts"; | |
| 54 | 56 | import { talkCss, talkJs } from "./talk_client.ts"; |
| 55 | 57 | |
| 56 | 58 | /** 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 }; | ||
| 64 | 66 | const open = new Map<string, number>(); |
| 65 | 67 | const TURN_ID = /^[0-9a-z]{0,40}$/; |
| 66 | 68 | const HERE = "/talk"; |
| 69 | /** Longest search typed, in characters. */ | |
| 70 | const FIND_CHARS = 100; | |
| 67 | 71 | const CONVERSATION_ID = /^[0-9a-z]{1,40}$/; |
| 68 | 72 | |
| 69 | 73 | /** 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 | ||
| 210 | 214 | } |
| 211 | 215 | |
| 212 | 216 | /** 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 { | |
| 214 | 218 | const user = ctx.auth.user!; |
| 219 | const found = typed === "" ? undefined : findConversations(ctx.app.db, user.id, typed); | |
| 215 | 220 | const shown = listConversations(ctx.app.db, user.id, { archived: showArchived }); |
| 216 | 221 | const put = listConversations(ctx.app.db, user.id, { archived: !showArchived }).length; |
| 217 | 222 | const archived = conversation.archivedAt !== null; |
| @@ -225,10 +230,28 @@ function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean): Htm | ||
| 225 | 230 | ? html`<a class="small" href="${HERE}?archived=1">Archived (${put})</a>` |
| 226 | 231 | : null} |
| 227 | 232 | </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}-${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"> | |
| 229 | 252 | ${shown.length === 0 |
| 230 | 253 | ? 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 }))} | |
| 232 | 255 | </ul> |
| 233 | 256 | </nav> |
| 234 | 257 | <div class="talk-here bar"> |
| @@ -247,10 +270,17 @@ function chats(ctx: Ctx, conversation: Conversation, showArchived: boolean): Htm | ||
| 247 | 270 | </div>`; |
| 248 | 271 | } |
| 249 | 272 | |
| 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 { | |
| 251 | 274 | const user = ctx.auth.user!; |
| 275 | let before = before0; | |
| 252 | 276 | const here = latest ? HERE : urlOf(conversation); |
| 253 | 277 | 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; | |
| 254 | 284 | const listed = listTurns(ctx.app.db, user.id, conversation.id, { |
| 255 | 285 | limit: DEFAULT_PAGE, |
| 256 | 286 | ...(before === undefined ? {} : { before }), |
| @@ -265,24 +295,24 @@ function talkPage(ctx: Ctx, conversation: Conversation, before: string | undefin | ||
| 265 | 295 | const works = worksOf(ctx.app.db, user.id, conversation.id, turns); |
| 266 | 296 | // Paging back, the log ends before the newest turn: it does not follow. |
| 267 | 297 | 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>(); | |
| 269 | 299 | // Edit shows the composer holding the last message, posting to Edit instead of Send. |
| 270 | 300 | const editing = edit ? lastExchange(ctx.app.db, user.id, conversation.id).person : undefined; |
| 271 | 301 | 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"}"> | |
| 274 | 304 | <div> |
| 275 | 305 | ${contextLine(context)} |
| 276 | 306 | ${turns.length === DEFAULT_PAGE && turns[0] !== undefined |
| 277 | 307 | ? html`<p class="talk-more small"><a href="${here}?before=${turns[0].id}">Earlier</a></p>` |
| 278 | 308 | : 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>`} | |
| 280 | 310 | <ol class="talk-log" role="log" data-last="${last}" aria-label="Conversation with the forge"> |
| 281 | 311 | ${turns.length === 0 |
| 282 | 312 | ? 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. |
| 283 | 313 | <span class="talk-suggest">${SUGGESTIONS.map((q) => html`<a class="btn" href="${here}?q=${encodeURIComponent(q)}" data-suggest="${q}">${q}</a>`)}</span></li>` |
| 284 | 314 | : 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} | |
| 286 | 316 | </ol> |
| 287 | 317 | <div id="end"></div> |
| 288 | 318 | </div> |
src/web/pages/talk_client.ts +1 −2
| @@ -48,8 +48,7 @@ export const talkCss = /* css */ ` | ||
| 48 | 48 | .talk-files { justify-content: flex-end; } |
| 49 | 49 | .talk-more { text-align: center; } |
| 50 | 50 | .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. |
||
| 53 | 52 | .talk-replaced { opacity: 0.55; } |
| 54 | 53 | .talk-failed form { margin-top: 6px; } |
| 55 | 54 | .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"; | ||
| 6 | 6 | import { |
| 7 | 7 | appendTurn, |
| 8 | 8 | archiveConversation, |
| 9 | findConversations, | |
| 10 | findQuery, | |
| 11 | pageAround, | |
| 9 | 12 | getConversation, |
| 10 | 13 | listConversations, |
| 11 | 14 | newConversation, |
| @@ -184,4 +187,90 @@ describe("many conversations (#8814)", () => { | ||
| 184 | 187 | expect(listConversations(db, "u2")).toEqual([]); |
| 185 | 188 | expect(listTurns(db, "u2", mine.id)).toEqual({ ok: false, error: "not_found" }); |
| 186 | 189 | }); |
| 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 | }); | |
| 187 | 276 | }); |
test/talk-conversations.test.ts +35 −1
| @@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { createSession } from "../src/core/sessions.ts"; |
| 14 | 14 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 15 | import { migrations } from "../src/core/schema.ts"; |
| @@ -288,3 +288,37 @@ describe("the MCP rename", () => { | ||
| 288 | 288 | expect(listConversations(app.db, alice.id).find((x) => x.id === made.id)?.title).toBe("Keep me"); |
| 289 | 289 | }); |
| 290 | 290 | }); |
| 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"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 13 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 14 | 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -32,6 +32,8 @@ async function drive(user: User, keys: string[]): Promise<string> { | ||
| 32 | 32 | await settleApp(tui); |
| 33 | 33 | for (const k of keys) { |
| 34 | 34 | 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); | |
| 35 | 37 | await settleApp(tui); |
| 36 | 38 | } |
| 37 | 39 | const text = tui.screen.toText(); |
| @@ -106,4 +108,29 @@ describe("the terminal's conversations screen", () => { | ||
| 106 | 108 | expect(text).toContain("the most there can be"); |
| 107 | 109 | expect((app.db.query("SELECT COUNT(*) AS n FROM conversation WHERE user_id = ?1").get(alice.id) as { n: number }).n).toBe(CONVERSATIONS_MAX); |
| 108 | 110 | }); |
| 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 | }); | |
| 109 | 136 | }); |
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.