From 7578846c2712a055545556b1380d80495cf5a446 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 15:37:46 +0700 Subject: [PATCH] refactor: add promoteDraftToAgent store action with tests --- .../screens/workspace/workspace-screen.tsx | 11 +- .../src/stores/workspace-tabs-store.test.ts | 107 ++++++++++++++++++ .../app/src/stores/workspace-tabs-store.ts | 82 ++++++++++++++ 3 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/stores/workspace-tabs-store.test.ts diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 6975e2b80..55a19d416 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -411,6 +411,7 @@ function WorkspaceScreenContent({ const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab); const focusTab = useWorkspaceTabsStore((state) => state.focusTab); const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab); + const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent); const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs); useEffect(() => { @@ -971,15 +972,11 @@ function WorkspaceScreenContent({ draftId={target.draftId} onCreated={(agentSnapshot) => { const tabId = activeTabId ?? target.draftId; - const nextAgentTabId = openOrFocusTab({ + const nextAgentTabId = promoteDraftToAgent({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - target: { kind: "agent", agentId: agentSnapshot.id }, - }); - closeWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, + draftTabId: tabId, + agentId: agentSnapshot.id, }); if (nextAgentTabId) { navigateToTabId(nextAgentTabId); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts new file mode 100644 index 000000000..e2212dc62 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore } from "@/stores/workspace-tabs-store"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/worktree"; + +describe("workspace-tabs-store promoteDraftToAgent", () => { + beforeEach(() => { + useWorkspaceTabsStore.setState({ + uiTabsByWorkspace: {}, + tabOrderByWorkspace: {}, + focusedTabIdByWorkspace: {}, + }); + }); + + it("replaces draft tab id in order with agent tab id, preserves tab count, and removes draft UI tab", () => { + const draftTabId = "draft_123"; + const agentId = "agent-1"; + const expectedAgentTabId = `agent_${agentId}`; + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + useWorkspaceTabsStore.getState().openDraftTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + }); + + const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? []; + const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftTabId, + agentId, + }); + + const state = useWorkspaceTabsStore.getState(); + const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? []; + + expect(promoted).toBe(expectedAgentTabId); + expect(afterOrder).toEqual([expectedAgentTabId]); + expect(afterOrder).toHaveLength(beforeOrder.length); + expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined(); + expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId); + }); + + it("does not create duplicate agent tab ids when agent tab already exists", () => { + const draftTabId = "draft_456"; + const agentId = "agent-dup"; + const expectedAgentTabId = `agent_${agentId}`; + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + useWorkspaceTabsStore.getState().openDraftTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + }); + useWorkspaceTabsStore.getState().openOrFocusTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId }, + }); + useWorkspaceTabsStore.getState().focusTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + }); + + const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? []; + const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftTabId, + agentId, + }); + + const state = useWorkspaceTabsStore.getState(); + const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? []; + const duplicateCount = afterOrder.filter((tabId) => tabId === expectedAgentTabId).length; + + expect(promoted).toBe(expectedAgentTabId); + expect(duplicateCount).toBe(1); + expect(afterOrder).toEqual([expectedAgentTabId]); + expect(afterOrder.length).toBeLessThanOrEqual(beforeOrder.length); + expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId); + expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined(); + }); +}); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index da9edadea..8493706ae 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -114,6 +114,12 @@ type WorkspaceTabsState = { }) => string | null; focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; + promoteDraftToAgent: (input: { + serverId: string; + workspaceId: string; + draftTabId: string; + agentId: string; + }) => string | null; reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; }; @@ -266,6 +272,82 @@ export const useWorkspaceTabsStore = create()( }; }); }, + promoteDraftToAgent: ({ serverId, workspaceId, draftTabId, agentId }) => { + const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + const normalizedDraftTabId = trimNonEmpty(draftTabId); + const normalizedAgentId = trimNonEmpty(agentId); + if (!key || !normalizedDraftTabId || !normalizedAgentId) { + return null; + } + + const nextTabId = `agent_${normalizedAgentId}`; + let promotedTabId: string | null = null; + + set((state) => { + const currentOrder = state.tabOrderByWorkspace[key] ?? []; + const currentTabs = state.uiTabsByWorkspace[key] ?? []; + const hasDraftInOrder = currentOrder.includes(normalizedDraftTabId); + const hasDraftInUiTabs = currentTabs.some((tab) => tab.tabId === normalizedDraftTabId); + if (!hasDraftInOrder && !hasDraftInUiTabs) { + return state; + } + + const hasAgentTabInOrder = currentOrder.includes(nextTabId); + const nextOrder = hasAgentTabInOrder + ? currentOrder.filter((tabId) => tabId !== normalizedDraftTabId) + : currentOrder + .map((tabId) => (tabId === normalizedDraftTabId ? nextTabId : tabId)) + .filter((tabId, index, arr) => arr.indexOf(tabId) === index); + + const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedDraftTabId); + const nextUiTabsByWorkspace = + nextTabs.length === 0 + ? (() => { + const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; + return rest; + })() + : nextTabs.length === currentTabs.length + ? state.uiTabsByWorkspace + : { ...state.uiTabsByWorkspace, [key]: nextTabs }; + + const nextTabOrderByWorkspace = + nextOrder.length === 0 + ? (() => { + const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; + return rest; + })() + : nextOrder.length === currentOrder.length && + nextOrder.every((tabId, index) => tabId === currentOrder[index]) + ? state.tabOrderByWorkspace + : { ...state.tabOrderByWorkspace, [key]: nextOrder }; + + const currentFocused = trimNonEmpty(state.focusedTabIdByWorkspace[key]); + const nextFocused = !currentFocused || currentFocused === normalizedDraftTabId ? nextTabId : currentFocused; + const nextFocusedByWorkspace = + nextFocused === currentFocused + ? state.focusedTabIdByWorkspace + : { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; + + const tabsChanged = nextTabs.length !== currentTabs.length; + const orderChanged = + nextOrder.length !== currentOrder.length || + nextOrder.some((tabId, index) => tabId !== currentOrder[index]); + const focusChanged = nextFocused !== currentFocused; + + if (!tabsChanged && !orderChanged && !focusChanged) { + return state; + } + + promotedTabId = nextTabId; + return { + uiTabsByWorkspace: nextUiTabsByWorkspace, + tabOrderByWorkspace: nextTabOrderByWorkspace, + focusedTabIdByWorkspace: nextFocusedByWorkspace, + }; + }); + + return promotedTabId; + }, reorderTabs: ({ serverId, workspaceId, tabIds }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); if (!key) {