refactor: unify workspace tab management and add runtime metrics
This commit is contained in:
@@ -20,7 +20,7 @@ import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore } from "@/stores
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
|
||||
describe("workspace-tabs-store promoteDraftToAgent", () => {
|
||||
describe("workspace-tabs-store retargetTab", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceTabsStore.setState({
|
||||
uiTabsByWorkspace: {},
|
||||
@@ -29,55 +29,26 @@ describe("workspace-tabs-store promoteDraftToAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces draft tab id in order with agent tab id, preserves tab count, and removes draft UI tab", () => {
|
||||
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
|
||||
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().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "left" },
|
||||
});
|
||||
useWorkspaceTabsStore.getState().openDraftTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
});
|
||||
|
||||
const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({
|
||||
useWorkspaceTabsStore.getState().ensureTab({
|
||||
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 },
|
||||
target: { kind: "agent", agentId: "right" },
|
||||
});
|
||||
useWorkspaceTabsStore.getState().focusTab({
|
||||
serverId: SERVER_ID,
|
||||
@@ -85,23 +56,84 @@ describe("workspace-tabs-store promoteDraftToAgent", () => {
|
||||
tabId: draftTabId,
|
||||
});
|
||||
|
||||
const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({
|
||||
const before = useWorkspaceTabsStore.getState();
|
||||
const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
|
||||
const retargeted = useWorkspaceTabsStore.getState().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftTabId,
|
||||
agentId,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created" },
|
||||
});
|
||||
|
||||
const after = useWorkspaceTabsStore.getState();
|
||||
const afterOrder = after.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const tabs = after.uiTabsByWorkspace[workspaceKey] ?? [];
|
||||
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
|
||||
|
||||
expect(retargeted).toBe(draftTabId);
|
||||
expect(afterOrder).toEqual(beforeOrder);
|
||||
expect(after.focusedTabIdByWorkspace[workspaceKey]).toBe(draftTabId);
|
||||
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
|
||||
});
|
||||
|
||||
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const terminalTabId = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
});
|
||||
expect(terminalTabId).toBe("terminal_term-1");
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBeUndefined();
|
||||
|
||||
const focusedTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
});
|
||||
expect(focusedTabId).toBe("terminal_term-1");
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
|
||||
"terminal_term-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
|
||||
const draftTabId = "draft_x";
|
||||
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().retargetTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
});
|
||||
|
||||
const ensured = useWorkspaceTabsStore.getState().ensureTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
});
|
||||
|
||||
const state = useWorkspaceTabsStore.getState();
|
||||
const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const duplicateCount = afterOrder.filter((tabId) => tabId === expectedAgentTabId).length;
|
||||
const tabs = state.uiTabsByWorkspace[workspaceKey] ?? [];
|
||||
const order = state.tabOrderByWorkspace[workspaceKey] ?? [];
|
||||
const matchingTabs = tabs.filter(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent"
|
||||
);
|
||||
|
||||
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();
|
||||
expect(ensured).toBe(draftTabId);
|
||||
expect(matchingTabs).toHaveLength(1);
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,8 +61,23 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUiTarget(target: WorkspaceTabTarget): target is Extract<WorkspaceTabTarget, { kind: "draft" | "file" }> {
|
||||
return target.kind === "draft" || target.kind === "file";
|
||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
@@ -107,6 +122,11 @@ type WorkspaceTabsState = {
|
||||
tabOrderByWorkspace: Record<string, string[]>;
|
||||
focusedTabIdByWorkspace: Record<string, string>;
|
||||
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
||||
ensureTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -114,11 +134,11 @@ type WorkspaceTabsState = {
|
||||
}) => string | null;
|
||||
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
promoteDraftToAgent: (input: {
|
||||
retargetTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
draftTabId: string;
|
||||
agentId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
@@ -141,39 +161,39 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
target: { kind: "draft", draftId: normalizedDraftId },
|
||||
});
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = buildDeterministicTabId(normalizedTarget);
|
||||
const deterministicTabId = buildDeterministicTabId(normalizedTarget);
|
||||
let resolvedTabId = deterministicTabId;
|
||||
const now = Date.now();
|
||||
|
||||
set((state) => {
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId });
|
||||
|
||||
if (!isUiTarget(normalizedTarget)) {
|
||||
return {
|
||||
tabOrderByWorkspace:
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === tabId);
|
||||
const nextTabs =
|
||||
existingIndex >= 0
|
||||
? currentTabs
|
||||
: [...currentTabs, { tabId, target: normalizedTarget, createdAt: now }];
|
||||
const tabWithSameTarget =
|
||||
currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||
resolvedTabId = effectiveTabId;
|
||||
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
|
||||
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
|
||||
const nextTabs = (() => {
|
||||
if (existingIndex < 0) {
|
||||
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt: now }];
|
||||
}
|
||||
const existing = currentTabs[existingIndex];
|
||||
if (existing && tabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
return currentTabs;
|
||||
}
|
||||
return currentTabs.map((tab, index) =>
|
||||
index === existingIndex ? { ...tab, target: normalizedTarget } : tab
|
||||
);
|
||||
})();
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace:
|
||||
@@ -184,13 +204,17 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return resolvedTabId;
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
const tabId = get().ensureTab({ serverId, workspaceId, target });
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
get().focusTab({ serverId, workspaceId, tabId });
|
||||
return tabId;
|
||||
},
|
||||
focusTab: ({ serverId, workspaceId, tabId }) => {
|
||||
@@ -272,81 +296,40 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
promoteDraftToAgent: ({ serverId, workspaceId, draftTabId, agentId }) => {
|
||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedDraftTabId = trimNonEmpty(draftTabId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!key || !normalizedDraftTabId || !normalizedAgentId) {
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTabId = `agent_${normalizedAgentId}`;
|
||||
let promotedTabId: string | null = null;
|
||||
let retargetedTabId: 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) {
|
||||
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||
if (index < 0) {
|
||||
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) {
|
||||
const currentTarget = currentTabs[index]?.target;
|
||||
if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
promotedTabId = nextTabId;
|
||||
const nextTabs = currentTabs.map((tab, tabIndex) =>
|
||||
tabIndex === index ? { ...tab, target: normalizedTarget } : tab
|
||||
);
|
||||
retargetedTabId = normalizedTabId;
|
||||
return {
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
|
||||
tabOrderByWorkspace: state.tabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
return promotedTabId;
|
||||
return retargetedTabId;
|
||||
},
|
||||
reorderTabs: ({ serverId, workspaceId, tabIds }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
@@ -389,12 +372,25 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
}),
|
||||
{
|
||||
name: "workspace-tabs-state",
|
||||
version: 4,
|
||||
version: 5,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => {
|
||||
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? []).filter((tab) => isUiTarget(tab.target));
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||
.map((tab) => {
|
||||
const normalizedTarget = normalizeTabTarget(tab.target);
|
||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||
if (!normalizedTarget || !normalizedTabId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(),
|
||||
} satisfies WorkspaceTab;
|
||||
})
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null);
|
||||
if (tabs.length > 0) {
|
||||
nextUiTabsByWorkspace[key] = tabs;
|
||||
}
|
||||
@@ -473,10 +469,6 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
orderFromTabs.push(tabId);
|
||||
}
|
||||
|
||||
if (!isUiTarget(normalizedTarget)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextUiTabs.push({
|
||||
tabId,
|
||||
target: normalizedTarget,
|
||||
|
||||
Reference in New Issue
Block a user