feat: canonical workspace tab IDs with URL routing and draft agent tabs
Rewrite workspace tab management to use stable tab IDs (agent_, terminal_, draft_, file_) with dedicated /tab/[tabId] routes, replace separate file-tabs store with unified workspace-tabs store, support multiple concurrent draft agent tabs, and detect text files by content instead of extension allowlist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
97f0f4b266
commit
73eabd2eea
@@ -3,7 +3,7 @@ import { useCreateFlowStore } from "./create-flow-store";
|
||||
|
||||
describe("create-flow-store", () => {
|
||||
beforeEach(() => {
|
||||
useCreateFlowStore.setState({ pending: null });
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
});
|
||||
|
||||
it("tracks lifecycle transitions explicitly", () => {
|
||||
@@ -18,10 +18,10 @@ describe("create-flow-store", () => {
|
||||
images: [],
|
||||
});
|
||||
|
||||
expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("active");
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]?.lifecycle).toBe("active");
|
||||
|
||||
useCreateFlowStore.getState().markLifecycle("abandoned");
|
||||
expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("abandoned");
|
||||
useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "abandoned" });
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]?.lifecycle).toBe("abandoned");
|
||||
});
|
||||
|
||||
it("rekeys draft id idempotently", () => {
|
||||
@@ -44,6 +44,31 @@ describe("create-flow-store", () => {
|
||||
toDraftId: "draft-b",
|
||||
});
|
||||
|
||||
expect(useCreateFlowStore.getState().pending?.draftId).toBe("draft-b");
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId["draft-b"]?.draftId).toBe("draft-b");
|
||||
});
|
||||
|
||||
it("supports multiple pending attempts", () => {
|
||||
useCreateFlowStore.getState().setPending({
|
||||
draftId: "draft-1",
|
||||
serverId: "server-1",
|
||||
agentId: null,
|
||||
clientMessageId: "msg-1",
|
||||
text: "one",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
useCreateFlowStore.getState().setPending({
|
||||
draftId: "draft-2",
|
||||
serverId: "server-1",
|
||||
agentId: null,
|
||||
clientMessageId: "msg-2",
|
||||
text: "two",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const state = useCreateFlowStore.getState();
|
||||
expect(Object.keys(state.pendingByDraftId).sort()).toEqual(["draft-1", "draft-2"]);
|
||||
|
||||
useCreateFlowStore.getState().clear({ draftId: "draft-1" });
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,63 +15,82 @@ type PendingCreateAttempt = {
|
||||
};
|
||||
|
||||
type CreateFlowState = {
|
||||
pending: PendingCreateAttempt | null;
|
||||
pendingByDraftId: Record<string, PendingCreateAttempt>;
|
||||
setPending: (
|
||||
pending: Omit<PendingCreateAttempt, "lifecycle">
|
||||
) => void;
|
||||
updateAgentId: (agentId: string) => void;
|
||||
markLifecycle: (lifecycle: CreateFlowLifecycleState) => void;
|
||||
updateAgentId: (input: { draftId: string; agentId: string }) => void;
|
||||
markLifecycle: (input: {
|
||||
draftId: string;
|
||||
lifecycle: CreateFlowLifecycleState;
|
||||
}) => void;
|
||||
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
|
||||
clear: () => void;
|
||||
clear: (input: { draftId: string }) => void;
|
||||
clearAll: () => void;
|
||||
};
|
||||
|
||||
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
||||
pending: null,
|
||||
pendingByDraftId: {},
|
||||
setPending: (pending) =>
|
||||
set({
|
||||
pending: {
|
||||
...pending,
|
||||
lifecycle: "active",
|
||||
set((state) => ({
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[pending.draftId]: {
|
||||
...pending,
|
||||
lifecycle: "active",
|
||||
},
|
||||
},
|
||||
}),
|
||||
updateAgentId: (agentId) =>
|
||||
})),
|
||||
updateAgentId: ({ draftId, agentId }) =>
|
||||
set((state) => {
|
||||
if (!state.pending || state.pending.agentId === agentId) {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.agentId === agentId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
pending: {
|
||||
...state.pending,
|
||||
agentId,
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, agentId },
|
||||
},
|
||||
};
|
||||
}),
|
||||
markLifecycle: (lifecycle) =>
|
||||
markLifecycle: ({ draftId, lifecycle }) =>
|
||||
set((state) => {
|
||||
if (!state.pending || state.pending.lifecycle === lifecycle) {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.lifecycle === lifecycle) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
pending: {
|
||||
...state.pending,
|
||||
lifecycle,
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, lifecycle },
|
||||
},
|
||||
};
|
||||
}),
|
||||
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
||||
set((state) => {
|
||||
if (!state.pending || state.pending.draftId !== fromDraftId) {
|
||||
const current = state.pendingByDraftId[fromDraftId];
|
||||
if (!current) {
|
||||
return state;
|
||||
}
|
||||
if (state.pending.draftId === toDraftId) {
|
||||
if (fromDraftId === toDraftId) {
|
||||
return state;
|
||||
}
|
||||
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return {
|
||||
pending: {
|
||||
...state.pending,
|
||||
draftId: toDraftId,
|
||||
pendingByDraftId: {
|
||||
...rest,
|
||||
[toDraftId]: { ...current, draftId: toDraftId },
|
||||
},
|
||||
};
|
||||
}),
|
||||
clear: () => set({ pending: null }),
|
||||
clear: ({ draftId }) =>
|
||||
set((state) => {
|
||||
if (!state.pendingByDraftId[draftId]) {
|
||||
return state;
|
||||
}
|
||||
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return { pendingByDraftId: rest };
|
||||
}),
|
||||
clearAll: () => set({ pendingByDraftId: {} }),
|
||||
}));
|
||||
|
||||
@@ -251,8 +251,11 @@ async function runAttachmentGc(): Promise<void> {
|
||||
import("@/stores/session-store"),
|
||||
]);
|
||||
|
||||
const pendingCreate = useCreateFlowStore.getState().pending;
|
||||
if (pendingCreate?.lifecycle === "active" && pendingCreate.images) {
|
||||
const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
|
||||
for (const pendingCreate of Object.values(pendingByDraftId)) {
|
||||
if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) {
|
||||
continue;
|
||||
}
|
||||
for (const image of pendingCreate.images) {
|
||||
referencedIds.add(image.id);
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "./workspace-tabs-store";
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeFilePath(value: string | null | undefined): string | null {
|
||||
const trimmed = trimNonEmpty(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
type WorkspaceFileTabsState = {
|
||||
openFilePathsByWorkspace: Record<string, string[]>;
|
||||
openFileTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
filePath: string;
|
||||
}) => void;
|
||||
closeFileTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
filePath: string;
|
||||
}) => void;
|
||||
isFileTabOpen: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
filePath: string;
|
||||
}) => boolean;
|
||||
};
|
||||
|
||||
export const useWorkspaceFileTabsStore = create<WorkspaceFileTabsState>()(
|
||||
(set, get) => ({
|
||||
openFilePathsByWorkspace: {},
|
||||
openFileTab: ({ serverId, workspaceId, filePath }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
if (!key || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const current = state.openFilePathsByWorkspace[key] ?? [];
|
||||
if (current.includes(normalizedFilePath)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
openFilePathsByWorkspace: {
|
||||
...state.openFilePathsByWorkspace,
|
||||
[key]: [...current, normalizedFilePath],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
closeFileTab: ({ serverId, workspaceId, filePath }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
if (!key || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const current = state.openFilePathsByWorkspace[key] ?? [];
|
||||
if (current.length === 0 || !current.includes(normalizedFilePath)) {
|
||||
return state;
|
||||
}
|
||||
const next = current.filter((path) => path !== normalizedFilePath);
|
||||
if (next.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.openFilePathsByWorkspace;
|
||||
return { openFilePathsByWorkspace: rest };
|
||||
}
|
||||
return {
|
||||
openFilePathsByWorkspace: {
|
||||
...state.openFilePathsByWorkspace,
|
||||
[key]: next,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
isFileTabOpen: ({ serverId, workspaceId, filePath }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
if (!key || !normalizedFilePath) {
|
||||
return false;
|
||||
}
|
||||
const current = get().openFilePathsByWorkspace[key] ?? [];
|
||||
return current.includes(normalizedFilePath);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3,25 +3,16 @@ import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const rawKey of keys) {
|
||||
const key = rawKey.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
export type WorkspaceTab = {
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
@@ -31,31 +22,8 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceTab(
|
||||
value: WorkspaceTabTarget | null | undefined
|
||||
): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.kind === "agent") {
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "agent", agentId };
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
if (!terminalId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "terminal", terminalId };
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
// File tabs are session-only; do not persist in workspace tab memory.
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
function normalizeWorkspaceId(value: string): string {
|
||||
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabPersistenceKey(input: {
|
||||
@@ -67,140 +35,433 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return `${serverId}:${workspaceId}`;
|
||||
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
|
||||
}
|
||||
|
||||
function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.kind === "draft") {
|
||||
const draftId = trimNonEmpty(value.draftId);
|
||||
return draftId ? { kind: "draft", draftId } : null;
|
||||
}
|
||||
if (value.kind === "agent") {
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
// File tabs are session-only, so stable IDs are still useful for in-session routing.
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
function targetsEqual(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;
|
||||
}
|
||||
|
||||
type WorkspaceTabsState = {
|
||||
lastFocusedTabByWorkspace: Record<string, WorkspaceTabTarget>;
|
||||
tabOrderByWorkspace: Record<string, string[]>;
|
||||
setLastFocusedTab: (input: {
|
||||
openTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||
focusedTabIdByWorkspace: Record<string, string>;
|
||||
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
||||
seedWorkspaceTabs: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
tab: WorkspaceTabTarget;
|
||||
targets: WorkspaceTabTarget[];
|
||||
focusedTabId?: string | null;
|
||||
}) => void;
|
||||
getLastFocusedTab: (input: {
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) => WorkspaceTabTarget | null;
|
||||
setTabOrder: (input: {
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||
replaceTabTarget: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
keys: string[];
|
||||
tabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => void;
|
||||
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||
};
|
||||
|
||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
lastFocusedTabByWorkspace: {},
|
||||
tabOrderByWorkspace: {},
|
||||
setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
|
||||
openTabsByWorkspace: {},
|
||||
focusedTabIdByWorkspace: {},
|
||||
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTab = normalizeWorkspaceTab(tab);
|
||||
if (!key || !normalizedTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const current = state.lastFocusedTabByWorkspace[key];
|
||||
if (
|
||||
current &&
|
||||
current.kind === normalizedTab.kind &&
|
||||
((current.kind === "agent" && normalizedTab.kind === "agent"
|
||||
? current.agentId === normalizedTab.agentId
|
||||
: current.kind === "terminal" && normalizedTab.kind === "terminal"
|
||||
? current.terminalId === normalizedTab.terminalId
|
||||
: false))
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
lastFocusedTabByWorkspace: {
|
||||
...state.lastFocusedTabByWorkspace,
|
||||
[key]: normalizedTab,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
getLastFocusedTab: ({ serverId, workspaceId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
const normalizedDraftId = trimNonEmpty(draftId);
|
||||
if (!key || !normalizedDraftId) {
|
||||
return null;
|
||||
}
|
||||
const value = get().lastFocusedTabByWorkspace[key];
|
||||
return normalizeWorkspaceTab(value);
|
||||
|
||||
const target: WorkspaceTabTarget = { kind: "draft", draftId: normalizedDraftId };
|
||||
const tabId = buildDeterministicTabId(target);
|
||||
const now = Date.now();
|
||||
set((state) => {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
if (current.some((tab) => tab.tabId === tabId)) {
|
||||
return {
|
||||
...state,
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
const nextTabs: WorkspaceTab[] = [...current, { tabId, target, createdAt: now }];
|
||||
return {
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
||||
};
|
||||
});
|
||||
return tabId;
|
||||
},
|
||||
setTabOrder: ({ serverId, workspaceId, keys }) => {
|
||||
seedWorkspaceTabs: ({ serverId, workspaceId, targets, focusedTabId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeKeys(keys);
|
||||
const now = Date.now();
|
||||
const nextTabs: WorkspaceTab[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const raw of targets) {
|
||||
const normalized = normalizeTabTarget(raw);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const tabId = buildDeterministicTabId(normalized);
|
||||
if (used.has(tabId)) {
|
||||
continue;
|
||||
}
|
||||
used.add(tabId);
|
||||
nextTabs.push({ tabId, target: normalized, createdAt: now });
|
||||
}
|
||||
if (nextTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const current = state.tabOrderByWorkspace[key] ?? [];
|
||||
if (current.length === normalized.length) {
|
||||
let isSame = true;
|
||||
for (let index = 0; index < current.length; index += 1) {
|
||||
if (current[index] !== normalized[index]) {
|
||||
isSame = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isSame) {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
if (current.length > 0) {
|
||||
return state;
|
||||
}
|
||||
const resolvedFocus = trimNonEmpty(focusedTabId) ?? nextTabs[0]?.tabId ?? null;
|
||||
return {
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||
...(resolvedFocus
|
||||
? {
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: resolvedFocus,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
const tabId = buildDeterministicTabId(normalizedTarget);
|
||||
const now = Date.now();
|
||||
set((state) => {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
const existing = current.find((tab) => tab.tabId === tabId);
|
||||
if (existing) {
|
||||
if (state.focusedTabIdByWorkspace[key] === tabId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: tabId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextTabs: WorkspaceTab[] = [...current, { tabId, target: normalizedTarget, createdAt: now }];
|
||||
return {
|
||||
tabOrderByWorkspace: {
|
||||
...state.tabOrderByWorkspace,
|
||||
[key]: normalized,
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
||||
};
|
||||
});
|
||||
return tabId;
|
||||
},
|
||||
focusTab: ({ serverId, workspaceId, tabId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!key || !normalizedTabId) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
focusedTabIdByWorkspace: {
|
||||
...state.focusedTabIdByWorkspace,
|
||||
[key]: normalizedTabId,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
closeTab: ({ serverId, workspaceId, tabId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!key || !normalizedTabId) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const currentTabs = state.openTabsByWorkspace[key] ?? [];
|
||||
if (currentTabs.length === 0 || !currentTabs.some((tab) => tab.tabId === normalizedTabId)) {
|
||||
return state;
|
||||
}
|
||||
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
|
||||
const nextOpenTabsByWorkspace =
|
||||
nextTabs.length === 0
|
||||
? (() => {
|
||||
const { [key]: _removed, ...rest } = state.openTabsByWorkspace;
|
||||
return rest;
|
||||
})()
|
||||
: { ...state.openTabsByWorkspace, [key]: nextTabs };
|
||||
|
||||
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
|
||||
const nextFocused =
|
||||
currentFocused !== normalizedTabId
|
||||
? currentFocused
|
||||
: nextTabs[nextTabs.length - 1]?.tabId ?? null;
|
||||
const nextFocusedByWorkspace = (() => {
|
||||
if (!nextFocused) {
|
||||
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
||||
return rest;
|
||||
}
|
||||
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
||||
})();
|
||||
|
||||
return {
|
||||
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
||||
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||
};
|
||||
});
|
||||
},
|
||||
reorderTabs: ({ serverId, workspaceId, tabIds }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const normalized = tabIds.map((id) => id.trim()).filter(Boolean);
|
||||
set((state) => {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
if (current.length <= 1) {
|
||||
return state;
|
||||
}
|
||||
const byId = new Map(current.map((tab) => [tab.tabId, tab]));
|
||||
const used = new Set<string>();
|
||||
const next: WorkspaceTab[] = [];
|
||||
for (const id of normalized) {
|
||||
const tab = byId.get(id);
|
||||
if (!tab || used.has(id)) {
|
||||
continue;
|
||||
}
|
||||
used.add(id);
|
||||
next.push(tab);
|
||||
}
|
||||
for (const tab of current) {
|
||||
if (used.has(tab.tabId)) {
|
||||
continue;
|
||||
}
|
||||
next.push(tab);
|
||||
}
|
||||
if (next.length !== current.length) {
|
||||
return state;
|
||||
}
|
||||
let same = true;
|
||||
for (let i = 0; i < next.length; i += 1) {
|
||||
if (next[i]?.tabId !== current[i]?.tabId) {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: next },
|
||||
};
|
||||
});
|
||||
},
|
||||
replaceTabTarget: ({ serverId, workspaceId, tabId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const current = state.openTabsByWorkspace[key] ?? [];
|
||||
const idx = current.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
const existing = current[idx];
|
||||
if (existing && targetsEqual(existing.target, normalizedTarget)) {
|
||||
return state;
|
||||
}
|
||||
const nextTabs = [...current];
|
||||
nextTabs[idx] = {
|
||||
tabId: normalizedTabId,
|
||||
target: normalizedTarget,
|
||||
createdAt: existing?.createdAt ?? Date.now(),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||
};
|
||||
});
|
||||
},
|
||||
getWorkspaceTabs: ({ serverId, workspaceId }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
return get().openTabsByWorkspace[key] ?? [];
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "workspace-tabs-state",
|
||||
version: 2,
|
||||
version: 3,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => {
|
||||
const nextOpenTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
for (const key in state.openTabsByWorkspace) {
|
||||
const tabs = state.openTabsByWorkspace[key] ?? [];
|
||||
const persistedTabs = tabs.filter((tab) => tab.target.kind !== "file");
|
||||
if (persistedTabs.length > 0) {
|
||||
nextOpenTabsByWorkspace[key] = persistedTabs;
|
||||
}
|
||||
}
|
||||
return {
|
||||
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
||||
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
|
||||
} as WorkspaceTabsState;
|
||||
},
|
||||
migrate: (persistedState) => {
|
||||
const state = persistedState as
|
||||
// Previous versions stored tab order + last focused tab by workspace using agent/terminal targets.
|
||||
const legacy = persistedState as
|
||||
| {
|
||||
lastFocusedTabByWorkspace?: Record<string, WorkspaceTabTarget>;
|
||||
version?: number;
|
||||
state?: any;
|
||||
lastFocusedTabByWorkspace?: Record<string, any>;
|
||||
tabOrderByWorkspace?: Record<string, string[]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const raw = state?.lastFocusedTabByWorkspace ?? {};
|
||||
const next: Record<string, WorkspaceTabTarget> = {};
|
||||
const rawState = (legacy as any)?.state ?? legacy ?? {};
|
||||
const legacyFocused = rawState.lastFocusedTabByWorkspace ?? {};
|
||||
const legacyOrder = rawState.tabOrderByWorkspace ?? {};
|
||||
|
||||
for (const key in raw) {
|
||||
const value = raw[key];
|
||||
const normalized = normalizeWorkspaceTab(value);
|
||||
if (normalized) {
|
||||
next[key] = normalized;
|
||||
const openTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||
const focusedTabIdByWorkspace: Record<string, string> = {};
|
||||
|
||||
for (const key in legacyOrder) {
|
||||
const list = legacyOrder[key];
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const now = Date.now();
|
||||
const tabs: WorkspaceTab[] = [];
|
||||
for (const entry of list) {
|
||||
const raw = typeof entry === "string" ? entry.trim() : "";
|
||||
if (!raw) continue;
|
||||
if (raw.startsWith("agent:")) {
|
||||
const agentId = raw.slice("agent:".length).trim();
|
||||
if (!agentId) continue;
|
||||
tabs.push({
|
||||
tabId: `agent_${agentId}`,
|
||||
target: { kind: "agent", agentId },
|
||||
createdAt: now,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (raw.startsWith("terminal:")) {
|
||||
const terminalId = raw.slice("terminal:".length).trim();
|
||||
if (!terminalId) continue;
|
||||
tabs.push({
|
||||
tabId: `terminal_${terminalId}`,
|
||||
target: { kind: "terminal", terminalId },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (tabs.length > 0) {
|
||||
openTabsByWorkspace[key] = tabs;
|
||||
}
|
||||
}
|
||||
|
||||
const rawOrder = state?.tabOrderByWorkspace ?? {};
|
||||
const nextOrder: Record<string, string[]> = {};
|
||||
for (const key in rawOrder) {
|
||||
const list = rawOrder[key];
|
||||
if (!Array.isArray(list)) {
|
||||
for (const key in legacyFocused) {
|
||||
const value = legacyFocused[key];
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeKeys(list.map((value) => String(value)));
|
||||
if (normalized.length > 0) {
|
||||
nextOrder[key] = normalized;
|
||||
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
||||
focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`;
|
||||
}
|
||||
if (
|
||||
value.kind === "terminal" &&
|
||||
typeof value.terminalId === "string" &&
|
||||
value.terminalId.trim()
|
||||
) {
|
||||
focusedTabIdByWorkspace[key] = `terminal_${value.terminalId.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
lastFocusedTabByWorkspace: next,
|
||||
tabOrderByWorkspace: nextOrder,
|
||||
openTabsByWorkspace,
|
||||
focusedTabIdByWorkspace,
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user