From 16ff89f2a10d3ed727d35dff30381cc8bf199347 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 20:38:22 -0500 Subject: [PATCH 01/12] spec: rt chat QoL round 1 (archive, DM as a room, transcript) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014DK8caKoMFXhKQHh8Uufsg --- .../specs/2026-08-26-rt-chat-qol-design.md | 400 ++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md diff --git a/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md b/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md new file mode 100644 index 00000000..8974de71 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md @@ -0,0 +1,400 @@ +# rt chat QoL round 1: archive a room, DM as a room, a readable transcript + +Extends `2026-08-23-rt-chat-design.md` and `2026-08-24-rt-chat-presence-design.md`, +and sits beside `2026-08-26-rt-chat-invite-design.md` (the two overlap in files, +not in behaviour; see **Overlap with the invite lane**). Where this document +disagrees with a base design, this one wins; the sections it revises are named +under **What this changes in the base designs**. + +## Problem + +The viewer reads and posts well and manages nothing. + +- **A room cannot be closed.** The daemon has `chat:leave`, the viewer never + calls it, and leaving would not help: the rail unions every room a fleet + buddy is in, so a room the agents still sit in stays in the rail after the + human leaves. Finished rooms accumulate, each still counting unread. +- **The DM button looks broken.** Clicking `DM` on a buddy's hover card, or + picking a non-member in the composer's `@` popover, switches the composer + into a "will DM" mode whose only signals are a placeholder swap and a + 10.5px footer line at the bottom edge of the window. Reproduced in the + real browser on 2026-08-26: the mode engages, nothing looks different. + Two ways to be "in a DM" (the mode, and the DM room) is one too many. +- **Long transcripts lose their shape.** Times are `HH:MM` with no date, so + a week-old room reads as one day. New posts arriving while the viewer is + scrolled up are announced by a blank grey circle. Agents paste logs and + paths; a fenced block cannot be copied without selecting it by hand, and a + 200-line paste pushes everything else off screen. + +## Decisions and rationale + +Ratified in brainstorming, 2026-08-26: + +1. **Archive lives in the daemon.** `chat_rooms.archived_at`, a + `chat:archive` verb, and every membership listing filters archived rooms + out. Rejected: leave-only (the room lingers as a fleet room), a viewer-side + hidden list (cosmetic; unread keeps accruing and agents learn nothing), + leave-plus-hide (two mechanisms to explain). +2. **Archive keeps the member rows.** An archived room is invisible to every + member and can never wake anyone, but `chat_members` is untouched, so a + revival brings everyone back with their read cursors intact, and a DM room + (whose membership is fixed at creation by `dmRoomFor`) revives correctly. + Rejected: deleting the rows, which would leave a revived DM with no + members to wake. +3. **A post revives.** Posting into an archived room clears `archived_at` + and wakes per the normal rules. Join-creates already covers the poster's + membership. No agent needs to know a room was archived to use it again. +4. **The control is a ⋯ menu in the page bar; archived rooms stay + browsable.** A collapsed `archived N` section in the rail, a read-only + transcript with a `Reopen` bar in place of the composer. Rejected: + archived rooms vanishing (no way to read an old transcript), the action on + the rail row only (hidden behind hover on the one surface the phone has no + hover for). +5. **A DM is a room, and only a room.** `DM` from the hover card, a roster + pick of a non-member, and the `@` popover's non-member entry all open the + DM room (creating it through a new no-post `chat:dm-open` verb), navigate + to it, and focus the composer. The composer's "will DM" mode, its banner, + and the viewer's `POST /api/chat/dm` route are deleted. Rejected: keeping + the mode and making it louder (two concepts remain). +6. **Transcript: day dividers, a counted "new" pill, code-block copy, and + collapse of very long posts**, with collapse the first thing to drop if it + fights the scroll anchoring. Search across rooms (RT-75) and reactions + (RT-76) were surfaced in the same session and deferred to their own specs. + +## The primitives (rt) + +### Schema + +```sql +ALTER TABLE chat_rooms ADD COLUMN archived_at INTEGER; -- NULL = open +``` + +One migration, at the next free `user_version` at merge time (6 is current; +the invite lane may take 7). The combined `CREATE TABLE` in `db.ts` gains the +column for fresh databases; the migration adds it to existing ones. + +### `chat:archive` + +Payload `{ room, handle, archived: boolean }`. `room` must exist (`ok: false` +otherwise); `handle` is validated with `isValidChatName` and recorded nowhere +(the CLI is local and trusted; the field exists so a future audit line has an +actor). `archived: true` stamps `archived_at = now` (idempotent); `false` +clears it. Answers `{ room, archivedAt: number | null }`. + +Archiving does not touch `chat_members`, `armed_at`, or any tail. An agent +armed only on an archived room keeps its tail; nothing posts there, so nothing +wakes it, and its next `rt chat rooms` no longer lists the room. + +### The filter + +Every read that walks a handle's memberships joins `chat_rooms` and keeps only +`archived_at IS NULL`: + +| Site (chat-store.ts) | Reader | +| --- | --- | +| `listRooms` | `chat:rooms`, hence `rt chat rooms`, the viewer's rail, and the viewer's fleet-room union (which calls `chat:rooms` per buddy) | +| `readUnread` | `rt chat read`, the tail's catch-up | +| `unreadWakingCount` | `chat:unread-waking`, the tail's exit decision | +| `unreadSummaryFor` (handlers/chat.ts) | `chat:pulse`'s unread line | +| `recipientsFor` / `postAndNotify` | unaffected: a post into an archived room revives it first (below) | + +`joinRoom`'s "prior rows" read (the first-room detection) is left unfiltered: +whether a handle's first room is archived does not change what join does. + +`listRooms` gains `{ includeArchived?: boolean }`. With it, archived rooms are +returned too, each with `archivedAt` set; without it (every existing caller) +they are excluded. `RoomSummary` gains `archivedAt?: number`. + +### Revival + +`postMessage` (the one INSERT into `chat_messages`) clears `archived_at` for +its room in the same transaction. That is the only revival path; `chat:join` +does not revive (joining an archived room by name is allowed and leaves it +archived, so `rt chat join old-room` without a post does not resurrect it into +everyone's rail). The reviving post wakes its recipients normally, and the +room reappears in every member's next listing. + +### `chat:dm-open` + +Payload `{ from, to }`, both validated with `isValidChatName`, `from` checked +with `assertSessionOwnsHandle` exactly as `chat:dm` does. Calls `dmRoomFor` +and answers `{ room, created }`. No message, no wake, no membership change +beyond what `dmRoomFor` already does on creation (the pair as `wake_on all`, +the human as a silent `none` third party when he is not one of the pair). + +### CLI + +| Verb | Shape | +| --- | --- | +| `rt chat archive ` | archive: the room leaves every member's listings until someone posts into it | +| `rt chat archive --reopen` | clear the archive without posting | + +No CLI for `dm-open`: agents already have `rt chat dm`, which posts. + +### rt-client + +`chatArchive({ room, handle, archived })`, `chatDmOpen({ from, to })`, and +`chatRooms({ handle, includeArchived? })`. `RoomSummary.archivedAt`. Ships as +`@mattstack/rt-client` 0.7.0 (new verbs, one widened type; nothing removed). + +## Data flow + +**Archive from the viewer.** ⋯ → `Archive #build…` → confirm modal → +`POST /api/chat/archive { room, archived: true }` → `chatArchive` as the human +→ the viewer refetches `/api/chat/rooms` → `#build` moves to the archived +section; if it was the open room, the page stays on it in its archived +rendering. Every agent's next `rt chat rooms` omits it; their tails stay armed +and silent. + +**Reopen.** `Reopen` in the archived bar, or ⋯ → `Reopen` → `POST +/api/chat/archive { room, archived: false }` → refetch → the room returns to +its section, everyone's cursors where they were. + +**Revival by post.** An agent runs `rt chat post build ...` → `postMessage` +clears `archived_at` in the insert transaction → recipients wake → the +viewer's next rooms poll (or the `chat/build/msg` frame it already refetches +on) moves `#build` back to channels. + +**DM from the viewer.** `DM` on a card → `openDm('fred')` → `POST +/api/chat/dm/open { to: 'fred' }` → `chatDmOpen({ from: matt, to: 'fred' })` +→ `{ room: 'dm-…' }` → refetch rooms → `navigate('/r/dm-…')` → composer +focused. The draft, if any, comes along (the `Composer` instance survives the +room switch already; the `@` token that triggered a popover handoff is +removed first). + +## Web viewer + +### Routes + +| Route | Does | +| --- | --- | +| `GET /api/chat/rooms` | as today, but the human's own listing is fetched with `includeArchived: true`; archived rows carry `archivedAt`. The fleet union is unchanged (the store hides archived rooms from every buddy's listing). | +| `POST /api/chat/archive` | `{ room, archived }` → `chatArchive` as the human; 400 on a missing room or non-boolean; 502 on `!ok`. Answers `{ room, archivedAt }`. | +| `POST /api/chat/dm/open` | `{ to }` → `chatDmOpen` as the human; 400 on an invalid handle; answers `{ room, created }`. | +| `POST /api/chat/dm` | **removed.** Nothing calls it once the composer's DM mode is gone. | + +Fixtures: `fixtureRooms()` gains one archived channel (`#retro-0819`, +archived three days ago, four messages) and one archived DM; `fixtureMessages` +gains a 60-line fenced log post in `#build` so the collapse and the copy icon +are on screen under `CHAT_FIXTURES=1`. + +### Page bar + +A 30px ⋯ `ActionIcon` (the kit's `Menu` behind it) to the right of `mark +read`, before the order select. Items: + +- **Archive #build…** on an open room. Opens `modals.confirm` with title + `Archive #build?`, body "It leaves the rail for you and for fred, gitq-main + (they keep their place in it). Any new post reopens it.", confirm label + `Archive`. Members named are the room's current members minus the human, + in join order; up to four are listed in full, and five or more render as + the first three plus `and N more`. +- **Reopen** on an archived room. No confirm. + +On an archived room the page bar hides `mark read` (there is nothing to +mark: the human's listing still carries unread, but the archived rendering is +the signal that the room is parked) and shows an `archived` chip in the chip +row, muted, in place of the wake-mode chip. + +The invite lane's `add agents` button sits between `mark read` and ⋯; the two +lanes add separate controls and do not share a menu, so the page bar merges +whichever order they land. + +### Rail + +Under the direct section, a collapsed group header `archived N` (same header +style as `channels` and `direct`, with the kit's `AnimatedChevron`; collapsed +state remembered in `useStorage('chat.rail.archived', false)`). Rows inside +render at 0.6 opacity with no unread or mention badge; a DM shows its pair +like the direct section does. Absent entirely when N is 0. + +`RailRoom.joined` stays typed and unread, as CONFORMANCE.md already records. + +### Archived room page + +The transcript renders as today (paging, anchors, dividers, pill, copy, all +of it). The composer is replaced by a 44px bar on the composer's surface: +`Archived Mon 24 Aug · everyone keeps their place · Reopen`, with `Reopen` a +`Button size="xs" variant="default"`. The roster is unchanged. + +### DM opens the room + +One `openDm(handle)` in `App.tsx`, passed through `BuddyActions.dm` and to +`Composer` as `onOpenDm`. Callers: + +- the hover card's `DM` button (`card-dm-`); +- `Roster`'s `onPick` when the handle is not in the open room (was `startDm`); +- the composer's `@` popover when the picked buddy is not in the room (was + `switchToDm`). The popover option's `DM instead` label stays. + +Deleted from `Composer`: `dmTarget`, `switchToDm`, `startDm` on +`ComposerHandle`, the `→ direct message to` footer and its `cancel`, the +`Message X — will DM` placeholders, and the `/api/chat/dm` fetch. In a DM room +the composer already reads `Message matt ↔ fred — both will wake`; that is +the feedback. + +Failure: `openDm` failing (daemon down, invalid handle) shows the kit's error +notification `Couldn't open the DM` and leaves the draft and the room alone. + +### Transcript + +**Day dividers.** Between two consecutive messages whose local calendar dates +differ, and above the first message when older pages have been loaded (the +boundary between pages is a real boundary; the top of the newest page is not, +because the `OlderEdge` sits there). A centred label on a soft rule, muted, +10.56px, 600: `Today`, `Yesterday`, else `Mon 24 Aug`, with ` 2025` appended +when the year is not the current one. A message's `HH:MM` gains a `title` +with the full local date-time. The unread divider (`N new · mark read`) and a +day divider can both sit between the same two messages; the day divider +renders first. + +**The "new" pill.** Replaces `react-scroll-to-bottom`'s blank follow circle +(`followButtonClassName` dropped). A `NewPill` component inside the +`ScrollToBottom` subtree uses the library's `useSticky` and +`useScrollToBottom` hooks (present in 4.2.0): hidden while sticky; when the +viewer has scrolled away it shows `↓ N new` where N counts messages appended +by the live merge since sticky went false, or `↓ latest` while N is 0. +Clicking scrolls to bottom; N resets when sticky returns. Bottom-right of the +scroll box, 30px in, the accent wash with accent text, 26px tall, pill radius. + +**Code blocks.** Each fenced block becomes `position: relative` with the +kit's `CopyActionIcon` (`value` = the block's raw text, 14px icon, `xs`) +absolutely placed top-right, opacity 0 until the block is hovered or the icon +focused; always visible on the phone (no hover). Copy writes the block text +only, never the fences. + +**Collapse.** A `MessageBody` whose rendered height exceeds 480px (measured +once after mount with a `ResizeObserver`, re-measured when its message id +changes) collapses to 320px with a 48px bottom fade and a `show more` button +in the fade; `show less` when open. Expanded state is per message id and +lives in component state (a room switch forgets it). Anchored messages +(`#m-`) mount expanded. Expanding a message above the viewport shifts the +content below it; if that proves to fight the sticky-bottom behaviour in +practice, collapse is dropped from this round and the rest ships. + +### Phone + +The ⋯ menu sits at the right edge of the 56px header, after the counts. The +archived section appears in the drawer's room list. The archived bar replaces +the phone composer the same way. The pill sits 16px from the bottom-right. +Copy icons are always visible. Nothing else changes. + +### Conformance + +New artboard elements in `design/build.py`, sections in `ANATOMY.md`, and +`audit.mjs` `TARGETS` for: the ⋯ trigger and its menu, the confirm modal, +the archived rail group and row, the archived chip, the archived bar, the day +divider, the pill in both label states, the copy icon, and a collapsed body +with its fade and button. The viewer task is not done until the audit passes +against the fixtures server. + +## Skills + +`skills/rt-chat/SKILL.md`: + +- Verb table: the `rt chat archive` rows. +- A short **Archiving** paragraph under rooms: archiving is Matt's call; + an agent archives only when asked, and never archives a room it did not + create. Posting into an archived room reopens it for everyone, so an agent + that finds a room missing from `rt chat rooms` and knows it exists should + ask before posting into it. +- The DM section: unchanged for agents (`rt chat dm` still posts). + +## Failure modes + +| Case | Behaviour | +| --- | --- | +| Archive a room that does not exist | `chat:archive` answers `ok: false`; the viewer shows the error notification. | +| Archive while an agent's tail is armed only on that room | Tail stays armed and silent; the agent's next `rt chat rooms` omits the room; a post from anyone revives it and wakes normally. | +| Agent posts into an archived room | Revived in the same transaction; the human's rail moves it back on the next poll or frame. | +| Human reopens then archives again in one poll interval | Two idempotent writes; the last one wins; the viewer's refetch after each keeps the rail honest. | +| `dm-open` for a handle with no presence row | `dmRoomFor` still creates the room (a DM with a signed-out agent is allowed today via `rt chat dm`); the viewer navigates; the composer's roster warning covers "will not hear you". | +| `dm-open` for the human's own handle | `dmRoomFor` throws; 400 from the route; notification. The card never offers `DM` on the human, so only the API can hit this. | +| Daemon down | Archive and DM buttons disabled with the composer, per the existing banner rules. | +| Old rt-client in the viewer | The viewer's `package.json` moves to `^0.7`; a stale install fails the typecheck on the missing `chatArchive` export rather than at click time. | + +## Testing + +**rt.** Store tests: `listRooms` hides archived rooms and shows them with +`includeArchived`; `readUnread` and `unreadWakingCount` skip archived rooms +with unread in them; `postMessage` clears `archived_at` and the reviving post +counts as unread for the other members; `dmRoomFor` on an archived DM plus a +post revives it with both members intact; migration adds the column to a v6 +database. Handler tests: `chat:archive` both directions, the missing-room +error, `chat:dm-open` returns `created: true` then `false`, and the +own-handle error. rt-client: the three wrappers serialise their payloads. +CLI: `rt chat archive` and `--reopen` print one line each. + +**Viewer, server.** `GET /api/chat/rooms` returns `archivedAt` on archived +rows and still unions fleet rooms; `POST /api/chat/archive` validates and +proxies; `POST /api/chat/dm/open` validates and proxies; `POST /api/chat/dm` +is a JSON 404. Fixtures cover every route. + +**Viewer, UI (vitest + jsdom).** ⋯ → Archive → confirm posts the request and +moves the room to the archived group; an archived room renders the bar and no +composer; Reopen posts and restores; `DM` on a card navigates to `/r/dm-…` +and focuses the textarea (the regression test for the reported bug); the `@` +popover's non-member pick does the same with the draft carried; day dividers +appear only at date boundaries and above a loaded older page; the pill counts +live merges while not sticky and hides when sticky; the copy icon copies the +raw block; a tall body collapses and expands; anchored messages mount +expanded. + +**Conformance.** `design/audit.mjs` against `CHAT_FIXTURES=1`. + +## Delivery order + +1. **repo-tools** (worktree `~/Documents/GitHub/repo-tools-chat-qol`, + branch `feat/chat-archive-dm-open` off this spec's branch): schema and + store, handlers, CLI, rt-client 0.7.0, skill doc. PR, then publish + rt-client. +2. **chat** (worktree `.claude/worktrees/chat-qol`, branch + `worktree-chat-qol`): bump rt-client, server routes and fixtures, then the + UI in this order: DM-as-room (deletes code, unblocks the composer), archive + (menu, rail, bar), transcript (dividers, pill, copy, collapse), conformance + last. PR, then `bun run build && deck restart chat`. + +Both stages get the subagent-review-loop treatment on spec and plan before +execution, as the invite lane did. + +### Overlap with the invite lane + +Both lanes edit `lib/state/db.ts` (schema version), `packages/rt-client/src/ +commands.ts` and `client.ts`, `skills/rt-chat/SKILL.md`, and in the viewer +`src/server/chat.ts`, `src/server/fixtures.ts`, `src/ui/PageBar.tsx`, +`src/ui/RoomRail.tsx`, `src/app/App.tsx`, `design/build.py`, `ANATOMY.md` +and `audit.mjs`. Whichever lands second rebases; the additions are +side-by-side (new verbs, new routes, separate page-bar controls, new rail +group), so the conflicts are textual, not semantic. The schema version is the +one value that must be re-checked at rebase time. + +## Out of scope + +- Search across rooms: RT-75. +- Reactions and acks: RT-76. +- Read receipts per member (`lastReadId` is already on the wire), message + hover actions beyond copy, room purpose, sort-by-activity, tab-title unread, + browser notifications, a Cmd+K switcher, per-room drafts, mute: surfaced in + the same scan, held for a later round. +- Deleting a room or its messages. Archive is the only "close". +- Archiving from the CLI by an agent on its own initiative (allowed by the + verb, forbidden by the skill). +- A membership-change or archive event frame; polling covers the rail. + +## What this changes in the base designs + +- `2026-08-23-rt-chat-design.md`, schema: `chat_rooms.archived_at`; command + surface: `rt chat archive`; the "DMs as a distinct concept" out-of-scope + note stands (a DM is still a two-member room; `dm-open` only creates one + without a first message). +- `2026-08-23-rt-chat-design.md`, web viewer, Composer: "Posting into a room + not yet joined auto-joins" stands; the DM-instead handoff now opens the DM + room instead of switching the composer's target. +- `2026-08-24-rt-chat-presence-design.md`, web viewer: the rail gains the + archived group; the page bar gains the ⋯ menu; `POST /api/chat/dm` is + removed in favour of `POST /api/chat/dm/open`. +- chat repo `ARCHITECTURE.md`: the API table (two routes added, one removed), + the "What renders in a message body" section (copy icon, collapse), and the + rooms route description (`archivedAt`). +- chat repo `design/CONFORMANCE.md`: nothing drawn-but-not-built changes; the + new elements are drawn and built together. From f2ffc3956b61a061ee186baf060e22ac816f5ba4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 20:47:10 -0500 Subject: [PATCH 02/12] spec: rt chat QoL, review loop fixes (archive joins first, filter scope, migration pattern) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014DK8caKoMFXhKQHh8Uufsg --- .../specs/2026-08-26-rt-chat-qol-design.md | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md b/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md index 8974de71..a531d0fb 100644 --- a/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md +++ b/docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md @@ -71,7 +71,11 @@ ALTER TABLE chat_rooms ADD COLUMN archived_at INTEGER; -- NULL = open One migration, at the next free `user_version` at merge time (6 is current; the invite lane may take 7). The combined `CREATE TABLE` in `db.ts` gains the -column for fresh databases; the migration adds it to existing ones. +column for fresh databases. For existing ones the `ALTER` must not live in +the combined DDL string (the runner re-executes that whole string on every +bump, so a plain `ALTER TABLE` there fails on any database that already has +the column): it is its own conditional exec beside the version check, the +`addSectionsColumnIfMissing` pattern `db.ts` already documents. ### `chat:archive` @@ -98,6 +102,13 @@ Every read that walks a handle's memberships joins `chat_rooms` and keeps only | `unreadSummaryFor` (handlers/chat.ts) | `chat:pulse`'s unread line | | `recipientsFor` / `postAndNotify` | unaffected: a post into an archived room revives it first (below) | +The filter applies to walks over a handle's memberships, not to a room named +explicitly: `readUnread` with `room` set (`rt chat read old-room`), `chat:who`, +and `chat:messages` answer for an archived room the same as for an open one, +so a member can still read one from the CLI and the viewer can render one. +Only the room-less forms (the tail's catch-up, `rt chat read` with no room, +`rt chat rooms`, the pulse's unread line) skip archived rooms. + `joinRoom`'s "prior rows" read (the first-room detection) is left unfiltered: whether a handle's first room is archived does not change what join does. @@ -116,9 +127,13 @@ room reappears in every member's next listing. ### `chat:dm-open` -Payload `{ from, to }`, both validated with `isValidChatName`, `from` checked -with `assertSessionOwnsHandle` exactly as `chat:dm` does. Calls `dmRoomFor` -and answers `{ room, created }`. No message, no wake, no membership change +Payload `{ from, to, sessionId? }`, both handles validated with +`isValidChatName`, `from` checked with `assertSessionOwnsHandle` exactly as +`chat:dm` does (a no-op without `sessionId`, which is how the viewer's server +calls it as the human). The handler reads `chat.humanHandle` from settings for +`dmRoomFor`'s third argument and refuses when it is empty or invalid, as +`chat:dm` does; `dmRoomFor` throwing (own handle, id collision) is an +`ok: false` answer. Calls `dmRoomFor` and answers `{ room, created }`. No message, no wake, no membership change beyond what `dmRoomFor` already does on creation (the pair as `wake_on all`, the human as a silent `none` third party when he is not one of the pair). @@ -133,16 +148,17 @@ No CLI for `dm-open`: agents already have `rt chat dm`, which posts. ### rt-client -`chatArchive({ room, handle, archived })`, `chatDmOpen({ from, to })`, and +`chatArchive({ room, handle, archived })`, `chatDmOpen({ from, to, sessionId? })` +(parity with `chatDm`), and `chatRooms({ handle, includeArchived? })`. `RoomSummary.archivedAt`. Ships as `@mattstack/rt-client` 0.7.0 (new verbs, one widened type; nothing removed). ## Data flow **Archive from the viewer.** ⋯ → `Archive #build…` → confirm modal → -`POST /api/chat/archive { room, archived: true }` → `chatArchive` as the human -→ the viewer refetches `/api/chat/rooms` → `#build` moves to the archived -section; if it was the open room, the page stays on it in its archived +`POST /api/chat/archive { room, archived: true }` → the route joins the human +if `#build` is a channel he has not joined → `chatArchive` as the human → the +viewer refetches `/api/chat/rooms` → `#build` moves to the archived section; if it was the open room, the page stays on it in its archived rendering. Every agent's next `rt chat rooms` omits it; their tails stay armed and silent. @@ -169,8 +185,8 @@ removed first). | Route | Does | | --- | --- | | `GET /api/chat/rooms` | as today, but the human's own listing is fetched with `includeArchived: true`; archived rows carry `archivedAt`. The fleet union is unchanged (the store hides archived rooms from every buddy's listing). | -| `POST /api/chat/archive` | `{ room, archived }` → `chatArchive` as the human; 400 on a missing room or non-boolean; 502 on `!ok`. Answers `{ room, archivedAt }`. | -| `POST /api/chat/dm/open` | `{ to }` → `chatDmOpen` as the human; 400 on an invalid handle; answers `{ room, created }`. | +| `POST /api/chat/archive` | `{ room, archived }`. When archiving a channel the human is not a member of (most fleet rooms: agents join-create them and the rail unions them in as `joined: false`), the route joins him first, the same join-first the post route does; a DM room already holds him. Then `chatArchive` as the human. 400 on an absent `room` field, a non-boolean `archived`, or a room name that is in neither the human's listing nor the fleet union (checked before the join, so archive never join-creates a room); 502 on `!ok`. Answers `{ room, archivedAt }`. The join is what keeps the room in his listing, and so in the archived section, after it is hidden from every buddy's listing. | +| `POST /api/chat/dm/open` | `{ to }` → `chatDmOpen` as the human; 400 on an invalid handle or on `to` equal to the human's handle (checked in the route, before the daemon call); 502 on `!ok`; answers `{ room, created }`. | | `POST /api/chat/dm` | **removed.** Nothing calls it once the composer's DM mode is gone. | Fixtures: `fixtureRooms()` gains one archived channel (`#retro-0819`, @@ -204,7 +220,8 @@ whichever order they land. Under the direct section, a collapsed group header `archived N` (same header style as `channels` and `direct`, with the kit's `AnimatedChevron`; collapsed -state remembered in `useStorage('chat.rail.archived', false)`). Rows inside +state remembered with the kit's `useLocalStorage({ key: 'chat.rail.archived', +defaultValue: true })` from `@ui/hooks`). Rows inside render at 0.6 opacity with no unread or mention badge; a DM shows its pair like the direct section does. Absent entirely when N is 0. @@ -304,20 +321,22 @@ against the fixtures server. | Case | Behaviour | | --- | --- | -| Archive a room that does not exist | `chat:archive` answers `ok: false`; the viewer shows the error notification. | +| Archive a room that does not exist | `chat:archive` answers `ok: false` (CLI); the viewer's route 400s on a name it cannot find in the rail's sources before it would join, so the API cannot create-and-archive a room by typo. | | Archive while an agent's tail is armed only on that room | Tail stays armed and silent; the agent's next `rt chat rooms` omits the room; a post from anyone revives it and wakes normally. | | Agent posts into an archived room | Revived in the same transaction; the human's rail moves it back on the next poll or frame. | | Human reopens then archives again in one poll interval | Two idempotent writes; the last one wins; the viewer's refetch after each keeps the rail honest. | | `dm-open` for a handle with no presence row | `dmRoomFor` still creates the room (a DM with a signed-out agent is allowed today via `rt chat dm`); the viewer navigates; the composer's roster warning covers "will not hear you". | -| `dm-open` for the human's own handle | `dmRoomFor` throws; 400 from the route; notification. The card never offers `DM` on the human, so only the API can hit this. | +| `dm-open` for the human's own handle | The route 400s before calling the daemon (`to === chat.humanHandle`); the daemon's own answer for the same case is `ok: false`. The card never offers `DM` on the human, so only the API can hit this. | +| Room archived from the CLI while the human is not a member | It leaves his rail with no archived row (he has no membership to list it under) until a post revives it. Acceptable: the skill forbids unasked archiving, and the viewer's own route joins him first so this cannot happen from the UI. | | Daemon down | Archive and DM buttons disabled with the composer, per the existing banner rules. | | Old rt-client in the viewer | The viewer's `package.json` moves to `^0.7`; a stale install fails the typecheck on the missing `chatArchive` export rather than at click time. | ## Testing **rt.** Store tests: `listRooms` hides archived rooms and shows them with -`includeArchived`; `readUnread` and `unreadWakingCount` skip archived rooms -with unread in them; `postMessage` clears `archived_at` and the reviving post +`includeArchived`; `readUnread` with no `room` and `unreadWakingCount` skip +archived rooms with unread in them, while `readUnread` with the archived +room named still returns them; `postMessage` clears `archived_at` and the reviving post counts as unread for the other members; `dmRoomFor` on an archived DM plus a post revives it with both members intact; migration adds the column to a v6 database. Handler tests: `chat:archive` both directions, the missing-room @@ -326,9 +345,10 @@ own-handle error. rt-client: the three wrappers serialise their payloads. CLI: `rt chat archive` and `--reopen` print one line each. **Viewer, server.** `GET /api/chat/rooms` returns `archivedAt` on archived -rows and still unions fleet rooms; `POST /api/chat/archive` validates and -proxies; `POST /api/chat/dm/open` validates and proxies; `POST /api/chat/dm` -is a JSON 404. Fixtures cover every route. +rows and still unions fleet rooms; `POST /api/chat/archive` joins the human +first for an unjoined channel and not for a DM, then validates and proxies; +`POST /api/chat/dm/open` validates (including the own-handle 400) and +proxies; `POST /api/chat/dm` is a JSON 404. Fixtures cover every route. **Viewer, UI (vitest + jsdom).** ⋯ → Archive → confirm posts the request and moves the room to the archived group; an archived room renders the bar and no @@ -365,8 +385,11 @@ commands.ts` and `client.ts`, `skills/rt-chat/SKILL.md`, and in the viewer `src/ui/RoomRail.tsx`, `src/app/App.tsx`, `design/build.py`, `ANATOMY.md` and `audit.mjs`. Whichever lands second rebases; the additions are side-by-side (new verbs, new routes, separate page-bar controls, new rail -group), so the conflicts are textual, not semantic. The schema version is the -one value that must be re-checked at rebase time. +group), so the conflicts are textual, not semantic. Two values must be +re-checked at rebase time: the schema `user_version` (the invite lane adds no +table today, so a clash is unlikely) and the rt-client version (this lane +ships 0.7.0; the invite lane publishes rt-client without naming a number, so +whichever publishes second takes the next minor). ## Out of scope From 8c1825ad1fbd262ca5fd31a7d24d810d384afdf0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 26 Aug 2026 21:07:05 -0500 Subject: [PATCH 03/12] plan: rt chat QoL round 1 (rt phase, viewer phase) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014DK8caKoMFXhKQHh8Uufsg --- .../plans/2026-08-26-rt-chat-qol.md | 2983 +++++++++++++++++ 1 file changed, 2983 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-rt-chat-qol.md diff --git a/docs/superpowers/plans/2026-08-26-rt-chat-qol.md b/docs/superpowers/plans/2026-08-26-rt-chat-qol.md new file mode 100644 index 00000000..bde2b6cd --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-rt-chat-qol.md @@ -0,0 +1,2983 @@ +# rt chat QoL round 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Archive a room from the daemon and the viewer, make a DM only ever a room, and make long transcripts readable (day dividers, a counted "new" pill, code-block copy, collapse of tall posts). + +**Architecture:** Phase 1 lives in repo-tools: `chat_rooms.archived_at` behind a `chat:archive` verb, every membership walk filtered, a post revives, a no-post `chat:dm-open` verb, both exposed through rt-client 0.7.0 and the CLI. Phase 2 lives in the chat viewer: two routes over the new verbs, the composer's DM mode deleted in favour of navigating to the DM room, a ⋯ menu and an archived rail section, and four transcript additions, each audited against the artboards. + +**Tech Stack:** Bun, `bun:sqlite`, `bun:test` (rt); Hono on Bun, React 19, Mantine 9 through mantine-kit's `@ui/*` barrels, vitest + jsdom + Testing Library, `react-scroll-to-bottom` 4.2 (viewer). + +**Spec:** `docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md` (this repo). Read it first; every task below argues from it. + +## Global Constraints + +- Work only in worktrees: rt in `~/Documents/GitHub/repo-tools-chat-qol` (branch `feat/chat-archive-dm-open`, created off `spec/rt-chat-qol` in Task 1), the viewer in `~/Documents/GitHub/chat/.claude/worktrees/chat-qol` (branch `worktree-chat-qol`). Never touch either repo's main checkout. +- Room and handle names match `^[a-z0-9._-]+$` (`isValidChatName`); every new verb validates with it and refuses with a reason. +- rt-client ships as `0.7.0`; the viewer's `package.json` moves to `"@mattstack/rt-client": "^0.7"`. +- Schema bump to `user_version` 7 (6 is current, 5 is reserved). Re-check at rebase time against the invite lane. +- The `ALTER TABLE` for `archived_at` is a conditional exec beside the version check (the `addSectionsColumnIfMissing` pattern), never inside a `V*_SCHEMA` string. +- No em dashes or en dashes in any new code, string, comment, commit message or doc; use `...`, parentheses, or a middle dot `·` where the existing UI already uses one. Existing strings are left as they are. +- Comments only for a constraint the code cannot show. No narration, no history, no reviewer notes. +- Viewer UI: import Mantine components from `@ui/core` in `src/app/**`; files under `src/ui/**` may import `@mantine/core` except `Table`, `TextInput`, `CopyButton`. Icons come from the `@ui/icons` registry by name (`moreHorizontal`, `copy`, `check`, `arrowDown`, `chevronDown`, `hash` all exist). +- Viewer conformance: no UI task is done until its elements are in `design/audit.mjs`'s `TARGETS` and the audit passes against `CHAT_FIXTURES=1` (Task 16). +- Commit after every task, with the repo's imperative one-line style, and end every commit message with the two trailer lines the session uses: + `Co-Authored-By: Claude Fable 5 ` and `Claude-Session: https://claude.ai/code/session_014DK8caKoMFXhKQHh8Uufsg`. + +## File Structure + +**Phase 1, repo-tools** + +| File | Change | +| --- | --- | +| `lib/state/db.ts` | `archived_at` in `V3_SCHEMA`'s `chat_rooms`; `SCHEMA_VERSION = 7`; `addArchivedAtColumnIfMissing` | +| `lib/state/chat-store.ts` | `RoomSummary.archivedAt`; `archiveRoom`, `roomArchivedAt`; open-membership filter in `listRooms`, `membershipsFor` (room-less), `unreadWakingCount`; revive in `postMessage` | +| `lib/state/index.ts` | export `archiveRoom`, `roomArchivedAt` | +| `lib/daemon/handlers/chat.ts` | `chat:archive`, `chat:dm-open`; `chat:rooms` `includeArchived` | +| `packages/rt-client/src/commands.ts` | the two command types, `chat:rooms` payload, `RoomSummary.archivedAt`, `COMMAND_NAMES` | +| `packages/rt-client/src/client.ts`, `index.ts`, `README.md`, `package.json` | `chatArchive`, `chatDmOpen`, `chatRooms` option; 0.7.0 | +| `commands/chat.ts` | `rt chat archive [--reopen]` | +| `lib/command-tree-def.ts`, `website/docs/reference/chat.mdx` | verb list, `--reopen` flag, regenerated reference | +| `skills/rt-chat/SKILL.md` | verb rows, Archiving paragraph | +| tests | `lib/state/__tests__/{db,chat-store,dm-store}.test.ts`, `lib/daemon/__tests__/chat-handlers.test.ts`, `packages/rt-client/test/client.test.ts`, `commands/__tests__/chat.test.ts` | + +**Phase 2, chat** + +| File | Change | +| --- | --- | +| `package.json` | rt-client `^0.7` | +| `src/server/chat.ts` | rooms with `includeArchived`; `POST /api/chat/archive`; `POST /api/chat/dm/open`; `/api/chat/dm` removed | +| `src/server/fixtures.ts` | an archived channel, an archived DM, a 60-line code post | +| `src/app/App.tsx` | `openDm`, `archiveRoom`, archived-room rendering, phone ⋯ and archived drawer section | +| `src/ui/buddies-context.tsx` | unchanged shape; `dm` now navigates | +| `src/ui/Composer.tsx` | DM mode deleted; `onOpenDm` prop | +| `src/ui/PageBar.tsx` | ⋯ menu, archived chip, `onArchive` | +| `src/ui/RoomRail.tsx` | archived section | +| `src/ui/ArchivedBar.tsx` (new) | the composer's replacement on an archived room | +| `src/ui/Transcript.tsx` | day dividers, time `title`, `NewPill`, code copy, collapse | +| `src/ui/NewPill.tsx` (new) | the counted follow pill | +| `src/ui/transcript-scroll.module.css` | `.follow` rules removed | +| `design/build.py`, `design/ANATOMY.md`, `design/audit.mjs`, `design/spec.json` | artboard elements, anatomy, `TARGETS` | +| `ARCHITECTURE.md` | API table, message-body section | +| tests | `src/server/chat.test.ts`, `src/ui/{Composer,PageBar,RoomRail,Transcript,ArchivedBar}.test.tsx`, `src/app/App.test.tsx` | + +--- + +# Phase 1: repo-tools + +Every command in this phase runs from `/Users/matt/Documents/GitHub/repo-tools-chat-qol`. Run `bun install` once after creating the branch (the `postinstall` builds rt-client's `dist/`, which `packages/rt-client/test/dist-freshness.test.ts` compares against). + +### Task 1: Schema: `chat_rooms.archived_at` + +**Files:** +- Modify: `lib/state/db.ts:24-25` (`SCHEMA_VERSION`), the `V3_SCHEMA` `chat_rooms` block (around line 173), `addSectionsColumnIfMissing` (line 262), `runMigrations` (line 403) +- Test: `lib/state/__tests__/db.test.ts` + +**Interfaces:** +- Produces: column `chat_rooms.archived_at INTEGER` (NULL = open) on fresh and migrated databases; `SCHEMA_VERSION === 7`. + +- [ ] **Step 1: Create the implementation branch in the worktree** + +```bash +cd /Users/matt/Documents/GitHub/repo-tools-chat-qol +git switch -c feat/chat-archive-dm-open +bun install +``` + +- [ ] **Step 2: Write the failing tests** + +In `lib/state/__tests__/db.test.ts`, change the two existing version assertions and add one test. Line 79 `expect(SCHEMA_VERSION).toBe(6);` becomes `expect(SCHEMA_VERSION).toBe(7);` and its test title becomes `"a fresh database reaches v7 directly, gaining every v1, v2, v3, v4, v6 and v7 change (v5 is reserved by another lane)"`. Line 98 `toMatchObject({ user_version: 6 })` becomes `{ user_version: 7 }`. Then add, inside `describe("openStateDb — replay over an older user_version", ...)`: + +```ts + test("v7 adds chat_rooms.archived_at to a v6 database without touching its rows", () => { + const dbPath = join(dir, "state.db"); + const db = openStateDb(dbPath, "cli"); + db.exec("INSERT INTO chat_rooms (name, created_at) VALUES ('build', 1);"); + // A real v6 file has no such column; SQLite >= 3.35 can drop one, which is + // what makes this fixture honest rather than a fresh v7 relabelled. + db.exec("ALTER TABLE chat_rooms DROP COLUMN archived_at;"); + db.exec("PRAGMA user_version = 6;"); + db.close(); + + const migrated = openStateDb(dbPath, "cli"); + expect(userVersion(migrated)).toBe(7); + const columns = (migrated.query("PRAGMA table_info(chat_rooms);").all() as { name: string }[]).map(c => c.name); + expect(columns).toContain("archived_at"); + expect(migrated.query("SELECT name, archived_at FROM chat_rooms;").all()).toEqual([{ name: "build", archived_at: null }]); + migrated.close(); + + expect(() => openStateDb(dbPath, "cli").close()).not.toThrow(); + }); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `bun test lib/state/__tests__/db.test.ts` +Expected: FAIL: `expect(SCHEMA_VERSION).toBe(7)` receives 6; the new test fails on `DROP COLUMN archived_at` (no such column). + +- [ ] **Step 4: Implement the schema change** + +In `lib/state/db.ts`: + +```ts +/** PRAGMA user_version target for the combined schema below (v1 + v2 + v3 + v4 + v6 + v7; v5 is reserved by another lane). */ +export const SCHEMA_VERSION = 7; +``` + +In `V3_SCHEMA`, the `chat_rooms` table becomes: + +```sql +CREATE TABLE IF NOT EXISTS chat_rooms ( + name TEXT PRIMARY KEY, + purpose TEXT, + created_at INTEGER NOT NULL, + archived_at INTEGER -- NULL while open; every membership walk skips a stamped room; a post clears it +); +``` + +Directly under `addSectionsColumnIfMissing`: + +```ts +/** chat_rooms.archived_at (v7): the same conditional-exec rule as `sections` + above, because the DDL string replays on every bump. */ +function addArchivedAtColumnIfMissing(db: Database): void { + const columns = db.query("PRAGMA table_info(chat_rooms);").all() as { name: string }[]; + if (columns.some((c) => c.name === "archived_at")) return; + db.exec("ALTER TABLE chat_rooms ADD COLUMN archived_at INTEGER;"); +} +``` + +In `runMigrations`, directly after `addSectionsColumnIfMissing(db);`: + +```ts + addArchivedAtColumnIfMissing(db); +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `bun test lib/state/__tests__/db.test.ts` +Expected: PASS, all tests in the file. + +- [ ] **Step 6: Commit** + +```bash +git add lib/state/db.ts lib/state/__tests__/db.test.ts +git commit -m "state: chat_rooms.archived_at, schema v7" +``` + +### Task 2: Store: archive, filter, revive + +**Files:** +- Modify: `lib/state/chat-store.ts` (types at 36-42, SQL constants at 110-140, `listRooms` 234-249, `membershipsFor` 350-360, `postMessage` 409-424, `unreadWakingCount` 485-489) +- Modify: `lib/state/index.ts:97-120` +- Test: `lib/state/__tests__/chat-store.test.ts`, `lib/state/__tests__/dm-store.test.ts` + +**Interfaces:** +- Produces: + - `RoomSummary.archivedAt?: number` (set only on archived rows returned with `includeArchived`). + - `archiveRoom(room: string, archived: boolean, db?: Database): { room: string; archivedAt: number | null }`; throws `chat: no such room ""` when the row is absent. + - `roomArchivedAt(room: string, db?: Database): number | null | undefined` (undefined: no such room). + - `listRooms(handle: string, db?: Database, opts?: { includeArchived?: boolean }): RoomSummary[]`. +- Consumes: Task 1's column. + +- [ ] **Step 1: Write the failing store tests** + +Append to `lib/state/__tests__/chat-store.test.ts` (add `archiveRoom`, `roomArchivedAt` to the import list from `../chat-store.ts`): + +```ts +test("archive hides a room from every membership walk and keeps the member rows", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + joinRoom({ room: "build", handle: "b" }, db); + joinRoom({ room: "other", handle: "b" }, db); + postMessage({ room: "build", handle: "a", body: "@b look" }, db); + + const stamped = archiveRoom("build", true, db); + expect(stamped.room).toBe("build"); + expect(typeof stamped.archivedAt).toBe("number"); + expect(roomArchivedAt("build", db)).toBe(stamped.archivedAt); + + expect(listRooms("b", db).map(r => r.room)).toEqual(["other"]); + expect(listRooms("b", db, { includeArchived: true }).map(r => [r.room, r.archivedAt !== undefined])).toEqual([["build", true], ["other", false]]); + expect(unreadWakingCount("b", db)).toEqual([]); + expect(readUnread({ handle: "b", limit: 20 }, db)).toEqual([]); + expect(listMembers("build", db).map(m => m.handle)).toEqual(["a", "b"]); +}); + +test("a room named explicitly still answers while archived", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + joinRoom({ room: "build", handle: "b" }, db); + postMessage({ room: "build", handle: "a", body: "hi" }, db); + archiveRoom("build", true, db); + const read = readUnread({ handle: "b", room: "build", limit: 20 }, db); + expect(read).toHaveLength(1); + expect(read[0]!.messages.map(m => m.body)).toEqual(["hi"]); + expect(listMessages({ room: "build", limit: 20 }, db)).toHaveLength(1); +}); + +test("a post into an archived room revives it and wakes the members who were there", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + joinRoom({ room: "build", handle: "b", wakeOn: "all" }, db); + archiveRoom("build", true, db); + expect(listRooms("a", db)).toEqual([]); + + const posted = postMessage({ room: "build", handle: "a", body: "back to it" }, db)!; + expect(posted.recipients).toEqual(["b"]); + expect(roomArchivedAt("build", db)).toBeNull(); + expect(listRooms("a", db).map(r => r.room)).toEqual(["build"]); + expect(listRooms("b", db).map(r => [r.room, r.unread])).toEqual([["build", 1]]); +}); + +test("archive refuses a room that does not exist, reopen clears the stamp, and both are idempotent", () => { + const db = freshDb(); + expect(() => archiveRoom("nope", true, db)).toThrow(/no such room/); + expect(roomArchivedAt("nope", db)).toBeUndefined(); + joinRoom({ room: "build", handle: "a" }, db); + const first = archiveRoom("build", true, db).archivedAt; + expect(archiveRoom("build", true, db).archivedAt).toBe(first); + expect(archiveRoom("build", false, db)).toEqual({ room: "build", archivedAt: null }); + expect(archiveRoom("build", false, db)).toEqual({ room: "build", archivedAt: null }); + expect(listRooms("a", db).map(r => r.room)).toEqual(["build"]); +}); + +test("join by name does not revive an archived room", () => { + const db = freshDb(); + joinRoom({ room: "build", handle: "a" }, db); + archiveRoom("build", true, db); + joinRoom({ room: "build", handle: "c" }, db); + expect(roomArchivedAt("build", db)).not.toBeNull(); + expect(listRooms("c", db)).toEqual([]); +}); +``` + +Append to `lib/state/__tests__/dm-store.test.ts` (add `archiveRoom, listRooms` to the chat-store import): + +```ts +test("an archived DM revives on the next dm post with both participants and the silent human intact", () => { + const db = fresh(); + const { room } = dmRoomFor("a", "b", "matt", db); + archiveRoom(room, true, db); + expect(listRooms("a", db)).toEqual([]); + expect(listRooms("matt", db)).toEqual([]); + const posted = postMessage({ room, handle: "a", body: "still there?", mentions: ["b"] }, db)!; + expect(posted.recipients).toEqual(["b"]); + expect(listRooms("a", db).map(r => r.room)).toEqual([room]); + expect(listMembers(room, db).map(m => m.handle).sort()).toEqual(["a", "b", "matt"]); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun test lib/state/__tests__/chat-store.test.ts lib/state/__tests__/dm-store.test.ts` +Expected: FAIL with `archiveRoom is not a function` (export missing). + +- [ ] **Step 3: Implement the store changes** + +In `lib/state/chat-store.ts`: + +1. `RoomSummary` gains one field: + +```ts +export interface RoomSummary { + room: string; + memberCount: number; + unread: number; + mentions: number; + lastPostedAt?: number; + /** Set only when the caller asked for archived rooms; absent on an open room. */ + archivedAt?: number; +} +``` + +2. Beside `MemberRow`, the joined row shape: + +```ts +interface MembershipRow extends MemberRow { + archived_at: number | null; +} +``` + +3. New SQL constants, next to `SELECT_HANDLE_MEMBERSHIPS_SQL`: + +```ts +const SELECT_HANDLE_MEMBERSHIPS_WITH_ROOM_SQL = `SELECT ${MEMBER_COLUMNS}, archived_at FROM chat_members JOIN chat_rooms ON chat_rooms.name = chat_members.room WHERE handle = ? ORDER BY room;`; +const SELECT_ROOM_ARCHIVED_SQL = `SELECT archived_at FROM chat_rooms WHERE name = ?;`; +const UPDATE_ROOM_ARCHIVED_SQL = `UPDATE chat_rooms SET archived_at = ? WHERE name = ?;`; +const REVIVE_ROOM_SQL = `UPDATE chat_rooms SET archived_at = NULL WHERE name = ? AND archived_at IS NOT NULL;`; +``` + +4. A helper directly above `listRooms`: + +```ts +/** A handle's memberships in rooms that are not archived: the rows every + room-less walk (rooms, read, the tail's catch-up, the pulse line) is + allowed to see. An explicit room bypasses this on purpose. */ +function openMembershipsFor(handle: string, db: Database): MembershipRow[] { + const rows = db.query(SELECT_HANDLE_MEMBERSHIPS_WITH_ROOM_SQL).all(handle) as MembershipRow[]; + return rows.filter((r) => r.archived_at === null); +} +``` + +5. `listRooms` becomes: + +```ts +export function listRooms( + handle: string, + db: Database = getStateDb(), + opts: { includeArchived?: boolean } = {}, +): RoomSummary[] { + const all = db.query(SELECT_HANDLE_MEMBERSHIPS_WITH_ROOM_SQL).all(handle) as MembershipRow[]; + const rows = opts.includeArchived ? all : all.filter((r) => r.archived_at === null); + return rows.map((row) => { + const memberCount = (db.query(SELECT_ROOM_MEMBER_COUNT_SQL).get(row.room) as { n: number }).n; + const unread = (db.query(SELECT_ROOM_UNREAD_SQL).get(row.room, row.last_read_id) as { n: number }).n; + const mentions = ( + db.query(SELECT_ROOM_UNREAD_MENTIONS_SQL).get(row.room, row.last_read_id, `%"${escapeLike(handle)}"%`) as { n: number } + ).n; + const lastPosted = (db.query(SELECT_ROOM_LAST_POSTED_SQL).get(row.room) as { lastPostedAt: number | null }).lastPostedAt; + + const summary: RoomSummary = { room: row.room, memberCount, unread, mentions }; + if (lastPosted !== null) summary.lastPostedAt = lastPosted; + if (row.archived_at !== null) summary.archivedAt = row.archived_at; + return summary; + }); +} + +export function roomArchivedAt(room: string, db: Database = getStateDb()): number | null | undefined { + const row = db.query(SELECT_ROOM_ARCHIVED_SQL).get(room) as { archived_at: number | null } | null; + return row ? row.archived_at : undefined; +} + +export function archiveRoom( + room: string, + archived: boolean, + db: Database = getStateDb(), +): { room: string; archivedAt: number | null } { + const run = db.transaction((): { room: string; archivedAt: number | null } => { + const current = roomArchivedAt(room, db); + if (current === undefined) throw new Error(`chat: no such room "${room}"`); + if (!archived) { + db.query(UPDATE_ROOM_ARCHIVED_SQL).run(null, room); + return { room, archivedAt: null }; + } + const archivedAt = current ?? Date.now(); + if (current === null) db.query(UPDATE_ROOM_ARCHIVED_SQL).run(archivedAt, room); + return { room, archivedAt }; + }); + return run(); +} +``` + +6. `membershipsFor`'s room-less branch uses the open rows: + +```ts +function membershipsFor(handle: string, room: string | undefined, db: Database): MemberRow[] { + if (room) { + const row = db.query(SELECT_ROOM_MEMBER_SQL).get(room, handle) as MemberRow | null; + return row ? [row] : []; + } + return openMembershipsFor(handle, db); +} +``` + +7. `postMessage`'s transaction revives first: + +```ts + const run = db.transaction((): { id: number; recipients: string[] } => { + const now = Date.now(); + db.query(REVIVE_ROOM_SQL).run(room); + const result = db.query(INSERT_MESSAGE_SQL).run(room, handle, body, JSON.stringify(mentions), null, now); + const recipients = recipientsFor(room, handle, mentions, db); + return { id: Number(result.lastInsertRowid), recipients }; + }); +``` + +8. `unreadWakingCount`'s first line becomes: + +```ts + const members = openMembershipsFor(handle, db); +``` + +`joinRoom`'s `priorRows` read and `markRead` keep `SELECT_HANDLE_MEMBERSHIPS_SQL` / `membershipsFor` as they are (`markRead` with no room now walks open rooms through `membershipsFor`, which is fine: marking an archived room read is not a behaviour anyone can observe). + +In `lib/state/index.ts`, add `archiveRoom,` and `roomArchivedAt,` to the `./chat-store.ts` export block (after `listRooms,`). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `bun test lib/state` +Expected: PASS, every store suite. + +- [ ] **Step 5: Commit** + +```bash +git add lib/state/chat-store.ts lib/state/index.ts lib/state/__tests__/chat-store.test.ts lib/state/__tests__/dm-store.test.ts +git commit -m "chat-store: archiveRoom, open-membership walks, a post revives" +``` + +### Task 3: Daemon handlers `chat:archive`, `chat:dm-open`, `chat:rooms includeArchived` + +**Files:** +- Modify: `packages/rt-client/src/commands.ts:113-124` (`RoomSummary`), `:251-279` (`Commands`), `:297-316` (`COMMAND_NAMES`) +- Modify: `lib/daemon/handlers/chat.ts:7-37` (imports), `:46-65` (`CHAT_COMMANDS`), the `chat:rooms` handler (181-189), the end of the handler map +- Test: `lib/daemon/__tests__/chat-handlers.test.ts` + +**Interfaces:** +- Produces: daemon verbs + - `chat:archive` payload `{ room: string; handle: string; archived: boolean }` → `{ room: string; archivedAt: number | null }` + - `chat:dm-open` payload `{ from: string; to: string; sessionId?: string }` → `{ room: string; created: boolean }` + - `chat:rooms` payload `{ handle: string; includeArchived?: boolean }`; rows carry `archivedAt?: number`. +- Consumes: Task 2's `archiveRoom`, `listRooms(handle, db, { includeArchived })`, `dmRoomFor`. + +- [ ] **Step 1: Write the failing handler tests** + +Append to `lib/daemon/__tests__/chat-handlers.test.ts`: + +```ts +test("chat:archive hides the room from chat:rooms until includeArchived asks, and reopen restores it", async () => { + const h = freshHandlers(); + await h["chat:join"]({ room: "build", handle: "a" }); + const res = await h["chat:archive"]({ room: "build", handle: "a", archived: true }); + expect(res.ok).toBe(true); + if (!res.ok) throw new Error("unreachable"); + expect(res.data.room).toBe("build"); + expect(typeof res.data.archivedAt).toBe("number"); + + const hidden = await h["chat:rooms"]({ handle: "a" }); + if (!hidden.ok) throw new Error("unreachable"); + expect(hidden.data.rooms).toEqual([]); + + const shown = await h["chat:rooms"]({ handle: "a", includeArchived: true }); + if (!shown.ok) throw new Error("unreachable"); + expect(shown.data.rooms).toHaveLength(1); + expect(shown.data.rooms[0]).toMatchObject({ room: "build", archivedAt: res.data.archivedAt }); + + const reopened = await h["chat:archive"]({ room: "build", handle: "a", archived: false }); + if (!reopened.ok) throw new Error("unreachable"); + expect(reopened.data).toEqual({ room: "build", archivedAt: null }); + const back = await h["chat:rooms"]({ handle: "a" }); + if (!back.ok) throw new Error("unreachable"); + expect(back.data.rooms.map((r) => r.room)).toEqual(["build"]); +}); + +test("chat:archive refuses an unknown room and an invalid name with a reason", async () => { + const h = freshHandlers(); + const missing = await h["chat:archive"]({ room: "nope", handle: "a", archived: true }); + expect(missing.ok).toBe(false); + if (missing.ok) throw new Error("unreachable"); + expect(missing.error).toContain("no such room"); + const bad = await h["chat:archive"]({ room: "Has@Sigil", handle: "a", archived: true }); + expect(bad.ok).toBe(false); + if (bad.ok) throw new Error("unreachable"); + expect(bad.error).toContain("room"); +}); + +test("chat:dm-open creates the pair's room without posting, then reuses it", async () => { + const emitted: string[] = []; + const h = freshHandlers((topic) => { emitted.push(topic); return 0; }); + const first = await h["chat:dm-open"]({ from: "matt", to: "a" }); + expect(first.ok).toBe(true); + if (!first.ok) throw new Error("unreachable"); + expect(first.data.created).toBe(true); + expect(first.data.room).toMatch(/^dm-/); + expect(emitted).toEqual([]); + + const again = await h["chat:dm-open"]({ from: "matt", to: "a" }); + if (!again.ok) throw new Error("unreachable"); + expect(again.data).toEqual({ room: first.data.room, created: false }); + + const messages = await h["chat:messages"]({ room: first.data.room }); + if (!messages.ok) throw new Error("unreachable"); + expect(messages.data.messages).toEqual([]); + const who = await h["chat:who"]({ room: first.data.room }); + if (!who.ok) throw new Error("unreachable"); + expect(who.data.members.map((m) => m.handle).sort()).toEqual(["a", "matt"]); +}); + +test("chat:dm-open refuses a self DM, an invalid handle, and an empty humanHandle setting", async () => { + const h = freshHandlers(); + const self = await h["chat:dm-open"]({ from: "matt", to: "matt" }); + expect(self.ok).toBe(false); + if (self.ok) throw new Error("unreachable"); + expect(self.error).toMatch(/your own/i); + + const bad = await h["chat:dm-open"]({ from: "matt", to: "a:b" }); + expect(bad.ok).toBe(false); + + setSetting("chat.humanHandle", "", "user"); + try { + const empty = await h["chat:dm-open"]({ from: "matt", to: "a" }); + expect(empty.ok).toBe(false); + if (empty.ok) throw new Error("unreachable"); + expect(empty.error).toContain("chat.humanHandle"); + } finally { + setSetting("chat.humanHandle", "matt", "user"); + } +}); + +test("chat:dm-open refuses a reclaimed sender the same way chat:dm does", async () => { + const h = freshHandlers(); + await h["chat:sign-in"]({ sessionId: "s1", baseHandle: "a" }); + await h["chat:sign-out"]({ sessionId: "s1" }); + await h["chat:sign-in"]({ sessionId: "s2", baseHandle: "a" }); + const res = await h["chat:dm-open"]({ from: "a", to: "b", sessionId: "s1" }); + expect(res.ok).toBe(false); +}); +``` + +If the existing `chat:dm refuses a reclaimed sender` test (line 325) sets the sessions up differently, mirror its exact setup in the last test above; the assertion is the same. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun test lib/daemon/__tests__/chat-handlers.test.ts` +Expected: FAIL, type errors on `h["chat:archive"]` / `h["chat:dm-open"]` (not a function). + +- [ ] **Step 3: Add the command types** + +In `packages/rt-client/src/commands.ts`: + +`RoomSummary` gains, after `defaultWake`: + +```ts + /** Set only when chat:rooms was asked for archived rooms; absent on an open room. */ + archivedAt?: number; +``` + +In `Commands`, replace the `chat:rooms` line and add two entries after `chat:dm`: + +```ts + "chat:rooms": { payload: { handle: string; includeArchived?: boolean }; data: { rooms: RoomSummary[] } }; +``` + +```ts + "chat:archive": { payload: { room: string; handle: string; archived: boolean }; data: { room: string; archivedAt: number | null } }; + "chat:dm-open": { payload: { from: string; to: string; sessionId?: string }; data: { room: string; created: boolean } }; +``` + +In `COMMAND_NAMES`, after `"chat:dm",`: + +```ts + "chat:archive", + "chat:dm-open", +``` + +- [ ] **Step 4: Add the handlers** + +In `lib/daemon/handlers/chat.ts`: + +Import `archiveRoom` from `../../state/index.ts` (add it to the existing import list, after `listRooms,`). + +`CHAT_COMMANDS` gains, after `"chat:dm",`: + +```ts + "chat:archive", + "chat:dm-open", +``` + +The `chat:rooms` handler's first line becomes: + +```ts + const rooms = listRooms(payload.handle, db, { includeArchived: payload.includeArchived === true }).map((room) => { +``` + +After the `chat:dm` handler, before the closing `};`: + +```ts + "chat:archive": async (payload: Commands["chat:archive"]["payload"]): Promise> => { + const { room, handle, archived } = payload; + if (!isValidChatName(handle)) return { ok: false, error: `invalid handle "${handle}"` }; + if (!isValidChatName(room)) return { ok: false, error: `invalid room "${room}"` }; + if (typeof archived !== "boolean") return { ok: false, error: "archived must be true or false" }; + try { + return { ok: true, data: archiveRoom(room, archived, db) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + + "chat:dm-open": async (payload: Commands["chat:dm-open"]["payload"]): Promise> => { + const { from, to, sessionId } = payload; + if (!isValidChatName(from)) return { ok: false, error: `invalid handle "${from}"` }; + if (!isValidChatName(to)) return { ok: false, error: `invalid handle "${to}"` }; + const err = assertionError(() => assertSessionOwnsHandle(from, sessionId, db)); + if (err) return { ok: false, error: err }; + const humanHandle = getSetting("chat.humanHandle").value; + if (!isValidChatName(humanHandle)) { + return { ok: false, error: `chat: chat.humanHandle setting is empty or invalid ("${humanHandle}")` }; + } + try { + return { ok: true, data: dmRoomFor(from, to, humanHandle, db) }; + } catch (dmErr) { + return { ok: false, error: dmErr instanceof Error ? dmErr.message : String(dmErr) }; + } + }, +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `bun test lib/daemon/__tests__/chat-handlers.test.ts` +Expected: PASS. Also run `bun test lib` to confirm nothing else in the daemon suites moved. + +- [ ] **Step 6: Commit** + +```bash +git add packages/rt-client/src/commands.ts lib/daemon/handlers/chat.ts lib/daemon/__tests__/chat-handlers.test.ts +git commit -m "daemon: chat:archive, chat:dm-open, chat:rooms includeArchived" +``` + +### Task 4: rt-client 0.7.0: `chatArchive`, `chatDmOpen`, `chatRooms` option + +**Files:** +- Modify: `packages/rt-client/src/client.ts:186-191` (`chatRooms`), after `chatDm` (~line 323) +- Modify: `packages/rt-client/src/index.ts:13-31` +- Modify: `packages/rt-client/README.md:100-106` +- Modify: `packages/rt-client/package.json:3` +- Test: `packages/rt-client/test/client.test.ts` + +**Interfaces:** +- Produces (exported from `@mattstack/rt-client`): + - `chatArchive(a: { room: string; handle: string; archived: boolean }, o?: RtClientOptions): Promise>` + - `chatDmOpen(a: { from: string; to: string; sessionId?: string }, o?: RtClientOptions): Promise>` + - `chatRooms(a: { handle: string; includeArchived?: boolean }, o?)`: sends `includeArchived` only when `true`. + +- [ ] **Step 1: Write the failing wrapper tests** + +Append to `packages/rt-client/test/client.test.ts` (extend the first import line with `chatArchive, chatDmOpen, chatRooms`): + +```ts +describe("chat archive and dm-open", () => { + test("chatArchive sends room, handle and archived verbatim", async () => { + const { sock, seen, stop } = fakeDaemon({ + "chat:archive": { ok: true, data: { room: "build", archivedAt: 5 } }, + }); + stops.push(stop); + const res = await chatArchive({ room: "build", handle: "matt", archived: true }, { sockPath: sock }); + expect(res).toEqual({ ok: true, data: { room: "build", archivedAt: 5 } }); + expect(seen).toEqual([{ cmd: "chat:archive", payload: { room: "build", handle: "matt", archived: true } }]); + }); + + test("chatDmOpen omits sessionId when not given and passes it when given", async () => { + const { sock, seen, stop } = fakeDaemon({ + "chat:dm-open": { ok: true, data: { room: "dm-abc", created: true } }, + }); + stops.push(stop); + await chatDmOpen({ from: "matt", to: "a" }, { sockPath: sock }); + await chatDmOpen({ from: "a", to: "b", sessionId: "s1" }, { sockPath: sock }); + expect(seen[0]!.payload).toEqual({ from: "matt", to: "a" }); + expect(seen[1]!.payload).toEqual({ from: "a", to: "b", sessionId: "s1" }); + }); + + test("chatRooms sends includeArchived only when true", async () => { + const { sock, seen, stop } = fakeDaemon({ "chat:rooms": { ok: true, data: { rooms: [] } } }); + stops.push(stop); + await chatRooms({ handle: "matt" }, { sockPath: sock }); + await chatRooms({ handle: "matt", includeArchived: false }, { sockPath: sock }); + await chatRooms({ handle: "matt", includeArchived: true }, { sockPath: sock }); + expect(seen.map((s) => s.payload)).toEqual([ + { handle: "matt" }, + { handle: "matt" }, + { handle: "matt", includeArchived: true }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun test packages/rt-client/test/client.test.ts` +Expected: FAIL, `chatArchive` is not exported. + +- [ ] **Step 3: Implement the wrappers** + +In `packages/rt-client/src/client.ts`, replace `chatRooms`: + +```ts +export function chatRooms( + a: { handle: string; includeArchived?: boolean }, + o: RtClientOptions = {}, +): Promise> { + const payload: Record = { handle: a.handle }; + if (a.includeArchived === true) payload.includeArchived = true; + return rtCommand<{ rooms: RoomSummary[] }>("chat:rooms", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 }); +} +``` + +After `chatDm`: + +```ts +export function chatArchive( + a: { room: string; handle: string; archived: boolean }, + o: RtClientOptions = {}, +): Promise> { + return rtCommand<{ room: string; archivedAt: number | null }>( + "chat:archive", + { room: a.room, handle: a.handle, archived: a.archived }, + { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 }, + ); +} + +export function chatDmOpen( + a: { from: string; to: string; sessionId?: string }, + o: RtClientOptions = {}, +): Promise> { + const payload: Record = { from: a.from, to: a.to }; + if (a.sessionId !== undefined) payload.sessionId = a.sessionId; + return rtCommand<{ room: string; created: boolean }>("chat:dm-open", payload, { sockPath: o.sockPath, timeoutMs: o.timeoutMs ?? 10_000 }); +} +``` + +In `packages/rt-client/src/index.ts`, add `chatArchive,` and `chatDmOpen,` after `chatDm,` in the export list. + +In `packages/rt-client/package.json`, `"version": "0.7.0"`. + +In `packages/rt-client/README.md`'s function table, change the membership row and add one: + +``` +| `chatJoin` / `chatLeave` / `chatArchive` | membership (`wakeOn: mention \| all \| none`); archive parks a room for everyone until a post revives it | +| `chatPost` / `chatDm` / `chatDmOpen` / `chatRead` / `chatMessages` / `chatMark` | messages: post, DM, open a DM room without posting, read-and-advance, page, advance the cursor | +``` + +(Replace the existing `chatPost / chatDm / ...` row with the second line above.) + +- [ ] **Step 4: Rebuild dist and run the package tests** + +Run: `bun run --cwd packages/rt-client build && bun test packages` +Expected: PASS, including `dist-freshness.test.ts` (it compares a fresh build with the `dist/` you just rebuilt). + +- [ ] **Step 5: Commit** + +```bash +git add packages/rt-client/src/client.ts packages/rt-client/src/index.ts packages/rt-client/README.md packages/rt-client/package.json packages/rt-client/test/client.test.ts +git commit -m "rt-client 0.7.0: chatArchive, chatDmOpen, chatRooms includeArchived" +``` + +### Task 5: CLI `rt chat archive`, command tree, skill doc + +**Files:** +- Modify: `commands/chat.ts:1-27` (header comment), `:48-74` (rt-client import list), the verb functions (add `runArchive` after `runLeave`, line 690), `:1424-1444` (`USAGE`, `VERBS`) +- Modify: `lib/command-tree-def.ts:654-679` +- Regenerate: `website/docs/reference/chat.mdx` +- Modify: `skills/rt-chat/SKILL.md:128-152` (verb table) and the room guidance +- Test: `commands/__tests__/chat.test.ts` + +**Interfaces:** +- Produces: `rt chat archive [--reopen] [--as ] [--json]`. +- Consumes: Task 4's `chatArchive`. + +- [ ] **Step 1: Write the failing CLI test** + +In `commands/__tests__/chat.test.ts`, after the `leave drops membership` test (line 367-372), add: + +```ts + test("archive hides the room from rooms until reopened; --json reports the stamp", async () => { + await runChat(["join", "r", "--as", "a"]); + const out = JSON.parse(await runChat(["archive", "r", "--json", "--as", "a"])); + expect(out.ok).toBe(true); + expect(out.room).toBe("r"); + expect(typeof out.archivedAt).toBe("number"); + expect(JSON.parse(await runChat(["rooms", "--json", "--as", "a"])).rooms).toEqual([]); + + const plain = await runChat(["archive", "r", "--reopen", "--as", "a"]); + expect(plain).toContain("reopened #r"); + expect(JSON.parse(await runChat(["rooms", "--json", "--as", "a"])).rooms.map((x: { room: string }) => x.room)).toEqual(["r"]); + }); + + test("archive refuses a room that does not exist with exit 1", async () => { + const { code, stderr } = await runChatRaw(["archive", "ghost", "--as", "a"]); + expect(code).toBe(1); + expect(stderr).toContain("no such room"); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bun test commands/__tests__/chat.test.ts -t archive` +Expected: FAIL, `unknown verb "archive"`. + +- [ ] **Step 3: Implement the verb** + +In `commands/chat.ts`: + +Header comment: after the `rt chat leave ` line add + +``` + * rt chat archive [--reopen] park a room for everyone; a post revives it +``` + +Import: add `chatArchive,` to the `../packages/rt-client/src/index.ts` import list (alphabetically, before `chatArm`). + +After `runLeave`: + +```ts +async function runArchive(args: string[]): Promise { + const room = positional(args); + if (!room) fail("usage: rt chat archive [--reopen]"); + requireValidName("room", room); + + const handle = resolveHandle(args); + requireValidName("handle", handle); + + const archived = !args.includes("--reopen"); + const res = await chatArchive({ room, handle, archived }); + const data = unwrap(res, "archive"); + + if (args.includes("--json")) { + console.log(JSON.stringify({ ok: true, room: data.room, archivedAt: data.archivedAt })); + return; + } + console.log( + archived + ? `archived #${room}: hidden from every member's rooms until someone posts into it` + : `reopened #${room}`, + ); +} +``` + +`USAGE` becomes: + +```ts +const USAGE = + "usage: rt chat ..."; +``` + +`VERBS` gains `archive: runArchive,` after `leave: runLeave,`. + +Check `unwrap` (line 136): it must `fail()` with the daemon's error text so the second test sees `no such room` on stderr. If it prints `rt chat: archive: `, the assertion above already matches by substring. + +- [ ] **Step 4: Update the command tree and regenerate the reference** + +In `lib/command-tree-def.ts`, the `chat` leaf: + +- the comment `// rooms/who/mark/tail/sign-in/...` gains `archive` after `leave`; +- the Verb placeholder becomes `"join | leave | archive | post | read | rooms | who | mark | tail | sign-in | sign-out | away | back | buddies | dm | pulse"`; +- the Room hint's first clause becomes `"Room name for join/leave/archive/post/read/who/mark; ..."`; +- after the `Wake on` flag add: + +```ts + { name: "Reopen", flag: "--reopen", type: "boolean", default: false, hint: "For archive: clear the archive instead of setting it" }, +``` + +- the JSON hint's parenthetical gains `archive` after `leave`. + +Run: `bun run docs:gen && bun run docs:check` +Expected: `chat.mdx` regenerated with the new verb and flag; `docs:check` exits 0. + +- [ ] **Step 5: Update the skill** + +In `skills/rt-chat/SKILL.md`'s verb table, after the `rt chat leave ` row: + +``` +| `rt chat archive ` | park a finished room: it leaves every member's `rooms`, wakes nobody, and any post into it reopens it for everyone. `--reopen` clears the archive without posting. Matt's call, not yours (see Archiving below) | +``` + +After the paragraph that starts `@mentions are how you wake a specific agent` (end of "The rest of the verb surface"), add: + +``` +## Archiving + +Archiving is Matt's call. Archive a room only when he asks you to, and never +one you did not create. A room missing from `rt chat rooms` that you know +exists has probably been archived: posting into it reopens it for every +member and wakes them, so ask before you post there. `rt chat read ` +and `rt chat who ` still answer for an archived room by name. +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `bun test commands/__tests__/chat.test.ts && bun run docs:check` +Expected: PASS; docs check clean. + +- [ ] **Step 7: Commit** + +```bash +git add commands/chat.ts lib/command-tree-def.ts website/docs/reference/chat.mdx skills/rt-chat/SKILL.md commands/__tests__/chat.test.ts +git commit -m "rt chat archive: park a room for everyone, --reopen to clear" +``` + +### Task 6: Full rt verification, PR, publish rt-client + +**Files:** none new. + +- [ ] **Step 1: Run everything** + +```bash +bun run --cwd packages/rt-client build +bun test lib commands packages scripts +bun run docs:check +bunx tsc --noEmit -p packages/rt-client/tsconfig.json +``` + +Expected: every suite green, docs clean, types clean. If the repo has a root type check script, run that too. + +- [ ] **Step 2: Rebase onto origin/main and re-check the two shared values** + +```bash +git fetch origin +git rebase origin/main +grep -n "SCHEMA_VERSION =" lib/state/db.ts +grep -n '"version"' packages/rt-client/package.json +``` + +If main already reached schema 7 or rt-client 0.7.0 (the invite lane), take the next number in each and re-run Task 1's tests and Task 4's `dist-freshness` test. + +- [ ] **Step 3: Push and open the PR** + +```bash +git push -u origin feat/chat-archive-dm-open +gh pr create --title "rt chat: archive a room, open a DM without posting (rt-client 0.7.0)" --body "$(cat <<'EOF' +## rt chat: archive a room, open a DM without posting + +Spec: docs/superpowers/specs/2026-08-26-rt-chat-qol-design.md (in this PR). + +### What changed + +**Store** (`lib/state/`) + +- Adds `chat_rooms.archived_at` (schema v7) with a conditional `ALTER` beside the version check +- Adds `archiveRoom` and `roomArchivedAt`; every room-less membership walk skips archived rooms; a post revives the room in its insert transaction + +**Daemon and client** + +- Adds `chat:archive` and `chat:dm-open`; `chat:rooms` takes `includeArchived` +- rt-client 0.7.0: `chatArchive`, `chatDmOpen`, `chatRooms({ includeArchived })` + +**CLI and docs** + +- Adds `rt chat archive [--reopen]`, the command-tree entry, and the regenerated reference +- Skill: archiving is Matt's call; posting into an archived room reopens it + +--- + +**Checklist** + +- [x] Appropriate tests have been created or updated + - store, dm-store, handler, rt-client and CLI suites; `bun test lib commands packages scripts` green + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +https://claude.ai/code/session_014DK8caKoMFXhKQHh8Uufsg +EOF +)" +``` + +- [ ] **Step 4: Publish rt-client after the merge, with Matt's go-ahead** + +Publishing is outward-facing. Ask Matt before running it, then: + +```bash +cd /Users/matt/Documents/GitHub/repo-tools-chat-qol/packages/rt-client +npm publish +npm view @mattstack/rt-client version +``` + +Expected: `0.7.0` on npm (`prepack` runs the build). Phase 2 cannot start its Task 7 install until this is on npm. + +--- + +# Phase 2: chat viewer + +Every command in this phase runs from `/Users/matt/Documents/GitHub/chat/.claude/worktrees/chat-qol` on branch `worktree-chat-qol`. Tests are vitest (`bunx vitest run `); the full gates are `bun run typecheck`, `bun run lint`, `bunx vitest run`, `bun run build`. Phase 2 starts only once `@mattstack/rt-client@0.7.0` is on npm (Task 6, step 4). + +### Task 7: Server: rooms with archived rows, `/api/chat/archive`, `/api/chat/dm/open`; fixtures + +**Files:** +- Modify: `package.json:28` +- Modify: `src/server/chat.ts` (imports 1-13, the rooms route 140-148, the end of the router 258-300) +- Modify: `src/server/fixtures.ts` (`fixtureRooms` 116-138, `fixtureMembers` 140-168, `fixtureMessages` 170-244) +- Modify: `ARCHITECTURE.md` (the API table) +- Test: `src/server/chat.test.ts`, `src/server/fixtures.test.ts` + +**Interfaces:** +- Produces: + - `GET /api/chat/rooms` → `{ rooms: (RoomSummary & { joined?: false })[] }`, the human's rows including archived ones (`archivedAt` set). + - `POST /api/chat/archive` `{ room: string; archived: boolean }` → `{ room, archivedAt }`; 400 `room is required` / `archived must be true or false` / `unknown room ""`; 502 on `!ok`. + - `POST /api/chat/dm/open` `{ to: string }` → `{ room, created }`; 400 `to is required` / `invalid handle` / `can't DM yourself`; 502 on `!ok`. + - `POST /api/chat/dm` gone (JSON 404 through `static-disk.ts`'s `/api/*` rule). + - Fixtures: `fixtureRooms(now?)` with `#retro-0819` (archived 3 days) and an archived DM `dm-7b2e9c4d1a0f` (`board-fix-auth ↔ matt`, archived 5 days); `fixtureMessages('build')` gains message 48, a 60-line fenced log; `fixtureMessages('retro-0819')` returns four messages across two days. +- Consumes: rt-client 0.7.0's `chatArchive`, `chatDmOpen`, `chatRooms({ includeArchived })`. + +- [ ] **Step 1: Bump rt-client** + +In `package.json`, `"@mattstack/rt-client": "^0.7"`, then `bun install`. Run `bunx vitest run src/server` to confirm the baseline is still green before changing anything. + +- [ ] **Step 2: Write the failing server tests** + +In `src/server/chat.test.ts`, change the `vi.mock` factory: remove `chatDm: vi.fn(),`, add `chatArchive: vi.fn(),` and `chatDmOpen: vi.fn(),`. Delete the test `"dm opens or reuses the pair's room and posts as the human"` (line 239). Add: + +```ts +test('rooms asks for the human’s archived rooms too and passes archivedAt through', async () => { + vi.mocked(rt.chatRooms).mockResolvedValueOnce({ + ok: true, + data: { + rooms: [ + { room: 'build', memberCount: 3, unread: 0, mentions: 0 }, + { room: 'retro', memberCount: 2, unread: 0, mentions: 0, archivedAt: 1700000000000 }, + ], + }, + }); + vi.mocked(rt.chatBuddies).mockResolvedValueOnce({ ok: true, data: { buddies: [] } }); + const res = await app.request('/api/chat/rooms?handle=matt'); + expect(res.status).toBe(200); + expect(rt.chatRooms).toHaveBeenCalledWith( + { handle: 'matt', includeArchived: true }, + expect.anything() + ); + const { rooms } = await res.json(); + expect(rooms[1]).toMatchObject({ room: 'retro', archivedAt: 1700000000000 }); +}); + +test('archiving a channel the human never joined joins him first, then archives', async () => { + // The human's own listing (no #build), then the fleet union's per-buddy listings. + vi.mocked(rt.chatRooms) + .mockResolvedValueOnce({ ok: true, data: { rooms: [] } }) + .mockResolvedValueOnce({ ok: true, data: { rooms: [{ room: 'build', memberCount: 2, unread: 0, mentions: 0 }] } }); + vi.mocked(rt.chatBuddies).mockResolvedValueOnce({ + ok: true, + data: { buddies: [{ handle: 'fred', sessionId: 's', baseHandle: 'fred', signedInAt: 1, lastSeenAt: 1, status: 'live' }] }, + }); + vi.mocked(rt.chatWho).mockResolvedValueOnce({ ok: true, data: { members: [] } }); + vi.mocked(rt.chatJoin).mockResolvedValueOnce({ ok: true, data: { handle: 'matt', memberCount: 3, unread: 0 } }); + vi.mocked(rt.chatArchive).mockResolvedValueOnce({ ok: true, data: { room: 'build', archivedAt: 5 } }); + + const res = await app.request('/api/chat/archive?handle=matt', { + method: 'POST', + body: JSON.stringify({ room: 'build', archived: true }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ room: 'build', archivedAt: 5 }); + expect(rt.chatJoin).toHaveBeenCalledWith({ room: 'build', handle: 'matt' }, expect.anything()); + expect(rt.chatArchive).toHaveBeenCalledWith({ room: 'build', handle: 'matt', archived: true }, expect.anything()); +}); + +test('archiving a room already in the human’s listing never joins; a DM never joins either', async () => { + vi.mocked(rt.chatRooms).mockResolvedValueOnce({ + ok: true, + data: { + rooms: [ + { room: 'build', memberCount: 3, unread: 0, mentions: 0 }, + { room: 'dm-1', memberCount: 2, unread: 0, mentions: 0, kind: 'dm', participants: { a: 'fred', b: 'matt' } }, + ], + }, + }); + vi.mocked(rt.chatArchive).mockResolvedValue({ ok: true, data: { room: 'build', archivedAt: 5 } }); + await app.request('/api/chat/archive?handle=matt', { method: 'POST', body: JSON.stringify({ room: 'build', archived: true }) }); + vi.mocked(rt.chatRooms).mockResolvedValueOnce({ + ok: true, + data: { rooms: [{ room: 'dm-1', memberCount: 2, unread: 0, mentions: 0, kind: 'dm', participants: { a: 'fred', b: 'matt' } }] }, + }); + await app.request('/api/chat/archive?handle=matt', { method: 'POST', body: JSON.stringify({ room: 'dm-1', archived: true }) }); + expect(rt.chatJoin).not.toHaveBeenCalled(); +}); + +test('archive 400s on a bad body and on a room nobody lists, and never join-creates', async () => { + const bad = await app.request('/api/chat/archive?handle=matt', { method: 'POST', body: JSON.stringify({ room: 'build' }) }); + expect(bad.status).toBe(400); + const noRoom = await app.request('/api/chat/archive?handle=matt', { method: 'POST', body: JSON.stringify({ archived: true }) }); + expect(noRoom.status).toBe(400); + + vi.mocked(rt.chatRooms).mockResolvedValue({ ok: true, data: { rooms: [] } }); + vi.mocked(rt.chatBuddies).mockResolvedValue({ ok: true, data: { buddies: [] } }); + const ghost = await app.request('/api/chat/archive?handle=matt', { method: 'POST', body: JSON.stringify({ room: 'ghost', archived: true }) }); + expect(ghost.status).toBe(400); + expect((await ghost.json()).error).toContain('unknown room'); + expect(rt.chatJoin).not.toHaveBeenCalled(); + expect(rt.chatArchive).not.toHaveBeenCalled(); +}); + +test('reopen posts archived:false for a room in the human’s listing', async () => { + vi.mocked(rt.chatRooms).mockResolvedValueOnce({ + ok: true, + data: { rooms: [{ room: 'retro', memberCount: 2, unread: 0, mentions: 0, archivedAt: 7 }] }, + }); + vi.mocked(rt.chatArchive).mockResolvedValueOnce({ ok: true, data: { room: 'retro', archivedAt: null } }); + const res = await app.request('/api/chat/archive?handle=matt', { method: 'POST', body: JSON.stringify({ room: 'retro', archived: false }) }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ room: 'retro', archivedAt: null }); + expect(rt.chatArchive).toHaveBeenCalledWith({ room: 'retro', handle: 'matt', archived: false }, expect.anything()); +}); + +test('dm/open opens or reuses the pair’s room as the human without posting', async () => { + vi.mocked(rt.chatDmOpen).mockResolvedValueOnce({ ok: true, data: { room: 'dm-1a2b3c4d5e6f', created: true } }); + const res = await app.request('/api/chat/dm/open?handle=matt', { method: 'POST', body: JSON.stringify({ to: 'fred' }) }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ room: 'dm-1a2b3c4d5e6f', created: true }); + expect(rt.chatDmOpen).toHaveBeenCalledWith({ from: 'matt', to: 'fred' }, expect.anything()); + expect(rt.chatPost).not.toHaveBeenCalled(); +}); + +test('dm/open 400s on a missing, invalid, or own handle before touching the daemon', async () => { + for (const body of [{}, { to: 'Has@Sigil' }, { to: 'matt' }]) { + const res = await app.request('/api/chat/dm/open?handle=matt', { method: 'POST', body: JSON.stringify(body) }); + expect(res.status).toBe(400); + } + expect(rt.chatDmOpen).not.toHaveBeenCalled(); +}); + +test('POST /api/chat/dm is gone: a JSON 404, never the SPA shell', async () => { + const res = await app.request('/api/chat/dm', { method: 'POST', body: JSON.stringify({ to: 'fred', body: 'hi' }) }); + expect(res.status).toBe(404); + expect(res.headers.get('content-type')).toContain('application/json'); +}); +``` + +The existing tests `posting into a room the human has not joined joins first` (181) and `rooms includes rooms the FLEET is in` (255) may assert `chatRooms` was called with `{ handle: 'matt' }` exactly; update those assertions to `{ handle: 'matt', includeArchived: true }` where the call is the human's own listing (the post route's listing stays `{ handle: 'matt' }`, see step 4). + +In `src/server/fixtures.test.ts`, add: + +```ts +test('fixtures carry an archived channel, an archived DM, and a long code post', () => { + const rooms = fixtureRooms(); + const archived = rooms.filter(r => r.archivedAt !== undefined); + expect(archived.map(r => r.room)).toEqual(['retro-0819', 'dm-7b2e9c4d1a0f']); + expect(fixtureMessages('build').at(-1)?.body.split('\n').length).toBeGreaterThan(60); + const retro = fixtureMessages('retro-0819'); + expect(retro).toHaveLength(4); + expect(new Date(retro[0]!.postedAt).getDate()).not.toBe(new Date(retro[3]!.postedAt).getDate()); + expect(fixtureMembers('retro-0819').map(m => m.handle)).toEqual(['deck-main', 'gitq-main']); +}); +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `bunx vitest run src/server` +Expected: FAIL: `chatArchive`/`chatDmOpen` are not called, `/api/chat/dm` still answers 200, fixtures lack the rooms. + +- [ ] **Step 4: Implement the routes** + +In `src/server/chat.ts`: + +Imports: replace `chatDm,` with `chatArchive,` and add `chatDmOpen,` (keep alphabetical: `chatArchive, chatBuddies, chatDmOpen, chatJoin, ...`). + +Add near `parseIntParam`: + +```ts +const CHAT_NAME = /^[a-z0-9._-]+$/; +``` + +The rooms route becomes: + +```ts + .get('/api/chat/rooms', async c => { + if (fixturesEnabled()) return c.json({ rooms: fixtureRooms() }, 200); + const res = await chatRooms( + { handle: humanHandle(c), includeArchived: true }, + rtOpts() + ); + if (!res.ok) return c.json({ error: res.error }, 502); + + const joined = res.data?.rooms ?? []; + const extra = await unjoinedFleetRooms(joined); + return c.json({ rooms: [...joined, ...extra] }, 200); + }) +``` + +Replace the whole `.post('/api/chat/dm', ...)` route with these two: + +```ts + // Archive is the one write that needs the human IN the room first: an + // archived room only stays listed for members, and most channels are + // join-created by agents. Joining first (never for a DM, which already + // holds him) is the same move the post route makes. A name that neither + // his listing nor the fleet union knows is refused before that join, so + // a typo can never create-and-archive a room. + .post('/api/chat/archive', async c => { + let raw: { room?: unknown; archived?: unknown }; + try { + raw = await c.req.json(); + } catch { + return c.json({ error: 'Malformed JSON in request body' }, 400); + } + const room = typeof raw?.room === 'string' ? raw.room : undefined; + const archived = typeof raw?.archived === 'boolean' ? raw.archived : undefined; + if (!room) return c.json({ error: 'room is required' }, 400); + if (archived === undefined) { + return c.json({ error: 'archived must be true or false' }, 400); + } + const handle = humanHandle(c); + + const roomsRes = await chatRooms( + { handle, includeArchived: true }, + rtOpts() + ); + if (!roomsRes.ok || !roomsRes.data) { + return c.json({ error: roomsRes.error ?? 'rooms: no data' }, 502); + } + const mine = roomsRes.data.rooms.find(r => r.room === room); + if (!mine) { + const fleet = (await unjoinedFleetRooms(roomsRes.data.rooms)).find( + r => r.room === room + ); + if (!fleet) return c.json({ error: `unknown room "${room}"` }, 400); + if (fleet.kind !== 'dm') { + const joinRes = await chatJoin({ room, handle }, rtOpts()); + if (!joinRes.ok) return c.json({ error: joinRes.error }, 502); + } + } + + const res = await chatArchive({ room, handle, archived }, rtOpts()); + if (!res.ok) return c.json({ error: res.error }, 502); + return c.json(res.data, 200); + }) + // Opens or reuses the pair's room with no first message: the client + // navigates to it and the composer there is the DM. Parsed by hand for + // the same reason as `/api/chat/post`. + .post('/api/chat/dm/open', async c => { + let raw: { to?: unknown }; + try { + raw = await c.req.json(); + } catch { + return c.json({ error: 'Malformed JSON in request body' }, 400); + } + const to = typeof raw?.to === 'string' ? raw.to : undefined; + if (!to) return c.json({ error: 'to is required' }, 400); + if (!CHAT_NAME.test(to)) return c.json({ error: `invalid handle "${to}"` }, 400); + const from = humanHandle(c); + if (to === from) return c.json({ error: "can't DM yourself" }, 400); + const res = await chatDmOpen({ from, to }, rtOpts()); + if (!res.ok) return c.json({ error: res.error }, 502); + return c.json(res.data, 200); + }); +``` + +- [ ] **Step 5: Implement the fixtures** + +In `src/server/fixtures.ts`: + +`fixtureRooms` takes `now = Date.now()` and returns, after the two existing DM rows: + +```ts + { + room: 'retro-0819', + memberCount: 3, + unread: 0, + mentions: 0, + archivedAt: now - 3 * 24 * H, + }, + { + room: 'dm-7b2e9c4d1a0f', + memberCount: 2, + unread: 0, + mentions: 0, + kind: 'dm' as const, + participants: { a: 'board-fix-auth', b: 'matt' }, + archivedAt: now - 5 * 24 * H, + }, +``` + +`fixtureMembers`: before `const all = ...`, add a fixed membership for the archived channel, since no buddy carries an archived room as a tag: + +```ts + const ARCHIVED_MEMBERS: Record = { + 'retro-0819': ['deck-main', 'gitq-main'], + }; +``` + +and compute `inRoom` as: + +```ts + const inRoom = pair + ? [pair.a, pair.b].filter(h => h !== 'matt') + : (ARCHIVED_MEMBERS[room] ?? + all.filter(b => b.rooms.includes(room)).map(b => b.handle)); +``` + +`fixtureMessages`: replace the `if (room !== 'build')` block's head with: + +```ts + if (room === 'retro-0819') { + const at = (daysAgo: number, minutes: number) => + now - daysAgo * 24 * H + minutes * M; + return [ + { id: 301, room, handle: 'deck-main', body: 'retro for the 0819 incident: what went wrong, what we keep.', postedAt: at(3, 0), mentions: [] }, + { id: 302, room, handle: 'gitq-main', body: 'the stack rebase raced the deploy. we keep: never restack while deck is mid-restart.', postedAt: at(3, 14), mentions: [] }, + { id: 303, room, handle: 'deck-main', body: 'agreed. writing it into the deploy loop doc.', postedAt: at(2, 5), mentions: [] }, + { id: 304, room, handle: 'gitq-main', body: 'done on my side too. closing this out.', postedAt: at(2, 40), mentions: [] }, + ]; + } + if (room !== 'build') { +``` + +and append to the `build` list, after message 47: + +```ts + msg( + 48, + 'board-fix-auth', + 0.5, + 'full jest output for the auth suite, for the record:\n```\n' + + Array.from({ length: 60 }, (_, i) => + i % 7 === 6 + ? ` ✕ auth › refresh token rotates (${120 + i} ms)` + : ` ✓ auth › case ${i + 1} (${3 + (i % 5)} ms)` + ).join('\n') + + '\n```' + ), +``` + +`H` is already defined in the file (`const H = 60 * M`). + +- [ ] **Step 6: Update ARCHITECTURE.md** + +In the API table: change the `GET /api/chat/rooms` row's description to `{ rooms: RoomSummary[] }: the human's rooms including archived ones (archivedAt set), then every room a fleet buddy is in that the human is not (joined: false)`; replace the `POST /api/chat/dm` row with: + +``` +| `POST /api/chat/archive` `{ room, archived }` | joins the human first when he is not in the channel, then archives or reopens; 400 on a room nobody lists | +| `POST /api/chat/dm/open` `{ to }` | opens or reuses the DM room without posting; the client navigates to it | +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `bunx vitest run src/server && bun run typecheck` +Expected: PASS; types clean (a stale `chatDm` reference anywhere fails the typecheck, which is the point). + +- [ ] **Step 8: Commit** + +```bash +git add package.json bun.lock src/server/chat.ts src/server/fixtures.ts src/server/chat.test.ts src/server/fixtures.test.ts ARCHITECTURE.md +git commit -m "server: archive and dm/open routes, archived rooms listed, /api/chat/dm removed" +``` + +### Task 8: A DM is a room: `openDm` replaces the composer's DM mode + +**Files:** +- Modify: `src/ui/Composer.tsx` (props 60-100, state 279-289, `switchToDm`/`selectBuddy` 345-362, `send` 364-410, `useImperativeHandle` 445-451, placeholder 456-470, footer 626-645) +- Modify: `src/ui/buddies-context.tsx:6-12` (doc only) +- Modify: `src/app/App.tsx` (`PhoneDrawer` props 556-570 and the roster pick 706-715, `PhoneChat` 756-846, `App` 871-960, desktop composer/roster 1028-1052) +- Test: `src/ui/Composer.test.tsx`, `src/app/App.test.tsx` + +**Interfaces:** +- Produces: + - `ComposerHandle = { insertMention(handle: string): void; focus(): void }` (`startDm` removed). + - `ComposerProps.onOpenDm?: (handle: string) => void` (`onNavigate` removed). + - `App`-level `openDm(handle: string): Promise`: `POST /api/chat/dm/open`, refetch rooms, navigate to `/r/`, focus the composer; error notification `Couldn't open the DM`. + - `BuddyActions.dm` now navigates (same signature). +- Consumes: Task 7's `/api/chat/dm/open`. + +- [ ] **Step 1: Write the failing tests** + +In `src/ui/Composer.test.tsx`, replace the test `"choosing DM instead posts through /api/chat/dm and navigates to the pair's room"` with: + +```ts +test('choosing DM instead hands the handle to onOpenDm, drops the @ token and keeps the draft', async () => { + const onOpenDm = vi.fn(); + renderWithProviders( + + ); + await userEvent.type( + screen.getByRole('textbox'), + 'can you take the flaky one? @' + ); + await userEvent.click(await screen.findByText('board-fix-auth')); + expect(onOpenDm).toHaveBeenCalledWith('board-fix-auth'); + expect(screen.getByRole('textbox')).toHaveValue('can you take the flaky one? '); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.queryByText(/direct message to/)).toBeNull(); +}); +``` + +In `src/app/App.test.tsx`, add (imports: `userEvent` from `@testing-library/user-event`, `fetchMock`/`installFetchMock` are already imported): + +```ts +function jsonResponse(body: unknown): Response { + return { ok: true, status: 200, json: async () => body } as Response; +} + +test('DM on a sender’s card opens the pair’s room and focuses the composer there', async () => { + installFetchMock(); + const now = Date.now(); + const dmRoom = { + room: 'dm-1a2b3c4d5e6f', + memberCount: 2, + unread: 0, + mentions: 0, + kind: 'dm' as const, + participants: { a: 'fred', b: 'matt' }, + }; + const build = { room: 'build', memberCount: 2, unread: 0, mentions: 0 }; + fetchMock.mockImplementation((url: string) => { + if (url === '/api/chat/dm/open') return Promise.resolve(jsonResponse({ room: dmRoom.room, created: true })); + if (url === '/api/chat/rooms') return Promise.resolve(jsonResponse({ rooms: [build, dmRoom] })); + return Promise.resolve(jsonResponse({})); + }); + window.history.replaceState(null, '', '/r/build'); + renderWithProviders( + + ); + const transcript = await screen.findByTestId('transcript'); + await userEvent.hover(within(transcript).getByText('fred')); + await userEvent.click(await screen.findByTestId('card-dm-fred')); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/chat/dm/open', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ to: 'fred' }) }) + ); + await screen.findByTestId(`room-row-${dmRoom.room}`); + expect(window.location.pathname).toBe(`/r/${dmRoom.room}`); + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveFocus(); + expect(screen.queryByText(/direct message to/)).toBeNull(); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bunx vitest run src/ui/Composer.test.tsx src/app/App.test.tsx` +Expected: FAIL: the composer still posts to `/api/chat/dm`; the App test finds no `room-row-dm-…` and the pathname stays `/r/build`. + +- [ ] **Step 3: Rewrite the composer's DM path** + +In `src/ui/Composer.tsx`: + +Props: replace the `onNavigate` prop (doc and field) with: + +```ts + /** A buddy outside the room was picked in the `@` popover: the caller + opens the DM room and moves there; the draft stays in this instance. */ + onOpenDm?: (handle: string) => void; +``` + +`ComposerHandle`: + +```ts +export interface ComposerHandle { + insertMention(handle: string): void; + focus(): void; +} +``` + +and its doc comment becomes: `The imperative surface the roster and the app drive: insert a mention at the caret, or take focus after a room change.` + +In the component: remove `onNavigate` from the destructured props and add `onOpenDm`; delete `const [dmTarget, setDmTarget] = useState(undefined);`, the `useEffect(() => { setDmTarget(undefined); }, [room]);` block and its comment. + +Replace `switchToDm`: + +```ts + function switchToDm(handle: string) { + replaceToken(''); + closePopover(); + onOpenDm?.(handle); + } +``` + +In `send()`, delete the whole `if (dmTarget) { ... return; }` block (the `/api/chat/dm` fetch), leaving the post path. + +`useImperativeHandle`: + +```ts + useImperativeHandle(ref, () => ({ + insertMention: insertMentionAtCaret, + focus: () => focusAt(value.length), + })); +``` + +Placeholder: delete the `: dmTarget ? ... : ...` branch so it reads: + +```ts + const placeholder = !daemonReachable + ? phone + ? 'rt daemon unreachable' + : "Can't post — rt daemon unreachable. Your draft is kept." + : isDm + ? phone + ? `Message ${roomMembers.join(' ↔ ')}` + : `Message ${roomMembers.join(' ↔ ')} — both will wake` + : phone + ? `Message #${room}` + : `Message #${room} — @ to mention`; +``` + +Footer: delete the `: dmTarget ? (<>...cancel...)` branch so the ternary is `!daemonReachable ? (...) : (<> posting as ... )`. + +If `PURPLE` is now only used by `BuddyOption`, leave it; if `notifications` is still used by the post path's catch, leave it. Run `bun run lint` to catch anything unused. + +- [ ] **Step 4: Wire `openDm` in the app** + +In `src/app/App.tsx`: + +Import `notifications` from `@ui/notifications`. + +`PhoneDrawer`: replace the `composerRef` prop with `onMention: (handle: string) => void` and `onOpenDm: (handle: string) => void`; the roster pick becomes: + +```tsx + onPick={(handle, { inRoom }) => { + if (inRoom) onMention(handle); + else onOpenDm(handle); + onClose(); + }} +``` + +`PhoneChat`: replace `onNavigate: (room: string) => void` with `onOpenDm: (handle: string) => void`; the composer gets `onOpenDm={onOpenDm}` instead of `onNavigate={...}`; the drawer gets `onMention={handle => composerRef.current?.insertMention(handle)}` and `onOpenDm={handle => { onOpenDm(handle); setDrawerOpen(false); }}`. + +In `App`, replace `handleComposerNavigate` and `buddyActions` with: + +```ts + const openDm = useCallback( + async (handle: string) => { + try { + const res = await fetch('/api/chat/dm/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to: handle }), + }); + if (!res.ok) throw new Error('dm open failed'); + const data = (await res.json()) as { room: string }; + refetchRooms(); + setActiveRoom(data.room); + const to = `/r/${encodeURIComponent(data.room)}`; + if (window.location.pathname !== to) navigate(to); + composerRef.current?.focus(); + } catch { + notifications.error("Couldn't open the DM"); + } + }, + [refetchRooms] + ); + + const buddyActions = useMemo( + () => ({ + mention: (handle: string) => composerRef.current?.insertMention(handle), + dm: (handle: string) => void openDm(handle), + }), + [openDm] + ); +``` + +Keep `selectRoom` as it is for the rail. Pass `onOpenDm={openDm}` to `PhoneChat` (removing `onNavigate`), `onOpenDm={openDm}` to the desktop `Composer` (removing `onNavigate`), and change both roster picks from `composerRef.current?.startDm(handle)` to `void openDm(handle)`. + +Update the `BuddyActions.dm` doc in `src/ui/buddies-context.tsx` to `/** Open the DM room with \`handle\` and move to it. */`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `bunx vitest run src/ui/Composer.test.tsx src/app/App.test.tsx src/ui/Roster.test.tsx && bun run typecheck && bun run lint` +Expected: PASS, clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/ui/Composer.tsx src/ui/buddies-context.tsx src/app/App.tsx src/ui/Composer.test.tsx src/app/App.test.tsx +git commit -m "dm: a DM is a room, the composer's DM mode is gone" +``` + +### Task 9: Page bar ⋯ menu: archive with confirm, reopen, the archived chip + +**Files:** +- Modify: `src/ui/PageBar.tsx` (imports 1-6, props 63-73, `controls` 165-215, chips 240-317) +- Modify: `src/app/App.tsx` (`App`: an `setArchived` handler; the desktop `PageBar` props 973-983) +- Test: `src/ui/PageBar.test.tsx` + +**Interfaces:** +- Produces: + - `PageBarProps.onArchive?: (room: string, archived: boolean) => void`. + - exported `RoomMenu({ room, memberHandles, onArchive, size }: { room: RoomSummary; memberHandles: string[]; onArchive?: ...; size?: number })` from `PageBar.tsx`, the ⋯ trigger plus its menu, reused by the phone header in Task 11. + - exported `memberList(handles: string[]): string`: `"fred"`, `"fred and gitq"`, `"a, b, c and d"`, `"a, b, c and 3 more"` (five or more). + - `data-testid`s: `room-menu`, `room-menu-archive`, `room-menu-reopen`, `chip-archived`. + - App-level `setArchived(room, archived)`: `POST /api/chat/archive`, refetch rooms; error notification `Couldn't archive the room` / `Couldn't reopen the room`. +- Consumes: `RoomSummary.archivedAt`; the kit's `modals.confirm` (`@ui/modals`). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/ui/PageBar.test.tsx` (imports: `userEvent` from `@testing-library/user-event`, `vi`; `renderWithProviders` is what the file already uses so the `ModalsProvider` is present): + +```ts +test('the ⋯ menu offers Archive with a confirm that names the members, and confirms through onArchive', async () => { + const onArchive = vi.fn(); + renderWithProviders( + + ); + await userEvent.click(screen.getByTestId('room-menu')); + await userEvent.click(await screen.findByTestId('room-menu-archive')); + expect(await screen.findByText('Archive #build?')).toBeInTheDocument(); + expect(screen.getByText(/for you and for fred and gitq-main/)).toBeInTheDocument(); + expect(onArchive).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button', { name: 'Archive' })); + expect(onArchive).toHaveBeenCalledWith('build', true); +}); + +test('an archived room shows the archived chip, hides mark read, and its menu offers Reopen with no confirm', async () => { + const onArchive = vi.fn(); + renderWithProviders( + + ); + expect(screen.getByTestId('chip-archived')).toHaveTextContent('archived'); + expect(screen.queryByTestId('chip-wakes')).toBeNull(); + expect(screen.queryByTestId('mark-read-button')).toBeNull(); + await userEvent.click(screen.getByTestId('room-menu')); + await userEvent.click(await screen.findByTestId('room-menu-reopen')); + expect(onArchive).toHaveBeenCalledWith('retro', false); + expect(screen.queryByText(/Archive #retro\?/)).toBeNull(); +}); + +test('memberList reads like a sentence and caps at three names', () => { + expect(memberList([])).toBe(''); + expect(memberList(['fred'])).toBe('fred'); + expect(memberList(['fred', 'gitq'])).toBe('fred and gitq'); + expect(memberList(['a', 'b', 'c', 'd'])).toBe('a, b, c and d'); + expect(memberList(['a', 'b', 'c', 'd', 'e'])).toBe('a, b, c and 2 more'); +}); +``` + +Add `memberList` to the `./PageBar` import. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bunx vitest run src/ui/PageBar.test.tsx` +Expected: FAIL: no `room-menu`, `memberList` not exported. + +- [ ] **Step 3: Implement the menu and chip** + +In `src/ui/PageBar.tsx`: + +Imports: `import { ActionIcon, Box, Button, Group, Menu, Select, Text } from '@mantine/core';` and `import { modals } from '@ui/modals';`. + +Props: add to `PageBarProps`: + +```ts + /** Archive (true) or reopen (false) the room; the bar confirms an archive + itself, naming who loses the room from their rail. */ + onArchive?: (room: string, archived: boolean) => void; +``` + +Above `PageBar`: + +```ts +export function memberList(handles: string[]): string { + if (handles.length === 0) return ''; + if (handles.length === 1) return handles[0]!; + if (handles.length <= 4) { + return `${handles.slice(0, -1).join(', ')} and ${handles[handles.length - 1]}`; + } + return `${handles.slice(0, 3).join(', ')} and ${handles.length - 3} more`; +} + +function archiveLabel(room: RoomSummary): string { + return room.kind === 'dm' ? 'Archive this conversation…' : `Archive #${room.room}…`; +} + +function archiveTitle(room: RoomSummary): string { + return room.kind === 'dm' ? 'Archive this conversation?' : `Archive #${room.room}?`; +} + +/** The ⋯ control and its menu. One component for the desk's page bar and + the phone header, so both offer the same two actions. */ +export function RoomMenu({ + room, + memberHandles, + onArchive, + size = 30, +}: { + room: RoomSummary; + memberHandles: string[]; + onArchive?: (room: string, archived: boolean) => void; + size?: number; +}) { + const archived = room.archivedAt !== undefined; + const others = memberList(memberHandles); + const confirmArchive = () => + modals.confirm({ + title: archiveTitle(room), + message: `It leaves the rail for you${others ? ` and for ${others}` : ''}. Everyone keeps their place in it, and any new post reopens it.`, + labels: { confirm: 'Archive', cancel: 'Keep' }, + onConfirm: () => onArchive?.(room.room, true), + }); + return ( + + + + + + + + {archived ? ( + onArchive?.(room.room, false)} + > + Reopen + + ) : ( + + {archiveLabel(room)} + + )} + + + ); +} +``` + +In `PageBar`, destructure `onArchive`, and in `controls`: + +- the mark-read button's condition becomes `room.unread > 0 && room.archivedAt === undefined`; +- after the `Select` (still inside the fragment), add: + +```tsx + + b.handle)} + onArchive={onArchive} + /> + +``` + +In the reachable render, replace the `chip-wakes` chip with: + +```tsx + {room.archivedAt !== undefined ? ( + + archived + + ) : ( + + wakes: {wakeMode} + + )} +``` + +- [ ] **Step 4: Wire the app** + +In `src/app/App.tsx`'s `App`, after `openDm`: + +```ts + const setArchived = useCallback( + async (room: string, archived: boolean) => { + try { + const res = await fetch('/api/chat/archive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room, archived }), + }); + if (!res.ok) throw new Error('archive failed'); + refetchRooms(); + } catch { + notifications.error( + archived ? "Couldn't archive the room" : "Couldn't reopen the room" + ); + } + }, + [refetchRooms] + ); +``` + +and pass `onArchive={setArchived}` to the desktop `PageBar`. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `bunx vitest run src/ui/PageBar.test.tsx src/app/App.test.tsx && bun run typecheck && bun run lint` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ui/PageBar.tsx src/ui/PageBar.test.tsx src/app/App.tsx +git commit -m "page bar: room menu with archive (confirmed) and reopen, archived chip" +``` + +### Task 10: Rail: the collapsed archived section (desk and phone drawer) + +**Files:** +- Modify: `src/ui/RoomRail.tsx` (imports 1-5, `RoomRow` 120-190, `RoomRail` 198-300) +- Modify: `src/app/App.tsx` (`PhoneDrawer` 556-735) +- Test: `src/ui/RoomRail.test.tsx` + +**Interfaces:** +- Produces: rail rows for archived rooms (`data-testid="room-row-"`, `data-archived="true"`, 0.6 opacity, no badges) inside a section toggled by `data-testid="archived-toggle"` (`aria-expanded`), collapsed by default and remembered under localStorage key `chat.rail.archived` (`true` = collapsed). The same section in the phone drawer with `phone-room-` rows. +- Consumes: `RoomSummary.archivedAt`; the kit's `useLocalStorage` (`@ui/hooks`) and `AnimatedChevron` (`@ui/icons`). + +- [ ] **Step 1: Write the failing tests** + +Append to `src/ui/RoomRail.test.tsx`. The file renders with `renderWithProviders` from `@ui/storybook/test-utils`; add `import userEvent from '@testing-library/user-event';`, add `within` to the `@testing-library/react` import and `afterEach` to the vitest import: + +```ts +afterEach(() => window.localStorage.removeItem('chat.rail.archived')); + +test('archived rooms sit in a collapsed section, badge-less and dimmed, and the toggle remembers itself', async () => { + const { unmount } = renderWithProviders( + + ); + expect(screen.getByText('ROOMS').nextSibling).toHaveTextContent('1'); + expect(screen.queryByTestId('room-row-retro')).toBeNull(); + const toggle = screen.getByTestId('archived-toggle'); + expect(toggle).toHaveTextContent('ARCHIVED'); + expect(toggle).toHaveTextContent('2'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + await userEvent.click(toggle); + const row = screen.getByTestId('room-row-retro'); + expect(row).toHaveAttribute('data-archived', 'true'); + expect(row.style.opacity).toBe('0.6'); + expect(within(row).queryByTestId('unread-badge')).toBeNull(); + expect(within(row).queryByTestId('mention-badge')).toBeNull(); + expect(screen.getByTestId('room-row-dm-1')).toHaveTextContent('fred'); + + unmount(); + renderWithProviders(); + expect(screen.getByTestId('archived-toggle')).toHaveAttribute('aria-expanded', 'true'); +}); + +test('no archived rooms means no archived section', () => { + renderWithProviders(); + expect(screen.queryByTestId('archived-toggle')).toBeNull(); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bunx vitest run src/ui/RoomRail.test.tsx` +Expected: FAIL: the archived room renders in the channel list; no toggle. + +- [ ] **Step 3: Implement the section** + +In `src/ui/RoomRail.tsx`: + +Imports: add `import { useLocalStorage } from '@ui/hooks';` and `import { AnimatedChevron, Icon } from '@ui/icons';` (replacing the bare `Icon` import). + +`RoomRow` gains `archived?: boolean`; the button gets `data-archived={archived ? 'true' : undefined}` and `opacity: archived ? 0.6 : undefined` in its style; the two badge lines become `{!archived && room.mentions > 0 && }` and `{!archived && room.unread > 0 && }`. + +In `RoomRail`: + +```ts + const openRooms = rooms.filter(r => r.archivedAt === undefined); + const channelRooms = openRooms.filter(r => r.kind !== 'dm'); + const directRooms = openRooms.filter(r => r.kind === 'dm'); + const archivedRooms = rooms.filter(r => r.archivedAt !== undefined); + const [archivedCollapsed, setArchivedCollapsed] = useLocalStorage({ + key: 'chat.rail.archived', + defaultValue: true, + }); +``` + +After the direct section (inside the outer `Stack`, after the closing `)}` of `directRooms.length > 0 && (...)`): + +```tsx + {archivedRooms.length > 0 && ( + <> + setArchivedCollapsed(!archivedCollapsed)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 6, + width: '100%', + padding: '10px var(--mantine-spacing-md) 4px', + borderBottom: `1px solid var(--tk-border-soft)`, + }} + > + + ARCHIVED + + + {archivedRooms.length} + + + + + {!archivedCollapsed && + archivedRooms.map(room => ( + onSelectRoom?.(room.room)} + /> + ))} + + )} +``` + +(`AnimatedChevron` is `IconProps & { opened: boolean }` from `@ui/icons`; `color` and `size` are `IconProps`.) + +In `src/app/App.tsx`'s `PhoneDrawer`: compute the same three lists plus the `useLocalStorage` pair (same key), pass `archived` into a `PhoneRoomRow` that gains the same `archived?: boolean` prop (dim, no badges), and add the section after the direct section using the drawer's `DIRECT` header styling with the label `ARCHIVED`, the count and the chevron, `data-testid="phone-archived-toggle"`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `bunx vitest run src/ui/RoomRail.test.tsx src/app/App.test.tsx && bun run typecheck && bun run lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/RoomRail.tsx src/ui/RoomRail.test.tsx src/app/App.tsx +git commit -m "rail: collapsed archived section, remembered per browser" +``` + +### Task 11: The archived room page: `ArchivedBar` in the composer's place; the phone ⋯ + +**Files:** +- Create: `src/ui/day-label.ts`, `src/ui/day-label.test.ts` +- Create: `src/ui/ArchivedBar.tsx`, `src/ui/ArchivedBar.test.tsx` +- Modify: `src/app/App.tsx` (`PhoneHeader` 356-440, `PhoneChat` composer 818-832, the desktop `Transcript` footer 1020-1040) +- Test: `src/app/App.test.tsx` + +**Interfaces:** +- Produces: + - `dayLabel(ts: number, now?: number): string` → `Today` / `Yesterday` / `Mon 24 Aug` / `Mon 24 Aug 2025`; `dayKey(ts): string` (local calendar day). + - `ArchivedBar({ archivedAt, onReopen, phone? })`: `data-testid="archived-bar"`, text `Archived · everyone keeps their place`, a `Reopen` button `data-testid="archived-reopen"`. + - `PhoneHeader` gains `memberHandles` and `onArchive`, rendering `RoomMenu` at 44px after the counts. +- Consumes: Task 9's `RoomMenu` and `setArchived`. + +- [ ] **Step 1: Write the failing tests** + +`src/ui/day-label.test.ts`: + +```ts +import { expect, test } from 'vitest'; + +import { dayKey, dayLabel } from './day-label'; + +const now = new Date(2026, 7, 26, 20, 0).getTime(); +const days = (n: number) => now - n * 86_400_000; + +test('dayLabel names today, yesterday, then the weekday and date, with the year only when it differs', () => { + expect(dayLabel(now, now)).toBe('Today'); + expect(dayLabel(days(1), now)).toBe('Yesterday'); + expect(dayLabel(days(2), now)).toBe('Mon 24 Aug'); + expect(dayLabel(new Date(2025, 11, 31, 9, 0).getTime(), now)).toBe('Wed 31 Dec 2025'); +}); + +test('dayKey follows the local calendar, not a 24h window', () => { + const lateTonight = new Date(2026, 7, 26, 23, 59).getTime(); + const earlyTomorrow = new Date(2026, 7, 27, 0, 1).getTime(); + expect(dayKey(lateTonight)).not.toBe(dayKey(earlyTomorrow)); + expect(dayKey(lateTonight)).toBe(dayKey(now)); +}); +``` + +`src/ui/ArchivedBar.test.tsx`: + +```tsx +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '@ui/storybook/test-utils'; +import { ArchivedBar } from './ArchivedBar'; + +test('the archived bar says when, reassures, and reopens on its one button', async () => { + const onReopen = vi.fn(); + renderWithProviders( + + ); + expect(screen.getByTestId('archived-bar')).toHaveTextContent( + 'Archived Yesterday · everyone keeps their place' + ); + await userEvent.click(screen.getByTestId('archived-reopen')); + expect(onReopen).toHaveBeenCalledTimes(1); +}); +``` + +In `src/app/App.test.tsx`: + +```ts +test('an archived room renders the archived bar instead of the composer, and Reopen posts archived:false', async () => { + installFetchMock(); + window.history.replaceState(null, '', '/r/retro'); + renderWithProviders( + + ); + expect(await screen.findByTestId('archived-bar')).toBeInTheDocument(); + expect(screen.queryByTestId('composer')).toBeNull(); + expect(screen.queryByTestId('mark-read-button')).toBeNull(); + await userEvent.click(screen.getByTestId('archived-reopen')); + expect(fetchMock).toHaveBeenCalledWith( + '/api/chat/archive', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ room: 'retro', archived: false }) }) + ); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `bunx vitest run src/ui/day-label.test.ts src/ui/ArchivedBar.test.tsx src/app/App.test.tsx` +Expected: FAIL: modules missing; the App still renders the composer. + +- [ ] **Step 3: Implement** + +`src/ui/day-label.ts`: + +```ts +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +/** The local calendar day a timestamp falls on, as a comparable key. */ +export function dayKey(ts: number): string { + const d = new Date(ts); + return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; +} + +export function dayLabel(ts: number, now = Date.now()): string { + const key = dayKey(ts); + if (key === dayKey(now)) return 'Today'; + if (key === dayKey(now - 86_400_000)) return 'Yesterday'; + const d = new Date(ts); + const base = `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`; + return d.getFullYear() === new Date(now).getFullYear() + ? base + : `${base} ${d.getFullYear()}`; +} +``` + +`src/ui/ArchivedBar.tsx`: + +```tsx +import { Button, Group, Text } from '@mantine/core'; + +import { dayLabel } from './day-label'; + +export interface ArchivedBarProps { + archivedAt: number; + onReopen: () => void; + /** Phone chrome: the panel surface and 44px controls. @default false */ + phone?: boolean; +} + +/** What replaces the composer on an archived room: when, a reassurance + that nobody lost their place, and the one way back. */ +export function ArchivedBar({ archivedAt, onReopen, phone = false }: ArchivedBarProps) { + return ( + + + Archived {dayLabel(archivedAt)} · everyone keeps their place + + + + ); +} +``` + +In `src/app/App.tsx`: + +- import `ArchivedBar` from `@ui/ArchivedBar` and `RoomMenu` from `@ui/PageBar`; +- desktop: the `Transcript`'s `footer` becomes + +```tsx + footer={ + activeRoomSummary?.archivedAt !== undefined ? ( + void setArchived(activeRoom, false)} + /> + ) : ( + + ) + } +``` + +- `PhoneChat`: gains `onArchive: (room: string, archived: boolean) => void`; renders ` onArchive(activeRoom, false)} />` in place of the `Composer` when `activeRoomSummary?.archivedAt !== undefined`; passes `memberHandles={roomMembers}` and `onArchive` to `PhoneHeader`. +- `PhoneHeader`: gains `memberHandles: string[]` and `onArchive`; after the fleet-count button, when `room` is defined: + +```tsx + {room && ( + + )} +``` + +- `App` passes `onArchive={setArchived}` to `PhoneChat`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `bunx vitest run src/ui src/app && bun run typecheck && bun run lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/day-label.ts src/ui/day-label.test.ts src/ui/ArchivedBar.tsx src/ui/ArchivedBar.test.tsx src/app/App.tsx src/app/App.test.tsx +git commit -m "archived room: the bar replaces the composer, phone header gets the room menu" +``` + +### Task 12: Transcript day dividers and full timestamps + +**Files:** +- Modify: `src/ui/Transcript.tsx` (`MessageRow` 340-372, the list render 645-680, `loadOlder` 548-583) +- Test: `src/ui/Transcript.test.tsx` + +**Interfaces:** +- Produces: a `data-testid="day-divider"` row (`aria-label="