summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 00:08:21 +0900
committerAdam Malczewski <[email protected]>2026-06-22 00:08:21 +0900
commit7ff9f94c41a9870e124a50133cd74b42295ab9ac (patch)
tree3a3f09d843dc3263983fa44b384ecc3c1a32e750
parent037c136823a900e28864e4dd48e1dbe626e95dfb (diff)
downloaddispatch-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
-rw-r--r--frontend-conversation-lifecycle-handoff.md102
-rw-r--r--packages/cli/src/args.test.ts29
-rw-r--r--packages/cli/src/args.ts23
-rw-r--r--packages/cli/src/http.test.ts23
-rw-r--r--packages/cli/src/http.ts10
-rw-r--r--packages/cli/src/main.ts9
-rw-r--r--packages/cli/src/render.test.ts1
-rw-r--r--packages/conversation-store/src/store.test.ts99
-rw-r--r--packages/conversation-store/src/store.ts75
-rw-r--r--packages/kernel/src/contracts/conversation.ts1
-rw-r--r--packages/kernel/src/contracts/index.ts1
-rw-r--r--packages/session-orchestrator/src/extension.ts1
-rw-r--r--packages/session-orchestrator/src/index.ts2
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts37
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts28
-rw-r--r--packages/session-orchestrator/src/queue.test.ts4
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts16
-rw-r--r--packages/transport-http/src/app.test.ts26
-rw-r--r--packages/transport-http/src/app.ts10
-rw-r--r--packages/transport-http/src/logic.ts31
-rw-r--r--packages/transport-http/src/server.bun.test.ts4
-rw-r--r--packages/transport-ws/src/extension.ts14
-rw-r--r--packages/wire/package.json2
-rw-r--r--packages/wire/src/index.ts13
25 files changed, 529 insertions, 34 deletions
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 <active|idle|closed>` — 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<Response> => {
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<ConversationListResponse> {
- 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 <url>]
- dispatch list [<prefix>] [--server <url>]
+ dispatch list [<prefix>] [--status <active|idle|closed>] [--all] [--server <url>]
dispatch read <conversationId> [--server <url>]
dispatch open <conversationId> [--server <url>]
dispatch send <conversationId> --text "..." [--queue] [--open] [--cwd <dir>] [--effort <level>] [--server <url>]
@@ -50,9 +50,14 @@ async function main(): Promise<void> {
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 ConversationMeta[]>;
+ readonly listConversations: (filter?: {
+ readonly status?: readonly ConversationStatus[];
+ }) => Promise<readonly ConversationMeta[]>;
/** Single conversation metadata, or null if unknown. */
readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>;
/** Set/update the human-readable title for a conversation. */
readonly setConversationTitle: (conversationId: string, title: string) => Promise<void>;
+ /** Get the lifecycle status of a conversation, or null if unknown. */
+ readonly getConversationStatus: (conversationId: string) => Promise<ConversationStatus | null>;
+ /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */
+ readonly setConversationStatus: (
+ conversationId: string,
+ status: ConversationStatus,
+ ) => Promise<void>;
}
export const conversationStoreHandle = defineService<ConversationStore>("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<ConversationOpenedPayload> =
defineEventHook<ConversationOpenedPayload>("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<ConversationStatusChangedPayload> =
+ defineEventHook<ConversationStatusChangedPayload>(
+ "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 ────────────────────────────────────────────
@@ -498,6 +501,17 @@ export interface ConversationOpenMessage {
}
/**
+ * 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
* enough metadata for a conversation picker UI (id, title, timestamps).
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<ConnectionState>({
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
@@ -500,14 +500,25 @@ 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;
}