From 7ff9f94c41a9870e124a50133cd74b42295ab9ac Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 22 Jun 2026 00:08:21 +0900 Subject: feat: conversation lifecycle status (active/idle/closed) for tab persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement roadmap item 9: tab persistence across devices. Wire (0.10.0): - Add ConversationStatus type (active | idle | closed) - Add status field to ConversationMeta Transport-contract (0.14.0): - Add conversation.statusChanged WS message to WsServerMessage union - Re-export ConversationStatus Conversation-store: - Track status in ConversationMetaRow (default: idle) - getConversationStatus / setConversationStatus methods - listConversations accepts { status: ConversationStatus[] } filter - Old meta rows without status default to idle on read Session-orchestrator: - conversationStatusChanged hook descriptor - Emit on transitions: idle→active (turn start), active→idle (turn settle), →closed (closeConversation) - Persist status to store as fire-and-forget side effect - Declare hook in manifest contributes.hooks Transport-ws: - Subscribe to conversationStatusChanged hook - Broadcast conversation.statusChanged WS message to all clients Transport-http: - GET /conversations?status=active,idle filter (parseStatusFilter pure helper) - POST /conversations/:id/close now sets status to closed CLI: - dispatch list defaults to active,idle (excludes closed) - --status flag to filter by single status - --all flag to include closed FE handoff: frontend-conversation-lifecycle-handoff.md --- frontend-conversation-lifecycle-handoff.md | 102 +++++++++++++++++++++ packages/cli/src/args.test.ts | 29 ++++++ packages/cli/src/args.ts | 23 ++++- packages/cli/src/http.test.ts | 23 ++++- packages/cli/src/http.ts | 10 +- packages/cli/src/main.ts | 9 +- packages/cli/src/render.test.ts | 1 + packages/conversation-store/src/store.test.ts | 99 +++++++++++++++++++- packages/conversation-store/src/store.ts | 75 ++++++++++++++- packages/kernel/src/contracts/conversation.ts | 1 + packages/kernel/src/contracts/index.ts | 1 + packages/session-orchestrator/src/extension.ts | 1 + packages/session-orchestrator/src/index.ts | 2 + .../session-orchestrator/src/orchestrator.test.ts | 37 ++++++-- packages/session-orchestrator/src/orchestrator.ts | 28 ++++++ packages/session-orchestrator/src/queue.test.ts | 4 + packages/transport-contract/package.json | 2 +- packages/transport-contract/src/index.ts | 16 +++- packages/transport-http/src/app.test.ts | 26 +++++- packages/transport-http/src/app.ts | 10 +- packages/transport-http/src/logic.ts | 31 ++++++- packages/transport-http/src/server.bun.test.ts | 4 + packages/transport-ws/src/extension.ts | 14 ++- packages/wire/package.json | 2 +- packages/wire/src/index.ts | 13 ++- 25 files changed, 529 insertions(+), 34 deletions(-) create mode 100644 frontend-conversation-lifecycle-handoff.md diff --git a/frontend-conversation-lifecycle-handoff.md b/frontend-conversation-lifecycle-handoff.md new file mode 100644 index 0000000..ec877e1 --- /dev/null +++ b/frontend-conversation-lifecycle-handoff.md @@ -0,0 +1,102 @@ +# FE handoff — conversation lifecycle (tab persistence across devices) + +Courier this to `../dispatch-web`. All changes are ADDITIVE — nothing existing breaks. + +## What shipped (backend) + +Conversations now have a lifecycle **status** field: `active`, `idle`, or `closed`. +This enables tab persistence: when a new browser connects, it fetches all +`active` + `idle` conversations and restores the tab bar. + +- **`active`** — an agent is currently generating (a turn is in-flight). +- **`idle`** — conversation exists, not generating. User can send a message to resume. +- **`closed`** — user dismissed the tab (hidden from the tab bar, not deleted). + +Status transitions are driven by the backend: +- `idle → active` when a turn starts. +- `active → idle` when a turn settles (done/error). +- `→ closed` when `POST /conversations/:id/close` is called. + +## Bump pinned deps +- `@dispatch/wire` → `0.10.0` +- `@dispatch/transport-contract` → `0.14.0` + +## New types (`@dispatch/wire` + `@dispatch/transport-contract`) + +```ts +export type ConversationStatus = "active" | "idle" | "closed"; + +// ConversationMeta now has a status field: +export interface ConversationMeta { + readonly id: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; +} + +// New WS message (server → client): +export interface ConversationStatusChangedMessage { + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; +} +``` + +`ConversationStatusChangedMessage` is added to the `WsServerMessage` union. + +## `GET /conversations?status=active,idle` — filter by status + +The existing `GET /conversations` endpoint now accepts an optional `?status=` +query param: a comma-separated list of statuses to filter by. + +- **Default (no param):** returns ALL conversations (all statuses). +- `?status=active,idle` → only active + idle (what the FE tab bar wants). +- `?status=closed` → only closed conversations (for a history view). +- Invalid values are silently dropped. If all values are invalid, no filter + is applied (returns all). + +## `POST /conversations/:id/close` — marks as closed + +The existing close endpoint now also sets the conversation's status to `closed` +in the store. This persists across server restarts. The response is unchanged +(`{ conversationId, abortedTurn }`). + +## `conversation.statusChanged` WS message + +Broadcast to ALL connected WS clients whenever a conversation's status changes. +The backend emits this synchronously alongside the existing `turnStarted` / +`turnSettled` / `conversationClosed` hooks. + +```ts +{ type: "conversation.statusChanged", conversationId: "conv-1", status: "active" } +``` + +## What the FE needs to do + +1. **On connect:** call `GET /conversations?status=active,idle` to fetch + conversations for the tab bar. Render tabs for each. + +2. **`active` tabs:** subscribe to the conversation's live stream + (`chat.subscribe` WS op) to receive in-flight events. + +3. **`idle` tabs:** load history via `GET /conversations/:id`. No live + subscription needed until the user sends a message. + +4. **Tab close button:** call `POST /conversations/:id/close` to mark the + conversation as `closed`. Remove it from the tab bar. + +5. **Handle `conversation.statusChanged` WS messages:** update the tab's + status indicator. When a conversation goes `idle → active`, show a + loading/generating indicator. When it goes `active → idle`, remove the + indicator. When it goes `closed`, remove the tab. + +6. **Closed conversations:** accessible from a history view + (`GET /conversations?status=closed`). Can be reopened by sending a message + (which transitions `closed → active`). + +## CLI + +`dispatch list` now defaults to `active,idle` (excludes closed). New flags: +- `--status ` — filter by a single status. +- `--all` — include closed (show all statuses). diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index 62bcafd..992d09f 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -190,6 +190,7 @@ describe("parseArgs", () => { expect(parseArgs(["list"], { defaultServer })).toEqual({ kind: "list", server: "http://localhost:24203", + all: false, }); }); @@ -198,6 +199,7 @@ describe("parseArgs", () => { kind: "list", server: "http://localhost:24203", query: "abc12345", + all: false, }); }); @@ -206,9 +208,36 @@ describe("parseArgs", () => { kind: "list", server: "http://s", query: "abc", + all: false, }); }); + it("parses 'list' with --status", () => { + expect(parseArgs(["list", "--status", "closed"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + status: "closed", + all: false, + }); + }); + + it("parses 'list' with --all", () => { + expect(parseArgs(["list", "--all"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + all: true, + }); + }); + + it("parses 'list' with --status and --all ( --all takes precedence)", () => { + const result = parseArgs(["list", "--status", "active", "--all"], { defaultServer }); + expect(result.kind).toBe("list"); + if (result.kind === "list") { + expect(result.all).toBe(true); + expect(result.status).toBe("active"); + } + }); + it("errors on a second positional argument", () => { const result = parseArgs(["list", "abc", "def"], { defaultServer }); expect(result.kind).toBe("error"); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index d4ed0e9..30fa309 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -27,7 +27,13 @@ export type ParsedCommand = readonly showReasoning: boolean; readonly open: boolean; } - | { readonly kind: "list"; readonly server: string; readonly query?: string } + | { + readonly kind: "list"; + readonly server: string; + readonly query?: string; + readonly status?: string; + readonly all: boolean; + } | { readonly kind: "open"; readonly server: string; readonly conversationId: string } | { readonly kind: "read"; readonly server: string; readonly conversationId: string } | { @@ -73,11 +79,18 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma if (first === "list") { let server = opts.defaultServer; let query: string | undefined; + let status: string | undefined; + let all = false; for (let i = 1; i < argv.length; i++) { const arg = argv[i] as string; if (arg === "--server") { if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; server = argv[++i] as string; + } else if (arg === "--status") { + if (i + 1 >= argv.length) return { kind: "error", message: "--status requires a value" }; + status = argv[++i]; + } else if (arg === "--all") { + all = true; } else if (arg.startsWith("--")) { return { kind: "error", message: `Unknown flag: ${arg}` }; } else if (query !== undefined) { @@ -86,7 +99,13 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma query = arg; } } - return { kind: "list", server, ...(query !== undefined && { query }) }; + return { + kind: "list", + server, + ...(query !== undefined && { query }), + ...(status !== undefined && { status }), + all, + }; } if (first === "read") { diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts index 013492e..3e7befe 100644 --- a/packages/cli/src/http.test.ts +++ b/packages/cli/src/http.test.ts @@ -251,7 +251,9 @@ describe("fetchConversations", () => { it("requests GET /conversations with no query when query omitted", async () => { let calledUrl: string | undefined; const list: ConversationListResponse = { - conversations: [{ id: "abcdef1234567890", title: "first", createdAt: 1, lastActivityAt: 2 }], + conversations: [ + { id: "abcdef1234567890", title: "first", createdAt: 1, lastActivityAt: 2, status: "idle" }, + ], }; const fakeFetch = (async (url: string | URL | Request): Promise => { calledUrl = String(url); @@ -277,7 +279,7 @@ describe("fetchConversations", () => { { fetchImpl: fakeFetch }, { server: "http://localhost:24203", query: "abc def" }, ); - expect(calledUrl).toBe("http://localhost:24203/conversations?q=abc%20def"); + expect(calledUrl).toBe("http://localhost:24203/conversations?q=abc+def"); }); it("throws on non-OK status", async () => { @@ -400,7 +402,13 @@ describe("resolveConversationId", () => { it("returns the full id on a single match", async () => { const fetchImpl = listFetch({ conversations: [ - { id: "abcdef1234567890abcdef1234567890", title: "only", createdAt: 1, lastActivityAt: 2 }, + { + id: "abcdef1234567890abcdef1234567890", + title: "only", + createdAt: 1, + lastActivityAt: 2, + status: "idle", + }, ], }); const result = await resolveConversationId( @@ -426,12 +434,19 @@ describe("resolveConversationId", () => { it("returns an error object listing matches on multiple matches", async () => { const fetchImpl = listFetch({ conversations: [ - { id: "abcdef1234567890aaaaaaaaaaaaaaaa", title: "first", createdAt: 1, lastActivityAt: 2 }, + { + id: "abcdef1234567890aaaaaaaaaaaaaaaa", + title: "first", + createdAt: 1, + lastActivityAt: 2, + status: "idle", + }, { id: "abcdef1234567890bbbbbbbbbbbbbbbb", title: "second", createdAt: 1, lastActivityAt: 3, + status: "idle", }, ], }); diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts index 8434519..876f570 100644 --- a/packages/cli/src/http.ts +++ b/packages/cli/src/http.ts @@ -96,16 +96,18 @@ export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promi interface FetchConversationsOpts { readonly server: string; readonly query?: string; + readonly status?: string; } export async function fetchConversations( deps: FetchDeps, opts: FetchConversationsOpts, ): Promise { - const url = - opts.query !== undefined - ? `${opts.server}/conversations?q=${encodeURIComponent(opts.query)}` - : `${opts.server}/conversations`; + const params = new URLSearchParams(); + if (opts.query !== undefined) params.set("q", opts.query); + if (opts.status !== undefined) params.set("status", opts.status); + const qs = params.toString(); + const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${opts.server}/conversations`; const res = await deps.fetchImpl(url); if (!res.ok) { diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 6df6e23..e709f21 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -22,7 +22,7 @@ import { extractLastText, formatConversationList, renderEvent } from "./render.j const USAGE = `Usage: dispatch models [--server ] - dispatch list [] [--server ] + dispatch list [] [--status ] [--all] [--server ] dispatch read [--server ] dispatch open [--server ] dispatch send --text "..." [--queue] [--open] [--cwd ] [--effort ] [--server ] @@ -50,9 +50,14 @@ async function main(): Promise { break; } case "list": { + const status = parsed.all ? undefined : (parsed.status ?? "active,idle"); const result = await fetchConversations( { fetchImpl: globalThis.fetch }, - { server: parsed.server, ...(parsed.query !== undefined && { query: parsed.query }) }, + { + server: parsed.server, + ...(parsed.query !== undefined && { query: parsed.query }), + ...(status !== undefined && { status }), + }, ); const table = formatConversationList(result.conversations, Date.now()); if (table.length > 0) process.stdout.write(`${table}\n`); diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts index 849d33a..eb89300 100644 --- a/packages/cli/src/render.test.ts +++ b/packages/cli/src/render.test.ts @@ -229,6 +229,7 @@ describe("formatConversationList", () => { title, createdAt: now - ageMs - 1000, lastActivityAt: now - ageMs, + status: "idle", }); it("returns empty string for an empty list", () => { diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 1f2b7ba..fee7de5 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -7,6 +7,7 @@ import type { TurnMetrics, } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; +import { CONVERSATION_INDEX_KEY, metaKey } from "./keys.js"; import { createConversationStore, extractTitle } from "./store.js"; interface SpanEvent { @@ -1023,6 +1024,7 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 12345, lastActivityAt: 12345, title: "my title", + status: "idle", }); }); @@ -1039,6 +1041,7 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 7777, lastActivityAt: 7777, title: "hello", + status: "idle", }); }); @@ -1068,6 +1071,7 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 5000, lastActivityAt: 5000, title: "preset title", + status: "idle", }); // And the new conversation is discoverable in the index. const list = await store.listConversations(); @@ -1170,14 +1174,105 @@ describe("ConversationStore conversation metadata + list + title", () => { createdAt: 1000, lastActivityAt: 1000, title: "persisted", + status: "idle", }); const list = await store2.listConversations(); expect(list).toHaveLength(1); expect(list[0]?.id).toBe("conv1"); }); -}); -describe("extractTitle (pure)", () => { + describe("ConversationStore conversation status", () => { + it("new conversation defaults to idle", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + expect((await store.getConversationMeta("conv1"))?.status).toBe("idle"); + }); + + it("setConversationStatus updates status on existing conversation", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + await store.setConversationStatus("conv1", "active"); + expect(await store.getConversationStatus("conv1")).toBe("active"); + + await store.setConversationStatus("conv1", "idle"); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + + await store.setConversationStatus("conv1", "closed"); + expect(await store.getConversationStatus("conv1")).toBe("closed"); + }); + + it("setConversationStatus creates minimal row for unknown conversation", async () => { + const store = createConversationStore(storage, undefined, () => 2000); + await store.setConversationStatus("convNew", "closed"); + expect(await store.getConversationStatus("convNew")).toBe("closed"); + const meta = await store.getConversationMeta("convNew"); + expect(meta?.status).toBe("closed"); + expect(meta?.title).toBe("Untitled"); + }); + + it("setConversationStatus preserves other metadata", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + await store.setConversationTitle("conv1", "custom title"); + + await store.setConversationStatus("conv1", "active"); + + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.status).toBe("active"); + }); + + it("listConversations filters by status", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + + await store.setConversationStatus("conv1", "active"); + await store.setConversationStatus("conv2", "closed"); + + const activeOnly = await store.listConversations({ status: ["active"] }); + expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]); + + const idleOnly = await store.listConversations({ status: ["idle"] }); + expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]); + + const activeIdle = await store.listConversations({ status: ["active", "idle"] }); + expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]); + + const all = await store.listConversations(); + expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]); + }); + + it("status persists across a fresh store instance", async () => { + const store1 = createConversationStore(storage, undefined, () => 1000); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store1.setConversationStatus("conv1", "active"); + + const store2 = createConversationStore(storage); + expect(await store2.getConversationStatus("conv1")).toBe("active"); + }); + + it("old meta rows without status default to idle on read", async () => { + // Simulate a pre-status meta row written by an older version. + await storage.set( + metaKey("conv1"), + JSON.stringify({ + createdAt: 1000, + lastActivityAt: 1000, + title: "old", + }), + ); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"])); + + const store = createConversationStore(storage); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.status).toBe("idle"); + }); + }); it("extractTitle: returns first user text", () => { const messages: ChatMessage[] = [ { role: "system", chunks: [{ type: "text", text: "sys" }] }, diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index f3bec4b..8d78df8 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -2,6 +2,7 @@ import type { ChatMessage, Chunk, ConversationMeta, + ConversationStatus, Logger, ReasoningEffort, Role, @@ -71,11 +72,20 @@ export interface ConversationStore { * recent first). Metadata (createdAt, lastActivityAt, title) is tracked * automatically on append; title defaults to the first user message. */ - readonly listConversations: () => Promise; + readonly listConversations: (filter?: { + readonly status?: readonly ConversationStatus[]; + }) => Promise; /** Single conversation metadata, or null if unknown. */ readonly getConversationMeta: (conversationId: string) => Promise; /** Set/update the human-readable title for a conversation. */ readonly setConversationTitle: (conversationId: string, title: string) => Promise; + /** Get the lifecycle status of a conversation, or null if unknown. */ + readonly getConversationStatus: (conversationId: string) => Promise; + /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ + readonly setConversationStatus: ( + conversationId: string, + status: ConversationStatus, + ) => Promise; } export const conversationStoreHandle = defineService("conversation-store/store"); @@ -120,6 +130,7 @@ interface ConversationMetaRow { readonly createdAt: number; readonly lastActivityAt: number; readonly title: string; + readonly status: ConversationStatus; } /** Maximum title length (in characters) before truncation with an ellipsis. */ @@ -166,7 +177,10 @@ function parseMetaRow(raw: string): ConversationMetaRow | null { ) { return null; } - return parsed as ConversationMetaRow; + const row = parsed as ConversationMetaRow; + const status: ConversationStatus = + row.status === "active" || row.status === "closed" ? row.status : "idle"; + return { createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, title: row.title, status }; } function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { @@ -175,6 +189,7 @@ function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, title: row.title, + status: row.status, }; } @@ -241,6 +256,7 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title: extractTitle(messages), + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); @@ -252,6 +268,7 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title: extractTitle(messages), + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); @@ -264,6 +281,7 @@ export function createConversationStore( createdAt: existing.createdAt, lastActivityAt: ts, title, + status: existing.status, }; await storage.set(metaKey(conversationId), JSON.stringify(row)); } @@ -387,7 +405,7 @@ export function createConversationStore( logger.debug("reasoning-effort set", { conversationId }); } }, - async listConversations() { + async listConversations(filter) { const raw = await storage.get(CONVERSATION_INDEX_KEY); if (raw === null) return []; let parsed: unknown; @@ -407,12 +425,14 @@ export function createConversationStore( ids.push(v); } + const statusFilter = filter?.status; const metas: ConversationMeta[] = []; for (const id of ids) { const metaRaw = await storage.get(metaKey(id)); if (metaRaw === null) continue; const row = parseMetaRow(metaRaw); if (row === null) continue; + if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; metas.push(toMeta(id, row)); } // Sort by lastActivityAt descending (most recent first). Stable sort @@ -437,6 +457,7 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title, + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); @@ -449,16 +470,62 @@ export function createConversationStore( createdAt: ts, lastActivityAt: ts, title, + status: "idle", }; await storage.set(metaKey(conversationId), JSON.stringify(row)); await ensureInIndex(conversationId); return; } - // Preserve createdAt + lastActivityAt; update only the title. + // Preserve createdAt + lastActivityAt + status; update only the title. const row: ConversationMetaRow = { createdAt: existing.createdAt, lastActivityAt: existing.lastActivityAt, title, + status: existing.status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getConversationStatus(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return row.status; + }, + + async setConversationStatus(conversationId, status) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Status set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status, }; await storage.set(metaKey(conversationId), JSON.stringify(row)); }, diff --git a/packages/kernel/src/contracts/conversation.ts b/packages/kernel/src/contracts/conversation.ts index f1f8d77..9b7d2f6 100644 --- a/packages/kernel/src/contracts/conversation.ts +++ b/packages/kernel/src/contracts/conversation.ts @@ -9,6 +9,7 @@ export type { ChatMessage, Chunk, ConversationMeta, + ConversationStatus, ErrorChunk, Role, StepId, diff --git a/packages/kernel/src/contracts/index.ts b/packages/kernel/src/contracts/index.ts index d39853e..3aa9740 100644 --- a/packages/kernel/src/contracts/index.ts +++ b/packages/kernel/src/contracts/index.ts @@ -16,6 +16,7 @@ export type { ChatMessage, Chunk, ConversationMeta, + ConversationStatus, ErrorChunk, Role, StepId, diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index 6e56c2b..cbb3def 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -27,6 +27,7 @@ export const manifest: Manifest = { "session-orchestrator/turn-settled", "session-orchestrator/warm-completed", "session-orchestrator/conversation-closed", + "session-orchestrator/conversation-status-changed", ], }, }; diff --git a/packages/session-orchestrator/src/index.ts b/packages/session-orchestrator/src/index.ts index 3d8ad18..d2aacd9 100644 --- a/packages/session-orchestrator/src/index.ts +++ b/packages/session-orchestrator/src/index.ts @@ -2,9 +2,11 @@ export { extension, manifest } from "./extension.js"; export { type ConversationClosedPayload, type ConversationOpenedPayload, + type ConversationStatusChangedPayload, cacheWarmHandle, conversationClosed, conversationOpened, + conversationStatusChanged, createSessionOrchestrator, createWarmService, type EnqueueInput, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 9ee9704..53c1ce7 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -86,6 +86,10 @@ function createInMemoryStore(): ConversationStore & { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; } @@ -532,6 +536,10 @@ describe("turn-sealed event", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -594,6 +602,10 @@ describe("turn-sealed event", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -945,6 +957,10 @@ describe("turn metrics persistence", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const { orchestrator } = createSessionOrchestrator({ @@ -1112,18 +1128,23 @@ describe("lifecycle event hooks", () => { modelName: "mymodel", }); - expect(emitted).toHaveLength(2); + expect(emitted).toHaveLength(4); expect(emitted[0]?.hook).toBe("session-orchestrator/turn-started"); expect(emitted[0]?.payload.conversationId).toBe("conv-lifecycle"); expect(emitted[0]?.payload.cwd).toBe("/work"); expect(emitted[0]?.payload.modelName).toBe("mymodel"); expect(emitted[0]?.order).toBe(0); - expect(emitted[1]?.hook).toBe("session-orchestrator/turn-settled"); - expect(emitted[1]?.payload.conversationId).toBe("conv-lifecycle"); - expect(emitted[1]?.payload.cwd).toBe("/work"); - expect(emitted[1]?.payload.modelName).toBe("mymodel"); - expect(emitted[1]?.order).toBe(1); + expect(emitted[1]?.hook).toBe("session-orchestrator/conversation-status-changed"); + expect((emitted[1]?.payload as unknown as { status: string }).status).toBe("active"); + + expect(emitted[2]?.hook).toBe("session-orchestrator/turn-settled"); + expect(emitted[2]?.payload.conversationId).toBe("conv-lifecycle"); + expect(emitted[2]?.payload.cwd).toBe("/work"); + expect(emitted[2]?.payload.modelName).toBe("mymodel"); + + expect(emitted[3]?.hook).toBe("session-orchestrator/conversation-status-changed"); + expect((emitted[3]?.payload as unknown as { status: string }).status).toBe("idle"); }); }); @@ -2302,6 +2323,10 @@ describe("closeConversation (CR-4c)", () => { hook: "session-orchestrator/conversation-closed", payload: { conversationId: "conv-never-seen" }, }, + { + hook: "session-orchestrator/conversation-status-changed", + payload: { conversationId: "conv-never-seen", status: "closed" }, + }, ]); // Closing again is still safe. diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 82ca59e..4ce83ca 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -2,6 +2,7 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import type { AgentEvent, ChatMessage, + ConversationStatus, EventHookDescriptor, Logger, ProviderContract, @@ -111,6 +112,22 @@ export interface ConversationOpenedPayload { export const conversationOpened: EventHookDescriptor = defineEventHook("session-orchestrator/conversation-opened"); +/** Payload for the conversationStatusChanged bus event. */ +export interface ConversationStatusChangedPayload { + readonly conversationId: string; + readonly status: ConversationStatus; +} + +/** + * Fired when a conversation's lifecycle status changes (active/idle/closed). + * Transport-ws subscribes and broadcasts a `conversation.statusChanged` WS + * message to all connected frontend clients so tabs sync across devices. + */ +export const conversationStatusChanged: EventHookDescriptor = + defineEventHook( + "session-orchestrator/conversation-status-changed", + ); + /** Payload for the warmCompleted bus event. */ export interface WarmCompletedPayload { readonly conversationId: string; @@ -288,6 +305,8 @@ export function createSessionOrchestrator( payloadPromise.then((payload) => { deps.emit?.(turnStarted, payload); + deps.emit?.(conversationStatusChanged, { conversationId, status: "active" }); + void deps.conversationStore.setConversationStatus(conversationId, "active"); }); void (async () => { @@ -419,6 +438,13 @@ export function createSessionOrchestrator( } void payloadPromise.then((payload) => { deps.emit?.(turnSettled, payload); + if (!carried) { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "idle", + }); + void deps.conversationStore.setConversationStatus(conversationId, "idle"); + } }); } })(); @@ -486,6 +512,8 @@ export function createSessionOrchestrator( turn.controller.abort(); } deps.emit?.(conversationClosed, { conversationId }); + deps.emit?.(conversationStatusChanged, { conversationId, status: "closed" }); + void deps.conversationStore.setConversationStatus(conversationId, "closed"); return { abortedTurn }; }, diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index 2aba3d4..745ac27 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -82,6 +82,10 @@ function createInMemoryStore(): ConversationStore & { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; } diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 0739977..682069b 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.13.0", + "version": "0.14.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index d8bfe50..237f4ab 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -23,6 +23,7 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-co import type { AgentEvent, ConversationMeta, + ConversationStatus, QueuedMessage, ReasoningEffort, StoredChunk, @@ -32,6 +33,7 @@ import type { export type { AgentEvent, ConversationMeta, + ConversationStatus, QueuedMessage, ReasoningEffort, StepMetrics, @@ -483,7 +485,8 @@ export type WsServerMessage = | SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage - | ConversationOpenMessage; + | ConversationOpenMessage + | ConversationStatusChangedMessage; // ─── Conversation list + metadata ──────────────────────────────────────────── @@ -497,6 +500,17 @@ export interface ConversationOpenMessage { readonly conversationId: string; } +/** + * Broadcast to all connected WS clients when a conversation's lifecycle status + * changes (active/idle/closed). The frontend uses this to sync tab state across + * devices in real time. + */ +export interface ConversationStatusChangedMessage { + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; +} + /** * Response for `GET /conversations` — the list of all known conversations, * sorted by `lastActivityAt` descending (most recent first). Each entry carries diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index cb14648..789efce 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -134,6 +134,10 @@ function createFakeConversationStore( return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; } @@ -851,6 +855,10 @@ describe("GET /conversations/:id", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const app = createApp({ conversationStore: store, @@ -915,6 +923,10 @@ describe("GET /conversations/:id", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const app = createApp({ conversationStore: store, @@ -1048,6 +1060,10 @@ describe("GET /conversations/:id/metrics", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const app = createApp({ conversationStore: brokenStore, @@ -2014,6 +2030,10 @@ describe("PUT /conversations/:id/reasoning-effort", () => { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; const app = createApp({ conversationStore: store, @@ -2034,9 +2054,9 @@ describe("PUT /conversations/:id/reasoning-effort", () => { describe("GET /conversations", () => { const sampleConvos: ConversationMeta[] = [ - { id: "conv-1", createdAt: 1000, lastActivityAt: 2000, title: "First" }, - { id: "conv-2", createdAt: 1500, lastActivityAt: 2500, title: "Second" }, - { id: "other-1", createdAt: 3000, lastActivityAt: 4000, title: "Other" }, + { id: "conv-1", createdAt: 1000, lastActivityAt: 2000, title: "First", status: "idle" }, + { id: "conv-2", createdAt: 1500, lastActivityAt: 2500, title: "Second", status: "idle" }, + { id: "other-1", createdAt: 3000, lastActivityAt: 4000, title: "Other", status: "idle" }, ]; function appWithList(list: ConversationMeta[]) { diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 7db5cba..e9f56c9 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -30,6 +30,7 @@ import { parseQueueBody, parseReasoningEffortBody, parseSinceSeq, + parseStatusFilter, parseWarmBody, parseWindowParam, serializeEventLine, @@ -551,7 +552,13 @@ export function createApp(opts: CreateServerOptions): Hono { app.get("/conversations", async (c) => { try { - const all = await opts.conversationStore.listConversations(); + // Optional `?status=` comma-separated filter (e.g. "active,idle"). + // Default: all statuses. Invalid values are silently ignored. + const rawStatus = c.req.query("status"); + const statusFilter = parseStatusFilter(rawStatus); + const all = await opts.conversationStore.listConversations( + statusFilter !== undefined ? { status: statusFilter } : undefined, + ); // Optional `?q=` filters by id prefix (short-id resolution). A // missing/empty/whitespace-only `q` is ignored → return all. const rawQ = c.req.query("q"); @@ -560,6 +567,7 @@ export function createApp(opts: CreateServerOptions): Hono { log.info("conversations: list", { count: conversations.length, ...(q.length > 0 ? { q } : {}), + ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}), }); const body: ConversationListResponse = { conversations }; return c.json(body, 200); diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index d20713c..5111c75 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -1,4 +1,9 @@ -import type { AgentEvent, ChatMessage, ReasoningEffort } from "@dispatch/kernel"; +import type { + AgentEvent, + ChatMessage, + ConversationStatus, + ReasoningEffort, +} from "@dispatch/kernel"; const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [ "low", @@ -8,6 +13,30 @@ const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [ "max", ]; +const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed"]; + +/** + * Pure: parse a `?status=` query value into a list of valid ConversationStatus + * values. Returns `undefined` when the input is missing/empty (no filter). + * Invalid values are silently dropped; if ALL values are invalid, returns + * `undefined` (no filter — shows all). + */ +export function parseStatusFilter( + raw: string | undefined, +): readonly ConversationStatus[] | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const parts = trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const valid = parts.filter((p): p is ConversationStatus => + VALID_STATUSES.includes(p as ConversationStatus), + ); + return valid.length > 0 ? valid : undefined; +} + export function isValidReasoningEffort(value: unknown): value is ReasoningEffort { return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); } diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index 3b6ee8f..a15a2c7 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -61,6 +61,10 @@ function fakeConversationStore(): ConversationStore { return null; }, async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, }; } diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 8b07a54..f59bb1f 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -8,7 +8,11 @@ import type { Extension, HostAPI } from "@dispatch/kernel"; import type { SessionOrchestrator } from "@dispatch/session-orchestrator"; -import { conversationOpened, sessionOrchestratorHandle } from "@dispatch/session-orchestrator"; +import { + conversationOpened, + conversationStatusChanged, + sessionOrchestratorHandle, +} from "@dispatch/session-orchestrator"; import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; import { surfaceRegistryHandle } from "@dispatch/surface-registry"; import type { WsClientMessage, WsServerMessage } from "@dispatch/transport-contract"; @@ -135,6 +139,14 @@ export function createTransportWsExtension(): Extension { }), ); + // Broadcast `conversation.statusChanged` to all connected clients so + // tabs sync across devices in real time. + disposers.push( + host.on(conversationStatusChanged, ({ conversationId, status }) => { + broadcast({ type: "conversation.statusChanged", conversationId, status }); + }), + ); + server = Bun.serve({ port, fetch(req, srv) { diff --git a/packages/wire/package.json b/packages/wire/package.json index e8aa662..d884c1e 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/wire", - "version": "0.9.0", + "version": "0.10.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index 72b5e43..53f1f02 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -499,15 +499,26 @@ export interface TurnSteeringEvent { // ─── Conversation metadata ─────────────────────────────────────────────────── +/** + * The lifecycle status of a conversation, used for tab persistence across + * devices. `active` = an agent is currently generating; `idle` = exists but not + * generating; `closed` = user dismissed the tab (hidden from the tab bar, not + * deleted). New conversations start as `idle`; transitions to `active` on + * turn-start, back to `idle` on turn done/error, and to `closed` on user close. + */ +export type ConversationStatus = "active" | "idle" | "closed"; + /** * Metadata for a conversation, returned by `GET /conversations` (the list * endpoint). The title defaults to the first user message (truncated) and can * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write; - * `lastActivityAt` is updated on every append. + * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle + * for cross-device persistence. */ export interface ConversationMeta { readonly id: string; readonly createdAt: number; readonly lastActivityAt: number; readonly title: string; + readonly status: ConversationStatus; } -- cgit v1.2.3