diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 00:08:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 00:08:21 +0900 |
| commit | 7ff9f94c41a9870e124a50133cd74b42295ab9ac (patch) | |
| tree | 3a3f09d843dc3263983fa44b384ecc3c1a32e750 /packages/transport-http/src | |
| parent | 037c136823a900e28864e4dd48e1dbe626e95dfb (diff) | |
| download | dispatch-7ff9f94c41a9870e124a50133cd74b42295ab9ac.tar.gz dispatch-7ff9f94c41a9870e124a50133cd74b42295ab9ac.zip | |
feat: conversation lifecycle status (active/idle/closed) for tab persistence
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 <state> flag to filter by single status
- --all flag to include closed
FE handoff: frontend-conversation-lifecycle-handoff.md
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 26 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 10 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 31 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 4 |
4 files changed, 66 insertions, 5 deletions
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() {}, }; } |
