Repository navigation
Conversations: pin, archive, full-text search and export (#18) - #99
Merged
Merged
Conversation
conversations gains pinned_at and archived_at. Timestamps rather than booleans: set means on, and the time orders the pinned group and dates the archive. message_search is a side table with one row per message: the text worth finding it by (body, built in code) and a tsvector generated from it with a GIN index. A side table rather than columns on messages, because the chat page selects every messages column and would ship the search text to the browser, and because a builder_version column lets the index be rebuilt when what is indexed changes. Both FKs cascade, so deleting a message or a conversation removes its search rows. Migration 0079, generated by db:generate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildMessageSearchText turns a message into the text it is found by: the message itself, attachment names, and one line per tool call with its name, file paths, commands, short arguments and any links in its output. Raw tool output, file bodies, edit payloads, thinking and system messages are left out, and the result is capped at 60k characters so an insert can never hit the tsvector size limit. Postgres reads src/lib/engine/options.server.ts as a single token, so a search for "options" or "engine/options.server.ts" would miss the turn that edited it. Every path is followed by its basename and its segments as words, and searchQueryParts rewrites a path-shaped search as the matching phrase of segments. The last plain word is matched as a prefix, so results appear while typing. searchUserConversations ranks the best message per conversation with ts_rank_cd, highlights it with ts_headline using control-character markers (the sidebar renders them as text, never HTML), merges in title matches, and is scoped to the caller and to unarchived chats unless asked. Indexing never fails or delays a message write: insertMessageWithSequence hands a committed row to scheduleMessageIndex, and anything missed is picked up by backfillMessageSearch, which runs in the background at boot. It walks messages by id and trims tool output in Postgres before sending it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setConversationPinned and setConversationArchived set or clear the timestamps, scoped to the caller. Pinning unarchives and archiving unpins, and neither moves updatedAt, so tidying a chat never reorders it. A message the user sends into an archived chat brings it back; automations and monitors writing into one do not. getConversations now leaves archived chats out and returns every pinned chat on top of the 50 most recent, with a 280-character preview instead of the whole last reply. getArchivedConversations lists the archive. The list fingerprint the monitor pushes also counts pins and archives, since those no longer move updatedAt. searchConversations is the remote query behind the sidebar search. The stream's final assistant message and an edited message are indexed after they are committed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /chat/[id]/export?format=md|json downloads one conversation. Markdown is a readable transcript: a header with dates, agent, model and totals, then each message with its blocks in order - edits as diffs, shell calls as the command and its output, todo lists as checklists, subagents as quotes, thinking folded into <details>. Long tool output is cut short with a pointer to the JSON, and every code fence is longer than any run of backticks inside it, so tool output cannot break out. JSON has everything unshortened: the conversation (without the owner's id) and every message with its full metadata and blocks. Someone else's conversation answers 404, like one that does not exist. The response is an attachment with an ASCII and a UTF-8 file name, not cacheable and nosniff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… search (#18) Each sidebar row gets a menu: Archive (first, and a one-click button on the row on a pointer device), Pin to top, Rename, Export as Markdown or JSON, and Delete last, behind the danger confirm dialog, which says what goes with it and suggests archiving instead. Deleting the open chat goes home. The menu opens inline under the row, so the scrolling list and the phone drawer never clip it, and on a touch screen or a phone-width drawer the button is always shown and the row makes room for it. Pinned chats form their own group above the others in every grouping. The Status filter's "Archived" used to mean "no running turn"; it is now the archive, with a bar to get back, and "Active" is renamed "Running". The search box keeps its instant filter over loaded titles and, 250ms after typing stops, asks the server. Matches in messages and tool calls show under "In messages" with the title, an archived badge, the date and the snippet, whose highlights are rendered as <mark> elements from the markers - never as HTML from the database. A pin, archive, rename or delete re-runs the search, and the archive view refreshes when another tab changes the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chat.message-search-text: what a message is indexed by (paths with their file name and segments, commands, links in output, short arguments) and what never is (file bodies, edit payloads, thinking, raw output, system messages), the 60k cap on a 5MB message, how a typed search becomes a text query, and that snippet highlights are split as text. chat.conversation-search: a tool call's file found by its full path, file name, partial path, segment and a prefix while typing, indexed by the write itself; archived chats only when asked; nothing for another user; stop-word searches still find titles; the backfill indexes what the writer missed and rebuilds rows from older rules; the sidebar shows the snippet with <mark> and the rest as text. chat.conversation-lifecycle: pinning unarchives, archiving unpins, updated_at never moves, the default list keeps every pin and leaves the archive out, a user's message unarchives and an automation's does not. In the sidebar: pin, rename, export, one-click archive, the Archived view and Unarchive; delete asks and leaves the open chat; on a phone the menu button shows without hover and the title keeps its width. chat.conversation-export: the Markdown and JSON formatters, fences that tool output cannot close, and the route's headers, 400, 404 and refusal without a session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chat.md is the chat domain doc the folder never had, written for the owner: pin, archive (and what brings a chat back), rename, delete and what it removes versus keeps, how search works and what it does and does not index, and the two export formats. It corrects the re-audit's claim that delete takes the cost ledger with it: those rows stay and lose their run link. chat-console.md: how the sidebar shows it - the Pinned group, the row menu on pointer and touch devices, the Status views (the old "Archived" meant "not running"), and the search results. Parity: rename was scored even with no UI calling it; it has one now. Pin/archive is behind only for folders, search and export are even. The re-audit marks #18 shipped. README: feature paragraph, docs link and the export route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…added Date groups were keyed by their label, and day labels drop the year, so a chat from the same date a year earlier gave two groups one key - a duplicate-key error in a keyed each. Groups are now keyed by the day or the category. The search snippet is rendered on one line so the markup's whitespace does not pad each highlighted word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a pointer device the row's Archive and menu buttons are invisible until the row is hovered or focused, but they still sat over the time label and took clicks. A tap there on a touch-screen laptop, whose primary pointer reports hover, archived the chat without showing why. Hidden now also means pointer-events: none; hovering the row turns them back on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a search running on the server, an empty local list said "No chats match." while the results section below said "Nothing found in messages." The local message now shows only for searches too short to send, and the results section speaks for the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Escape was handled only inside the open menu, so with focus still on the "⋯" button it went to the phone drawer and closed that instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ts_headline's excerpt mode will not start or end an excerpt on a word of three letters or fewer. When the excerpt reached either end of a short message it trimmed those words away, down to the match itself: "fix the login bug now" came back as just "login", and "Deploy it" as "Deploy". Short messages are what people search for most. A message of up to 100 characters is now shown whole, with the matches marked. Longer ones keep two excerpts around the matches, with ShortWord=0 so their edges are kept too. The spec pins both, and the sidebar spec's "R&D <3 word now" extract now holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A backfill batch was one multi-row upsert. A message deleted between the batch's read and its write (its conversation deleted meanwhile) failed the whole statement on its foreign key, and the boot backfill stopped there until the next restart; on a first deploy that is the whole history. The batch now falls back to one row at a time on a foreign-key violation and skips the rows that are gone. The backfill also takes an optional conversation, which the spec uses so it no longer indexes other workers' rows while they are being inserted and deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The readable file name was cut at 80 UTF-16 units. An emoji across the cut left half a surrogate pair, encodeURIComponent threw on it, and the download answered 500. The name is now cut by code points, and a lone half already in a title is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ime (#18) The server lists pinned chats by when they were pinned and the archive by when each chat was archived, but the sidebar re-sorted every list by last activity and threw that order away. The Pinned group followed activity, and the Archived view was ordered, grouped by day and dated by last use rather than by when each chat was put away. The sorting and grouping move to $lib/chat/conversation-order, a plain module a spec can load without a browser: pins by pin time, the archive by archive time (each row's time is when it was archived there), the rest by last activity. Sort by Name or Project still applies in every group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening the Archived view said "Nothing archived." while its list was still on its way; it now says "Loading…" until the list arrives. And the row menu refetched the archive after every change, archive open or not; it now does so only when the chat is in the archive or is going into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page shows the first five chats of the list on a phone, where the sidebar is hidden. The list now starts with every pinned chat, so someone with five pins saw only those, however old, and never their latest chat. The five are now picked by last activity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setConversationPinnedForUser and setConversationArchivedForUser threw a plain Error for a conversation the caller does not own, which surfaced as a 500 in the server log. They now return null, and the remote functions answer 404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The delete cascades to the conversation's chat_runs rows, and Stop finds a run through its row. Deleting under a live turn left the agent running tools with nothing able to stop it, until its final write failed on a conversation that no longer existed. deleteConversationForUser checks ownership, interrupts the conversation's unfinished runs through the run registry (as Stop does), waits up to ten seconds for them to wind down, and then deletes. Another user's delete touches nothing. A turn held by the jobs worker is out of reach here, exactly as it is for Stop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar spec expects a chat back in the list after Unarchive. The list holds the 50 most recently active chats, and the chat was created about 30 seconds earlier while other workers seed chats for the same owner, so it could fall out of the list. It now has fresh activity before Unarchive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cts (#18) The chat doc now says the Pinned group goes by pin time and the archive by archive time, that a delete stops a running turn first and waits up to ten seconds, how long an extract is, how export file names are cut, that a phone's "Recent chats" ignore pins, and that the backfill skips a message deleted under it. The console doc covers the sidebar's side: pin order, the archive's order and its loading state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the wave-3 chain below #18: #22 composer mentions, #24 rewind, #4 native AskUserQuestion, #35 live shell and #32 orchestration (with #14's rail and #9's gateway as far as #32 had them). Resolutions: - src/lib/chat/conversation-delete.server.ts (add/add, #18 vs #35): both stopped a conversation's live turn before deleting it. Kept #18's version (ownership checked before anything is stopped, every unfinished run interrupted through the run registry, a bounded wait for the turn to wind down) and folded in #35's reasoning: with no perTaskStopAffordance the interrupt also kills background commands and delegated children. One result shape, { deleted, stoppedRuns }. - tests/chat.background-commands.spec.ts: #35's delete tests expect that shape ({ stoppedRuns: 1 | 0 } for { stoppedRun: true | false }); what they check is unchanged and still holds (no claim, so no wait). - src/lib/chat/chat.remote.ts: #18's pin/archive/search/archived-list functions alongside #24's edit/regenerate through message-branch.server and previewRewind; one deleteConversationForUser import; gt/ne/or dropped with the inline edit code; scheduleMessageIndex moved out. - src/lib/chat/message-branch.server.ts (merged clean, semantic): #18 re-indexed an edited message for search in the old inline editMessage; #24 moved the edit here, so editUserMessage now returns the edited row from its transaction and schedules the index after the commit. - src/lib/chat-console/Icon.svelte: #14's 'panel' plus #18's 'pin', 'archive', 'download', 'dots', 'trash'. - README.md: #18's conversation-lifecycle paragraph next to #35/#24/#4/ #9/#22's; the Docs list names chat.md once for both halves; the route map keeps #14's /chat/[id] rail note, #18's export route and /runs/[id]. - docs/chat/chat.md (add/add, #18 vs #24): one domain doc covering the conversation list (pin, archive, rename, delete, search, export) and changing a conversation after the fact (edit, regenerate, restore files, compact); anchors other docs link to are kept. - docs/structure/parity-reaudit-2026-09-22.md: one row each for #4 (shipped), #18 (shipped) and #22 (split, both halves shipped); the "cheap independents" line strikes #4, #18 and #14; #18's section notes the shared delete path and the edit re-index. - docs/structure/claude-desktop-parity.md: #18 marked landed in wave 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in what main gained after the chain below #18 was cut: #92 (#14 right rail), #93 (#9 gateway), #94 (#22 composer mentions), a chat on a retired Claude model moving to the settings default (resolveRunnableModel), and getOrCreateSettings creating a user's settings row once under an advisory lock. No textual conflicts. Checked for semantic ones: the stream route's model fallback runs before resolveParentMessage, so #18's unarchive-on-send and search indexing are untouched; no #18 spec reads app_settings or a conversation's model; the migration journal is unchanged (main adds no migration, #18 owns 0079_conversation_lifecycle). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main gained #95 (feat/24-rewind) while the previous merge was being checked. Everything #24 brings was already here through feat/32-orchestration's chain (feat/24-rewind -> feat/4 -> feat/35 -> feat/32), including b122672's hooks spec fix, so the tree does not change; this commit only makes main an ancestor so the PR merges cleanly. No conflicts, textual or semantic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tes it (#18, #32) #18 found delegated work through the delegation's own Task/Agent tool block (its prompt and subagent_type are indexed arguments) plus the subagent block's task and text. #32 dropped that tool block, so the card is now the delegation's only record, and it keeps the child's own calls on `transcript`, each labelled with the path, command or pattern it touched. Neither search nor the Markdown export read them, so a turn whose edit was delegated could not be found by the file edited, and a child the user stopped was exported with a failure cross. - message-search-text: a subagent line also carries the child's distinct tool calls with their labels (paths spelled out as segments), and its final report when it said nothing on the way. SEARCH_BUILDER_VERSION 2, so the boot backfill rebuilds older rows. - conversation-export: the card lists the child's calls, uses the report as the body when there is no text, reads `stopped` as stopped (not ✗) and keeps the reason. A pre-#32 card reads as before. - specs for both; docs (chat.md, the parity pages) say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with #18 docs/chat/spec.md and #35's section of the re-audit said a delete stops the turn and then deletes. Since the merge they run through #18's deleteConversationForUser, which checks ownership first and waits up to ten seconds for the stopped turn to save what it had done; both now say so and point at chat.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main as of #97 via the orchestration branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements all four parts of #18. Schema: conversations gains nullable pinned_at/archived_at timestamps, and a new message_search side table holds each message's searchable text plus a generated tsvector with a GIN index. The migration is drizzle/0079_conversation_lifecycle.sql, made by db:generate; db:check passes and a second db:generate reports no changes. Pin and archive live in conversation-lifecycle.server.ts. Pinning unarchives, archiving unpins, and neither moves updatedAt. A message the user sends unarchives the chat; automation and monitor writes do not. getConversations now leaves archived chats out, returns every pinned chat plus the 50 most recent others, and trims the lastMessage preview to 280 characters. getArchivedConversations lists the archive, and the list fingerprint now also moves on pin and archive. Search: buildMessageSearchText indexes message text, attachment names, each tool call's paths, commands, short arguments and printed URLs, and subagent tasks. It skips file bodies, edit payloads, raw output, thinking and system messages, caps at 60k characters, and spells each path out as segments so options.server.ts, engine/options.server.ts and options all match. searchUserConversations ranks with ts_rank_cd, highlights with ts_headline using control-character markers, merges title matches, scopes to the caller, and hides archived chats unless asked. Indexing runs fire-and-forget after commit, and a batched keyset backfill runs at boot. Export: GET /chat/[id]/export?format=md|json downloads a readable Markdown transcript or a complete JSON file; another user's chat returns 404, and responses are attachment, no-store and nosniff. UI: each sidebar row has a menu (Archive first plus a one-click hover button, Pin, Rename, two exports, Delete last behind the danger confirmDialog). Pinned chats get their own group, the Status filter has a real Archived view, and a 250ms-debounced server search shows snippets rendered as text. On touch or phone widths the menu button is always visible. Docs, README and the parity rows are updated.
After review: Addressed every must_fix, should_fix and claimed-but-not-fixed item from the review of issue #18, plus minor notes 1, 2, 3 and 5. The branch now has 21 commits and a clean worktree.
Docs (chat.md, chat-console.md) are updated.
bun run check(0 errors),bun run buildanddb:checkall pass.Closes #18
What was addressed
Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.
R&D <3 ${word} nowexpectation now matches.Verification
bun run check: 0 errorsbun run build: passesbun run check: 0 errors, tests included. -bun run build: exit 0. -db:check: fine. - tests/chat.conversation-order.spec.ts: its 9 pure tests pass under bun with a shim for @playwright/test (no DB, no app). - Search SQL: read-only SELECTs of ts_headline over literal strings on the dev DB (no table access). The rendered snippetSql and textQuery run and produce the expected extracts. - Export file names: sanity-checked directly with bun. New and changed specs for CI: tests/chat.conversation-search.spec.ts, chat.conversation-export.spec.ts, chat.conversation-lifecycle.spec.ts, chat.conversation-order.spec.ts, chat.conversation-delete.spec.ts.Docs updated
Follow-ups / residual risk
git merge-treeagainst origin/main (0bb97b6) still reports a content conflict in README.md; nothing else conflicts. It needs resolving when the PR is opened or main is merged in. I did not merge main here, per the no-merge rule.🤖 Generated with Claude Code