diff --git a/commands/__tests__/chat.test.ts b/commands/__tests__/chat.test.ts index a1449380..877dbf4b 100644 --- a/commands/__tests__/chat.test.ts +++ b/commands/__tests__/chat.test.ts @@ -382,6 +382,25 @@ describe("rt chat CLI — additional verb behavior", () => { const rooms = JSON.parse(await runChat(["rooms", "--json", "--as", "solo"])); expect(rooms.rooms).toEqual([]); }); + + 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"); + }); }); // ─── sign-in / sign-out (presence) ────────────────────────────────────────── diff --git a/commands/chat.ts b/commands/chat.ts index aa759bd8..54f65da5 100644 --- a/commands/chat.ts +++ b/commands/chat.ts @@ -3,6 +3,7 @@ * * rt chat join [--as ] [--wake-on mention|all|none] * rt chat leave + * rt chat archive [--reopen] park a room for everyone; a post revives it * rt chat post <<'EOF' ... EOF the body on stdin; for a one-liner * rt chat read [room] [--limit 20] [--full] [--since ] * rt chat read --last newest N regardless of cursor, then marks read @@ -53,6 +54,7 @@ import { chatViewerUrl, readChatViewerUrlSetting } from "../lib/chat-viewer-url. import { planSessionRename, type RenamePlan } from "../lib/chat-rename.ts"; import { parseDuration } from "./events.ts"; import { + chatArchive, chatArm, chatAway, chatBack, @@ -632,6 +634,29 @@ async function runLeave(args: string[]): Promise { console.log(`✓ left #${room} (${handle})`); } +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}`, + ); +} + /** * The body of a post or DM. Three sources, first match wins: `--file `, * stdin (no text while stdin is not a terminal, which is what a heredoc looks @@ -1405,11 +1430,12 @@ export async function chatTail(args: string[]): Promise { // ─── dispatcher ──────────────────────────────────────────────────────────────── const USAGE = - "usage: rt chat ..."; + "usage: rt chat ..."; const VERBS: Record Promise> = { join: runJoin, leave: runLeave, + archive: runArchive, post: runPost, read: runRead, rooms: runRooms, @@ -1435,6 +1461,7 @@ const VERB_HINTS: Record = { join: "join a room", leave: "leave a room", mark: "mark a room read", + archive: "park a room (post revives it)", tail: "stream wakes for this handle", "sign-in": "sign in and set presence", "sign-out": "sign out", 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..e83566d9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-rt-chat-qol.md @@ -0,0 +1,2999 @@ +# 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`, every assertion that pins the schema version moves to 7. There are four: line 79 `expect(SCHEMA_VERSION).toBe(6);` (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 })`, line 164 `expect(userVersion(db)).toBe(6)` inside the `describe` at 158-159 (retitle that describe and its test from `migrates to v6` to `migrates to v7`), and line 421 `expect(before).toBe(6)`. Search the file for `toBe(6)` and `version: 6` once more before moving on. 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 () => { + // Same setup as `chat:dm refuses a reclaimed sender` (line 325): the + // first session goes stale, a second session claims the handle, and the + // stale session's own id no longer owns it. + const h = freshHandlers(); + await h["chat:sign-in"]({ sessionId: "s1", baseHandle: "a" }); + h.db.run("UPDATE chat_presence SET last_seen_at = last_seen_at - 7200000"); + 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); +}); +``` + +- [ ] **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'); +}); +``` + +In `src/server/fixtures.test.ts`, the existing assertion at lines 66-68 that `fixtureRooms().filter(r => r.kind === 'dm')` has length 2 must exclude archived rows: change its filter to `r => r.kind === 'dm' && r.archivedAt === undefined`. Then 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}`); + // `focus()` defers through requestAnimationFrame. + await waitFor(() => + expect(screen.getByRole('textbox', { name: 'Message' })).toHaveFocus() + ); + expect(screen.queryByText(/direct message to/)).toBeNull(); +}); +``` + +(`waitFor` and `within` come from `@testing-library/react`.) + +- [ ] **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, + humanHandle = 'matt', + onArchive, + size = 30, +}: { + room: RoomSummary; + /** The room's current members; the human is filtered out of the confirm + text since it already says "for you". */ + memberHandles: string[]; + humanHandle?: string; + onArchive?: (room: string, archived: boolean) => void; + size?: number; +}) { + const archived = room.archivedAt !== undefined; + const others = memberList(memberHandles.filter(h => h !== humanHandle)); + 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)} + + )} + + + ); +} +``` + +`PageBarProps` also gains `memberHandles?: string[]` (doc: `The room's full membership for the archive confirm; defaults to the buddies' handles, which omit offline members`). In `PageBar`, destructure `onArchive` and `memberHandles`, 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}` and `memberHandles={roomMembers}` to the desktop `PageBar` (every member of the open room, signed in or not). + +- [ ] **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="