summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src
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 /packages/conversation-store/src
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
Diffstat (limited to 'packages/conversation-store/src')
-rw-r--r--packages/conversation-store/src/store.test.ts99
-rw-r--r--packages/conversation-store/src/store.ts75
2 files changed, 168 insertions, 6 deletions
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));
},