summaryrefslogtreecommitdiffhomepage
path: root/src/adapters
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 14:38:50 +0900
committerAdam Malczewski <[email protected]>2026-10-15 14:38:50 +0900
commitc4c1d76fec972e5d902a669f0cb1953eaa528f16 (patch)
treed5d85b8500157dd690e13954216a61a6aac64a8a /src/adapters
parent3d3c28b10de06aae7e2ad474e8931a3fdd88b41c (diff)
downloaddispatch-web-c4c1d76fec972e5d902a669f0cb1953eaa528f16.tar.gz
dispatch-web-c4c1d76fec972e5d902a669f0cb1953eaa528f16.zip
feat(tabs): 2-column tab grid with nestable subagent groups + tab menu (add/remove from group)
Diffstat (limited to 'src/adapters')
-rw-r--r--src/adapters/ws/index.ts5
-rw-r--r--src/adapters/ws/logic.test.ts60
-rw-r--r--src/adapters/ws/logic.ts21
3 files changed, 86 insertions, 0 deletions
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts
index 4dc0104..76d9e75 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -4,6 +4,7 @@ import type {
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
ExtensionsChangedMessage,
LayoutChangedMessage,
TileLayoutChangedMessage,
@@ -30,6 +31,8 @@ export interface SurfaceSocketOptions {
onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void;
/** Broadcast when a conversation's history has been compacted (reload needed). */
onConversationCompacted?: (msg: ConversationCompactedMessage) => void;
+ /** Broadcast when a conversation's (visual) tab group parent changes. */
+ onConversationTabGroupChanged?: (msg: ConversationTabGroupChangedMessage) => void;
/**
* Broadcast after every plugin-manager mutation (plan 05 §11): every
* connected frontend refetches its extension list (the server re-pushes the
@@ -93,6 +96,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
opts.onConversationOpen?.(msg as ConversationOpenMessage);
} else if (msg.type === "conversation.statusChanged") {
opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage);
+ } else if (msg.type === "conversation.tabGroupChanged") {
+ opts.onConversationTabGroupChanged?.(msg as ConversationTabGroupChangedMessage);
} else if (msg.type === "conversation.compacted") {
opts.onConversationCompacted?.(msg as ConversationCompactedMessage);
} else if (msg.type === "extensions-changed") {
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index 8fe2b5f..2179ad0 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -502,3 +502,63 @@ describe("parseServerMessage — layout (plan 02)", () => {
expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull();
});
});
+
+describe("parseServerMessage — subagent tab groups", () => {
+ it("forwards the effective tabGroupParentId on conversation.open", () => {
+ const result = parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "child",
+ workspaceId: "w1",
+ tabGroupParentId: "parent",
+ }),
+ );
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "child",
+ workspaceId: "w1",
+ tabGroupParentId: "parent",
+ });
+ });
+
+ it("omits a non-string/empty tabGroupParentId on conversation.open", () => {
+ for (const tabGroupParentId of ["", 42, null]) {
+ const result = parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ tabGroupParentId,
+ }),
+ );
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ });
+ }
+ });
+
+ it("parses conversation.tabGroupChanged with a parent id and with null (ungrouped)", () => {
+ for (const tabGroupParentId of ["parent", null]) {
+ const msg = {
+ type: "conversation.tabGroupChanged",
+ conversationId: "c1",
+ workspaceId: "w1",
+ tabGroupParentId,
+ };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ }
+ });
+
+ it("rejects a malformed conversation.tabGroupChanged", () => {
+ const base = { type: "conversation.tabGroupChanged", conversationId: "c1", workspaceId: "w1" };
+ expect(parseServerMessage(JSON.stringify(base))).toBeNull(); // missing (undefined) parent
+ expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: "" }))).toBeNull();
+ expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: 1 }))).toBeNull();
+ const { workspaceId: _w, ...noWorkspace } = base;
+ expect(
+ parseServerMessage(JSON.stringify({ ...noWorkspace, tabGroupParentId: "p" })),
+ ).toBeNull();
+ });
+});
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 4dbd173..cecc66c 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -4,6 +4,7 @@ import type {
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
ExtensionsChangedMessage,
LayoutChangedMessage,
TileLayoutChangedMessage,
@@ -27,6 +28,7 @@ const VALID_SERVER_TYPES = new Set([
"conversation.open",
"conversation.statusChanged",
"conversation.compacted",
+ "conversation.tabGroupChanged",
"extensions-changed",
"layout",
"tile-layout",
@@ -126,11 +128,30 @@ export function parseServerMessage(data: string): WsServerMessage | null {
// it, so a client can stamp the opened tab with the conversation's ACTUAL
// model (fix-toolcall-duplicate-keys plan Part 4).
const model = parsed.model;
+ // The EFFECTIVE tab group parent, so a summoned subagent's tab lands in
+ // its summoner's group immediately.
+ const tabGroupParentId = parsed.tabGroupParentId;
const msg: ConversationOpenMessage = {
type: "conversation.open",
conversationId: parsed.conversationId,
workspaceId: parsed.workspaceId,
...(typeof model === "string" && model.length > 0 ? { model } : {}),
+ ...(typeof tabGroupParentId === "string" && tabGroupParentId.length > 0
+ ? { tabGroupParentId }
+ : {}),
+ };
+ return msg;
+ }
+ case "conversation.tabGroupChanged": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.workspaceId !== "string") return null;
+ const parent = parsed.tabGroupParentId;
+ if (parent !== null && (typeof parent !== "string" || parent.length === 0)) return null;
+ const msg: ConversationTabGroupChangedMessage = {
+ type: "conversation.tabGroupChanged",
+ conversationId: parsed.conversationId,
+ workspaceId: parsed.workspaceId,
+ tabGroupParentId: parent,
};
return msg;
}