summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/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/transport-http/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/transport-http/src')
-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
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() {},
};
}