feat: open Changes as a workspace tab (#2298)
Open the complete working comparison in a desktop workspace tab while preserving inline sidebar diffs when the tab is closed. Working and commit comparisons share one diff panel, and workspace layout is the sole owner of live tab persistence and identity. Refs #1520
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
|
||||
|
||||
export interface RouteSelectionInput {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PendingWorkspaceDraftSubmission {
|
||||
serverId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
|
||||
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
|
||||
@@ -285,9 +285,14 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
|
||||
|
||||
const tab = value as WorkspaceTab;
|
||||
const target = normalizeWorkspaceTabTarget(tab.target);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const tabId =
|
||||
trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null);
|
||||
if (!target || !tabId) {
|
||||
target.kind === "working_diff"
|
||||
? buildDeterministicWorkspaceTabId(target)
|
||||
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
|
||||
if (!tabId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,8 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
createWorkspaceLayoutStore,
|
||||
@@ -27,8 +26,10 @@ import {
|
||||
getFocusedBrowserId,
|
||||
getTreeDepth,
|
||||
insertSplit,
|
||||
normalizeLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
stripEphemeralTabsFromLayout,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -1128,6 +1129,73 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists working diff tabs while stripping commit diff tabs", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = workspaceLayoutStore.getState();
|
||||
|
||||
store.openTabFocused(workspaceKey, {
|
||||
kind: "working_diff",
|
||||
focusPath: "src/a.ts",
|
||||
});
|
||||
store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" });
|
||||
|
||||
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
||||
expect(partialize).toBeTypeOf("function");
|
||||
if (!partialize) {
|
||||
throw new Error("Workspace layout partialize function is missing");
|
||||
}
|
||||
const currentState = workspaceLayoutStore.getState();
|
||||
const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]);
|
||||
const persisted = partialize(currentState);
|
||||
|
||||
expect(persisted).toEqual({
|
||||
layoutByWorkspace: { [workspaceKey]: layout },
|
||||
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
|
||||
});
|
||||
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
|
||||
{
|
||||
kind: "working_diff",
|
||||
focusPath: "src/a.ts",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => {
|
||||
const legacyTabId = "working_diff_uncommitted_0_n";
|
||||
const layout = normalizeLayout({
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: [legacyTabId],
|
||||
focusedTabId: legacyTabId,
|
||||
tabs: [
|
||||
{
|
||||
tabId: legacyTabId,
|
||||
createdAt: 1,
|
||||
target: {
|
||||
kind: "working_diff",
|
||||
focusPath: "src/a.ts",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
ignoreWhitespace: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
});
|
||||
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: "working_diff",
|
||||
target: { kind: "working_diff", focusPath: "src/a.ts" },
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
||||
useWorkspaceLayoutIds(
|
||||
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||
|
||||
@@ -2,11 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabTarget,
|
||||
} from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
defaultWorkspaceLayoutIds,
|
||||
type WorkspaceLayoutIdSource,
|
||||
@@ -47,7 +43,6 @@ import {
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
|
||||
export { buildWorkspaceTabPersistenceKey };
|
||||
export {
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { selectSubagentsForParent } from "@/subagents/select";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
applyCloseTab,
|
||||
applyEnsureTab,
|
||||
applyFocusTab,
|
||||
applyOpenDraftTab,
|
||||
applyOpenOrFocusTab,
|
||||
applyPurgeWorkspace,
|
||||
applyReorderTabs,
|
||||
applyRetargetTab,
|
||||
initialWorkspaceTabsCoreState,
|
||||
migrateWorkspaceTabsState,
|
||||
partializeWorkspaceTabsState,
|
||||
selectWorkspaceTabs,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabsCoreState,
|
||||
type WorkspaceTabTarget,
|
||||
} from "./state";
|
||||
|
||||
export { buildWorkspaceTabPersistenceKey } from "./state";
|
||||
export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state";
|
||||
|
||||
interface WorkspaceTabsState extends WorkspaceTabsCoreState {
|
||||
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;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
retargetTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialWorkspaceTabsCoreState,
|
||||
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyOpenDraftTab(state, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
draftId,
|
||||
now: Date.now(),
|
||||
});
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyEnsureTab(state, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
now: Date.now(),
|
||||
});
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyOpenOrFocusTab(state, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
now: Date.now(),
|
||||
});
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
focusTab: (input) => set((state) => applyFocusTab(state, input)),
|
||||
closeTab: (input) => set((state) => applyCloseTab(state, input)),
|
||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||
let resolved: string | null = null;
|
||||
set((state) => {
|
||||
const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target });
|
||||
resolved = result.tabId;
|
||||
return result.state;
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
reorderTabs: (input) => set((state) => applyReorderTabs(state, input)),
|
||||
getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input),
|
||||
purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)),
|
||||
}),
|
||||
{
|
||||
name: "workspace-tabs-state",
|
||||
version: 5,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }),
|
||||
migrate: (persistedState) =>
|
||||
migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,432 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCloseTab,
|
||||
applyEnsureTab,
|
||||
applyFocusTab,
|
||||
applyOpenDraftTab,
|
||||
applyOpenOrFocusTab,
|
||||
applyRetargetTab,
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
initialWorkspaceTabsCoreState,
|
||||
migrateWorkspaceTabsState,
|
||||
type WorkspaceTabsCoreState,
|
||||
} from "./state";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`;
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function emptyState(): WorkspaceTabsCoreState {
|
||||
return {
|
||||
uiTabsByWorkspace: {},
|
||||
tabOrderByWorkspace: {},
|
||||
focusedTabIdByWorkspace: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabPersistenceKey", () => {
|
||||
it("preserves opaque workspace ids instead of normalizing them like paths", () => {
|
||||
expect(
|
||||
buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: " setup\\workspace\\ ",
|
||||
}),
|
||||
).toBe("server-1:setup\\workspace\\");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace-tabs-store reducers", () => {
|
||||
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
|
||||
const draftTabId = "draft_123";
|
||||
|
||||
let state = emptyState();
|
||||
state = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "left" },
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "right" },
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
});
|
||||
|
||||
const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
|
||||
const retargeted = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created" },
|
||||
});
|
||||
|
||||
const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
|
||||
|
||||
expect(retargeted.tabId).toBe(draftTabId);
|
||||
expect(afterOrder).toEqual(beforeOrder);
|
||||
expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId);
|
||||
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
|
||||
});
|
||||
|
||||
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
|
||||
let state = emptyState();
|
||||
const ensured = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
now: NOW,
|
||||
});
|
||||
state = ensured.state;
|
||||
expect(ensured.tabId).toBe("terminal_term-1");
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
|
||||
const focused = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
now: NOW,
|
||||
});
|
||||
expect(focused.tabId).toBe("terminal_term-1");
|
||||
expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1");
|
||||
});
|
||||
|
||||
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
|
||||
const draftTabId = "draft_x";
|
||||
|
||||
let state = emptyState();
|
||||
state = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
now: NOW,
|
||||
}).state;
|
||||
state = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
}).state;
|
||||
|
||||
const ensured = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
const matchingTabs = tabs.filter(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent",
|
||||
);
|
||||
|
||||
expect(ensured.tabId).toBe(draftTabId);
|
||||
expect(matchingTabs).toHaveLength(1);
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
|
||||
it("openDraftTab creates a draft tab and deduplicates by draftId", () => {
|
||||
let state = emptyState();
|
||||
const first = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: "draft-1",
|
||||
now: NOW,
|
||||
});
|
||||
state = first.state;
|
||||
const second = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: "draft-2",
|
||||
now: NOW,
|
||||
});
|
||||
state = second.state;
|
||||
|
||||
expect(first.tabId).toBe("draft-1");
|
||||
expect(second.tabId).toBe("draft-2");
|
||||
expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([
|
||||
{
|
||||
tabId: "draft-1",
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
createdAt: NOW,
|
||||
},
|
||||
{
|
||||
tabId: "draft-2",
|
||||
target: { kind: "draft", draftId: "draft-2" },
|
||||
createdAt: NOW,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps draft setup on a retargeted tab", () => {
|
||||
let state = emptyState();
|
||||
const ensured = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
now: NOW,
|
||||
});
|
||||
expect(ensured.tabId).toBe("agent_agent-1");
|
||||
state = ensured.state;
|
||||
|
||||
const retargeted = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: ensured.tabId!,
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: "draft-replacement",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: { effort: "high" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
|
||||
kind: "draft",
|
||||
draftId: "draft-replacement",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: { effort: "high" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates an existing draft tab when the setup changes", () => {
|
||||
let state = emptyState();
|
||||
const first = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
now: NOW,
|
||||
});
|
||||
state = first.state;
|
||||
const second = applyEnsureTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: {
|
||||
kind: "draft",
|
||||
draftId: "draft-1",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
},
|
||||
now: NOW,
|
||||
});
|
||||
state = second.state;
|
||||
|
||||
expect(second.tabId).toBe(first.tabId);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
|
||||
kind: "draft",
|
||||
draftId: "draft-1",
|
||||
setup: {
|
||||
provider: "mock",
|
||||
cwd: "/repo/worktree",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
|
||||
let state = emptyState();
|
||||
state = applyOpenDraftTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
draftId: draftTabId,
|
||||
now: NOW,
|
||||
}).state;
|
||||
const file = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
now: NOW,
|
||||
});
|
||||
state = file.state;
|
||||
|
||||
state = applyRetargetTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: draftTabId,
|
||||
target: { kind: "agent", agentId: "created-agent" },
|
||||
}).state;
|
||||
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId);
|
||||
});
|
||||
|
||||
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
|
||||
let state = emptyState();
|
||||
const fileResult = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
now: NOW,
|
||||
});
|
||||
state = fileResult.state;
|
||||
const terminalResult = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
now: NOW,
|
||||
});
|
||||
state = terminalResult.state;
|
||||
|
||||
expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts");
|
||||
expect(terminalResult.tabId).toBe("terminal_term-1");
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId);
|
||||
|
||||
const reopened = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(reopened.tabId).toBe(fileResult.tabId);
|
||||
expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId);
|
||||
});
|
||||
|
||||
it("builds a deterministic setup tab keyed by workspace id", () => {
|
||||
const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`);
|
||||
expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId);
|
||||
});
|
||||
|
||||
it("opens a commit diff tab with a commit-specific id", () => {
|
||||
let state = emptyState();
|
||||
const commit = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "commit_diff", sha: "abc123" },
|
||||
now: NOW,
|
||||
});
|
||||
state = commit.state;
|
||||
|
||||
expect(commit.tabId).toBe("commit_diff_abc123");
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => {
|
||||
let state = emptyState();
|
||||
const first = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "left" },
|
||||
now: NOW,
|
||||
});
|
||||
state = first.state;
|
||||
const second = applyOpenOrFocusTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: "right" },
|
||||
now: NOW,
|
||||
});
|
||||
state = second.state;
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId);
|
||||
|
||||
state = applyCloseTab(state, {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
tabId: second.tabId!,
|
||||
});
|
||||
|
||||
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId);
|
||||
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateWorkspaceTabsState commit diff coercion", () => {
|
||||
// This legacy store no longer enforces the "commit diff tabs are ephemeral"
|
||||
// guarantee — that now lives in the workspace-layout store's partialize (see
|
||||
// stripEphemeralTabsFromLayout). This migration only needs to carry old commit
|
||||
// diff targets forward to the dedicated `commit_diff` tab shape.
|
||||
it("migrates a legacy commit diff tab to the dedicated target shape", () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
uiTabsByWorkspace: {
|
||||
[WORKSPACE_KEY]: [
|
||||
{
|
||||
tabId: "commit_diff_abc123",
|
||||
target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } },
|
||||
createdAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
|
||||
const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
|
||||
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" });
|
||||
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]);
|
||||
});
|
||||
|
||||
it("drops a legacy working diff tab during migration", () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
uiTabsByWorkspace: {
|
||||
[WORKSPACE_KEY]: [
|
||||
{
|
||||
tabId: "diff_working:base:main",
|
||||
target: {
|
||||
kind: "diff",
|
||||
diffTarget: { kind: "working", mode: "base", baseRef: "main" },
|
||||
},
|
||||
createdAt: NOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
|
||||
|
||||
expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,799 +0,0 @@
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceDraftTabSetup,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/workspace-tabs/identity";
|
||||
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
|
||||
|
||||
export interface WorkspaceDraftTabSetup {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| WorkspaceFileTabTarget
|
||||
| { kind: "setup"; workspaceId: string }
|
||||
| { kind: "commit_diff"; sha: string };
|
||||
|
||||
export interface WorkspaceTab {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabsCoreState {
|
||||
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
tabOrderByWorkspace: Record<string, string[]>;
|
||||
focusedTabIdByWorkspace: Record<string, string>;
|
||||
}
|
||||
|
||||
export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = {
|
||||
uiTabsByWorkspace: {},
|
||||
tabOrderByWorkspace: {},
|
||||
focusedTabIdByWorkspace: {},
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabPersistenceKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
const serverId = trimNonEmpty(input.serverId);
|
||||
const workspaceId = trimNonEmpty(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
// workspaceId is opaque; do not parse this key back into a path.
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isPlainRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeTabOrder(list: unknown): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
const next: string[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const value of list) {
|
||||
const tabId = trimNonEmpty(typeof value === "string" ? value : null);
|
||||
if (!tabId || used.has(tabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(tabId);
|
||||
next.push(tabId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function ensureInOrder(input: { current: string[]; tabId: string }): string[] {
|
||||
if (input.current.includes(input.tabId)) {
|
||||
return input.current;
|
||||
}
|
||||
return [...input.current, input.tabId];
|
||||
}
|
||||
|
||||
function retargetTabAtIndex(
|
||||
tab: WorkspaceTab,
|
||||
index: number,
|
||||
targetIndex: number,
|
||||
normalizedTarget: WorkspaceTabTarget,
|
||||
): WorkspaceTab {
|
||||
return index === targetIndex ? { ...tab, target: normalizedTarget } : tab;
|
||||
}
|
||||
|
||||
function buildNextTabsForEnsure(args: {
|
||||
currentTabs: WorkspaceTab[];
|
||||
existingIndex: number;
|
||||
effectiveTabId: string;
|
||||
normalizedTarget: WorkspaceTabTarget;
|
||||
createdAt: number;
|
||||
}): WorkspaceTab[] {
|
||||
const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args;
|
||||
if (existingIndex < 0) {
|
||||
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }];
|
||||
}
|
||||
const existing = currentTabs[existingIndex];
|
||||
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
return currentTabs;
|
||||
}
|
||||
return currentTabs.map((tab, index) =>
|
||||
retargetTabAtIndex(tab, index, existingIndex, normalizedTarget),
|
||||
);
|
||||
}
|
||||
|
||||
export interface EnsureTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export interface EnsureTabResult {
|
||||
state: WorkspaceTabsCoreState;
|
||||
tabId: string | null;
|
||||
}
|
||||
|
||||
export function applyEnsureTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: EnsureTabInput,
|
||||
): EnsureTabResult {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const tabWithSameTarget =
|
||||
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
|
||||
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
|
||||
const nextTabs = buildNextTabsForEnsure({
|
||||
currentTabs,
|
||||
existingIndex,
|
||||
effectiveTabId,
|
||||
normalizedTarget,
|
||||
createdAt: input.now,
|
||||
});
|
||||
|
||||
const uiTabsByWorkspace =
|
||||
nextTabs === currentTabs
|
||||
? state.uiTabsByWorkspace
|
||||
: { ...state.uiTabsByWorkspace, [key]: nextTabs };
|
||||
const tabOrderByWorkspace =
|
||||
nextOrder === currentOrder
|
||||
? state.tabOrderByWorkspace
|
||||
: { ...state.tabOrderByWorkspace, [key]: nextOrder };
|
||||
|
||||
if (
|
||||
uiTabsByWorkspace === state.uiTabsByWorkspace &&
|
||||
tabOrderByWorkspace === state.tabOrderByWorkspace
|
||||
) {
|
||||
return { state, tabId: effectiveTabId };
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace },
|
||||
tabId: effectiveTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FocusTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export function applyFocusTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: FocusTabInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
if (!key || !normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: normalizedTabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenOrFocusTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: EnsureTabInput,
|
||||
): EnsureTabResult {
|
||||
const ensured = applyEnsureTab(state, input);
|
||||
if (!ensured.tabId) {
|
||||
return ensured;
|
||||
}
|
||||
const focused = applyFocusTab(ensured.state, {
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
tabId: ensured.tabId,
|
||||
});
|
||||
return { state: focused, tabId: ensured.tabId };
|
||||
}
|
||||
|
||||
export interface OpenDraftTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
now: number;
|
||||
}
|
||||
|
||||
export function applyOpenDraftTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: OpenDraftTabInput,
|
||||
): EnsureTabResult {
|
||||
const normalizedDraftId = trimNonEmpty(input.draftId);
|
||||
if (!normalizedDraftId) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
return applyOpenOrFocusTab(state, {
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
target: { kind: "draft", draftId: normalizedDraftId },
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
|
||||
export interface CloseTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export function applyCloseTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: CloseTabInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
if (!key || !normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
|
||||
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
|
||||
const nextOrder = currentOrder.filter((value) => value !== normalizedTabId);
|
||||
|
||||
let nextUiTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
if (nextTabs.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
|
||||
nextUiTabsByWorkspace = rest;
|
||||
} else if (nextTabs.length === currentTabs.length) {
|
||||
nextUiTabsByWorkspace = state.uiTabsByWorkspace;
|
||||
} else {
|
||||
nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs };
|
||||
}
|
||||
|
||||
let nextTabOrderByWorkspace: Record<string, string[]>;
|
||||
if (nextOrder.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
|
||||
nextTabOrderByWorkspace = rest;
|
||||
} else if (nextOrder.length === currentOrder.length) {
|
||||
nextTabOrderByWorkspace = state.tabOrderByWorkspace;
|
||||
} else {
|
||||
nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder };
|
||||
}
|
||||
|
||||
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
|
||||
const nextFocused =
|
||||
currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null);
|
||||
const nextFocusedByWorkspace = (() => {
|
||||
if (!nextFocused) {
|
||||
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
||||
return rest;
|
||||
}
|
||||
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
||||
})();
|
||||
|
||||
const tabsChanged = nextTabs.length !== currentTabs.length;
|
||||
const orderChanged = nextOrder.length !== currentOrder.length;
|
||||
const focusChanged =
|
||||
(state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null);
|
||||
|
||||
if (!tabsChanged && !orderChanged && !focusChanged) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RetargetTabInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}
|
||||
|
||||
export function applyRetargetTab(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: RetargetTabInput,
|
||||
): EnsureTabResult {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const normalizedTabId = trimNonEmpty(input.tabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||
if (index < 0) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const currentTarget = currentTabs[index]?.target;
|
||||
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
return { state, tabId: null };
|
||||
}
|
||||
|
||||
const nextTabs = currentTabs.map((tab, tabIndex) =>
|
||||
tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab,
|
||||
);
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
|
||||
},
|
||||
tabId: normalizedTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReorderTabsInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tabIds: string[];
|
||||
}
|
||||
|
||||
export function applyReorderTabs(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: ReorderTabsInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!key) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const normalized = normalizeTabOrder(input.tabIds);
|
||||
const current = state.tabOrderByWorkspace[key] ?? [];
|
||||
if (current.length === normalized.length) {
|
||||
let same = true;
|
||||
for (let i = 0; i < current.length; i += 1) {
|
||||
if (current[i] !== normalized[i]) {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
tabOrderByWorkspace: {
|
||||
...state.tabOrderByWorkspace,
|
||||
[key]: normalized,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PurgeWorkspaceInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function applyPurgeWorkspace(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: PurgeWorkspaceInput,
|
||||
): WorkspaceTabsCoreState {
|
||||
const key = buildWorkspaceTabPersistenceKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (!key) {
|
||||
return state;
|
||||
}
|
||||
if (
|
||||
!(key in state.uiTabsByWorkspace) &&
|
||||
!(key in state.tabOrderByWorkspace) &&
|
||||
!(key in state.focusedTabIdByWorkspace)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace;
|
||||
const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace;
|
||||
const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace;
|
||||
return {
|
||||
...state,
|
||||
uiTabsByWorkspace: remainingUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: remainingTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectWorkspaceTabs(
|
||||
state: WorkspaceTabsCoreState,
|
||||
input: { serverId: string; workspaceId: string },
|
||||
): WorkspaceTab[] {
|
||||
const key = buildWorkspaceTabPersistenceKey(input);
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
return state.uiTabsByWorkspace[key] ?? [];
|
||||
}
|
||||
|
||||
interface MigrationRawSources {
|
||||
rawUiTabsByWorkspace: Record<string, unknown>;
|
||||
rawFocused: Record<string, unknown>;
|
||||
rawOrder: Record<string, unknown>;
|
||||
legacyOrder: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
|
||||
const top = toObjectRecord(persistedState) ?? {};
|
||||
const rawState = toObjectRecord(top.state) ?? top;
|
||||
|
||||
return {
|
||||
rawUiTabsByWorkspace:
|
||||
toObjectRecord(
|
||||
rawState.uiTabsByWorkspace ??
|
||||
rawState.openTabsByWorkspace ??
|
||||
top.uiTabsByWorkspace ??
|
||||
top.openTabsByWorkspace,
|
||||
) ?? {},
|
||||
rawFocused:
|
||||
toObjectRecord(
|
||||
rawState.focusedTabIdByWorkspace ??
|
||||
rawState.lastFocusedTabByWorkspace ??
|
||||
top.focusedTabIdByWorkspace,
|
||||
) ?? {},
|
||||
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
|
||||
legacyOrder:
|
||||
toObjectRecord(
|
||||
rawState.tabOrderByWorkspace ??
|
||||
rawState.tabOrderLegacyByWorkspace ??
|
||||
top.tabOrderLegacyByWorkspace,
|
||||
) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||
const kind = typeof raw.kind === "string" ? raw.kind : null;
|
||||
if (kind === "draft" && typeof raw.draftId === "string") {
|
||||
const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "draft",
|
||||
draftId: raw.draftId,
|
||||
...(setup ? { setup } : {}),
|
||||
});
|
||||
}
|
||||
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
||||
}
|
||||
if (
|
||||
kind === "provider_subagent" &&
|
||||
typeof raw.parentAgentId === "string" &&
|
||||
typeof raw.subagentId === "string"
|
||||
) {
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: raw.parentAgentId,
|
||||
subagentId: raw.subagentId,
|
||||
});
|
||||
}
|
||||
if (kind === "terminal" && typeof raw.terminalId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
||||
}
|
||||
if (kind === "browser" && typeof raw.browserId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
|
||||
}
|
||||
if (kind === "file" && typeof raw.path === "string") {
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "file",
|
||||
path: raw.path,
|
||||
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
|
||||
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
|
||||
});
|
||||
}
|
||||
if (kind === "setup" && typeof raw.workspaceId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
|
||||
}
|
||||
return coercePersistedDiffTabTargetByKind(kind, raw);
|
||||
}
|
||||
|
||||
function coercePersistedDiffTabTargetByKind(
|
||||
kind: string | null,
|
||||
raw: Record<string, unknown>,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (kind === "commit_diff" && typeof raw.sha === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha });
|
||||
}
|
||||
return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null;
|
||||
}
|
||||
|
||||
// NOTE: This legacy store no longer persists diff tabs — live tab persistence is
|
||||
// owned by the workspace-layout store, which is where the "commit diff tabs are
|
||||
// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout).
|
||||
// This coercion exists only so this store's migration stays type-complete for any
|
||||
// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff
|
||||
// tabs are dropped because they no longer exist as workspace targets; commit diff
|
||||
// tabs migrate to the dedicated `commit_diff` target shape.
|
||||
function coercePersistedDiffTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
|
||||
const diffTarget = toObjectRecord(raw.diffTarget);
|
||||
if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") {
|
||||
return null;
|
||||
}
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "commit_diff",
|
||||
sha: diffTarget.sha,
|
||||
});
|
||||
}
|
||||
|
||||
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {
|
||||
const record = toObjectRecord(rawTab);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const rawTarget = toObjectRecord(record.target);
|
||||
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
|
||||
if (!normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null);
|
||||
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
const rawCreatedAt = record.createdAt;
|
||||
return {
|
||||
tabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now,
|
||||
};
|
||||
}
|
||||
|
||||
interface MigratedTabsForKey {
|
||||
nextUiTabs: WorkspaceTab[];
|
||||
orderFromTabs: string[];
|
||||
}
|
||||
|
||||
function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey {
|
||||
const entries = Array.isArray(rawEntries) ? rawEntries : [];
|
||||
const nextUiTabs: WorkspaceTab[] = [];
|
||||
const orderFromTabs: string[] = [];
|
||||
const usedOrder = new Set<string>();
|
||||
|
||||
for (const rawTab of entries) {
|
||||
const migrated = migrateSingleTab(rawTab, now);
|
||||
if (!migrated) {
|
||||
continue;
|
||||
}
|
||||
if (!usedOrder.has(migrated.tabId)) {
|
||||
usedOrder.add(migrated.tabId);
|
||||
orderFromTabs.push(migrated.tabId);
|
||||
}
|
||||
nextUiTabs.push(migrated);
|
||||
}
|
||||
|
||||
return { nextUiTabs, orderFromTabs };
|
||||
}
|
||||
|
||||
function mergeExplicitTabOrder(
|
||||
tabOrderByWorkspace: Record<string, string[]>,
|
||||
rawOrder: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key in rawOrder) {
|
||||
const normalizedOrder = normalizeTabOrder(rawOrder[key]);
|
||||
if (normalizedOrder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = tabOrderByWorkspace[key] ?? [];
|
||||
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]);
|
||||
}
|
||||
}
|
||||
|
||||
function convertLegacyOrderEntry(entry: unknown): string | null {
|
||||
const raw = typeof entry === "string" ? entry.trim() : "";
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw.startsWith("agent:")) {
|
||||
const agentId = raw.slice("agent:".length).trim();
|
||||
return agentId ? `agent_${agentId}` : null;
|
||||
}
|
||||
if (raw.startsWith("terminal:")) {
|
||||
const terminalId = raw.slice("terminal:".length).trim();
|
||||
return terminalId ? `terminal_${terminalId}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeLegacyOrderList(list: unknown[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of list) {
|
||||
const converted = convertLegacyOrderEntry(entry);
|
||||
if (converted) {
|
||||
result.push(converted);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeLegacyTabOrder(
|
||||
tabOrderByWorkspace: Record<string, string[]>,
|
||||
legacyOrder: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key in legacyOrder) {
|
||||
const list = legacyOrder[key];
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedLegacyOrder = normalizeLegacyOrderList(list);
|
||||
if (normalizedLegacyOrder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = tabOrderByWorkspace[key] ?? [];
|
||||
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFocusedTabId(rawValue: unknown): string | null {
|
||||
if (typeof rawValue === "string") {
|
||||
return trimNonEmpty(rawValue);
|
||||
}
|
||||
if (!rawValue || typeof rawValue !== "object") {
|
||||
return null;
|
||||
}
|
||||
const value = rawValue as {
|
||||
kind?: string;
|
||||
agentId?: string;
|
||||
terminalId?: string;
|
||||
draftId?: string;
|
||||
};
|
||||
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
||||
return `agent_${value.agentId.trim()}`;
|
||||
}
|
||||
if (
|
||||
value.kind === "terminal" &&
|
||||
typeof value.terminalId === "string" &&
|
||||
value.terminalId.trim()
|
||||
) {
|
||||
return `terminal_${value.terminalId.trim()}`;
|
||||
}
|
||||
if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) {
|
||||
return value.draftId.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function migrateFocusedTabIds(
|
||||
focusedTabIdByWorkspace: Record<string, string>,
|
||||
rawFocused: Record<string, unknown>,
|
||||
): void {
|
||||
for (const key in rawFocused) {
|
||||
const resolved = resolveFocusedTabId(rawFocused[key]);
|
||||
if (resolved) {
|
||||
focusedTabIdByWorkspace[key] = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateWorkspaceTabsState(
|
||||
persistedState: unknown,
|
||||
options: { now: number },
|
||||
): WorkspaceTabsCoreState {
|
||||
const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } =
|
||||
extractMigrationRawSources(persistedState);
|
||||
|
||||
const uiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
const tabOrderByWorkspace: Record<string, string[]> = {};
|
||||
const focusedTabIdByWorkspace: Record<string, string> = {};
|
||||
|
||||
for (const key in rawUiTabsByWorkspace) {
|
||||
const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(
|
||||
rawUiTabsByWorkspace[key],
|
||||
options.now,
|
||||
);
|
||||
if (nextUiTabs.length > 0) {
|
||||
uiTabsByWorkspace[key] = nextUiTabs;
|
||||
}
|
||||
if (orderFromTabs.length > 0) {
|
||||
tabOrderByWorkspace[key] = orderFromTabs;
|
||||
}
|
||||
}
|
||||
|
||||
mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder);
|
||||
mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder);
|
||||
migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused);
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace,
|
||||
tabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function partializeWorkspaceTabsState(
|
||||
state: WorkspaceTabsCoreState,
|
||||
options: { now: number },
|
||||
): WorkspaceTabsCoreState {
|
||||
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||
.map((tab) => {
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||
if (!normalizedTarget || !normalizedTabId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now,
|
||||
} satisfies WorkspaceTab;
|
||||
})
|
||||
.filter((tab): tab is WorkspaceTab => tab !== null);
|
||||
if (tabs.length > 0) {
|
||||
nextUiTabsByWorkspace[key] = tabs;
|
||||
}
|
||||
}
|
||||
|
||||
const nextTabOrderByWorkspace: Record<string, string[]> = {};
|
||||
for (const key in state.tabOrderByWorkspace) {
|
||||
const order = normalizeTabOrder(state.tabOrderByWorkspace[key]);
|
||||
if (order.length > 0) {
|
||||
nextTabOrderByWorkspace[key] = order;
|
||||
}
|
||||
}
|
||||
|
||||
const nextFocusedTabIdByWorkspace: Record<string, string> = {};
|
||||
for (const key in state.focusedTabIdByWorkspace) {
|
||||
const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
|
||||
if (focusedTabId) {
|
||||
nextFocusedTabIdByWorkspace[key] = focusedTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiTabsByWorkspace: nextUiTabsByWorkspace,
|
||||
tabOrderByWorkspace: nextTabOrderByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user