feat: add sqlite storage and terminal ui

This commit is contained in:
Mohamed Boudra
2026-03-29 23:20:18 +07:00
parent e5fd61f131
commit 7d02e24d84
189 changed files with 17868 additions and 4663 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
const DRAFT_STORE_VERSION = 2;
const DRAFT_STORE_VERSION = 4;
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
type LegacyDraftImage = {
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import {
__providerRecencyStoreTestUtils,
sortProvidersByRecency,
useProviderRecencyStore,
} from "./provider-recency-store";
describe("provider-recency-store", () => {
beforeEach(() => {
useProviderRecencyStore.setState({
recentProviderIds: [],
recordUsage: useProviderRecencyStore.getState().recordUsage,
});
});
it("sorts used providers first and keeps unused providers in default order", () => {
const sorted = sortProvidersByRecency(AGENT_PROVIDER_DEFINITIONS, ["codex"]);
expect(sorted.map((provider) => provider.id)).toEqual(["codex", "claude", "opencode"]);
});
it("moves the latest provider to the front without duplicating prior entries", () => {
useProviderRecencyStore.getState().recordUsage("codex");
useProviderRecencyStore.getState().recordUsage("opencode");
useProviderRecencyStore.getState().recordUsage("codex");
expect(useProviderRecencyStore.getState().recentProviderIds).toEqual([
"codex",
"opencode",
]);
});
it("filters invalid and duplicate providers during migration", () => {
expect(
__providerRecencyStoreTestUtils.migratePersistedState({
recentProviderIds: ["codex", "invalid", "codex", "claude"],
}),
).toEqual({
recentProviderIds: ["codex", "claude"],
});
});
});
@@ -0,0 +1,129 @@
import { useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import {
AGENT_PROVIDER_DEFINITIONS,
isValidAgentProvider,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
const PROVIDER_RECENCY_STORE_VERSION = 1;
interface ProviderRecencyStoreState {
recentProviderIds: AgentProvider[];
recordUsage: (providerId: AgentProvider) => void;
}
function sanitizeRecentProviderIds(providerIds: readonly string[] | undefined): AgentProvider[] {
if (!providerIds || providerIds.length === 0) {
return [];
}
const seen = new Set<AgentProvider>();
const sanitized: AgentProvider[] = [];
for (const providerId of providerIds) {
if (!isValidAgentProvider(providerId)) {
continue;
}
if (seen.has(providerId)) {
continue;
}
seen.add(providerId);
sanitized.push(providerId);
}
return sanitized;
}
export function sortProvidersByRecency<T extends { id: string }>(
providers: readonly T[],
recentProviderIds: readonly string[],
): T[] {
if (providers.length <= 1) {
return [...providers];
}
const recentRank = new Map<string, number>();
for (const providerId of recentProviderIds) {
if (recentRank.has(providerId)) {
continue;
}
recentRank.set(providerId, recentRank.size);
}
return providers
.map((provider, defaultIndex) => ({
provider,
defaultIndex,
recentIndex: recentRank.get(provider.id) ?? Number.POSITIVE_INFINITY,
}))
.sort((left, right) => {
if (left.recentIndex !== right.recentIndex) {
return left.recentIndex - right.recentIndex;
}
return left.defaultIndex - right.defaultIndex;
})
.map((entry) => entry.provider);
}
function migratePersistedState(state: unknown): Pick<ProviderRecencyStoreState, "recentProviderIds"> {
const record = state as { recentProviderIds?: string[] } | null | undefined;
return {
recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds),
};
}
export const useProviderRecencyStore = create<ProviderRecencyStoreState>()(
persist(
(set) => ({
recentProviderIds: [],
recordUsage: (providerId) => {
if (!isValidAgentProvider(providerId)) {
return;
}
set((state) => ({
recentProviderIds: [
providerId,
...state.recentProviderIds.filter((id) => id !== providerId),
],
}));
},
}),
{
name: "terminal-agent-provider-recency",
version: PROVIDER_RECENCY_STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
recentProviderIds: state.recentProviderIds,
}),
migrate: (persistedState) => migratePersistedState(persistedState),
},
),
);
export function useProviderRecency(
availableProviders: readonly AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS,
): {
providers: AgentProviderDefinition[];
recordUsage: (providerId: AgentProvider) => void;
} {
const recentProviderIds = useProviderRecencyStore((state) => state.recentProviderIds);
const recordUsage = useProviderRecencyStore((state) => state.recordUsage);
const providers = useMemo(
() => sortProvidersByRecency(availableProviders, recentProviderIds),
[availableProviders, recentProviderIds],
);
return {
providers,
recordUsage,
};
}
export const __providerRecencyStoreTestUtils = {
migratePersistedState,
sanitizeRecentProviderIds,
};
+8 -3
View File
@@ -22,6 +22,7 @@ import type {
GitSetupOptions,
ProjectPlacementPayload,
ServerCapabilities,
AgentSnapshotPayload,
WorkspaceDescriptorPayload,
} from "@server/shared/messages";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
@@ -79,10 +80,13 @@ export interface AgentRuntimeInfo {
extra?: Record<string, unknown>;
}
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
export interface Agent {
serverId: string;
id: string;
provider: AgentProvider;
terminal: boolean;
status: AgentLifecycleStatus;
createdAt: Date;
updatedAt: Date;
@@ -96,6 +100,7 @@ export interface Agent {
runtimeInfo?: AgentRuntimeInfo;
lastUsage?: AgentUsage;
lastError?: string | null;
terminalExit?: TerminalExitDetails | null;
title: string | null;
cwd: string;
model: string | null;
@@ -126,8 +131,8 @@ export function normalizeWorkspaceDescriptor(
): WorkspaceDescriptor {
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
return {
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
projectId: payload.projectId,
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id),
projectId: String(payload.projectId),
projectDisplayName: payload.projectDisplayName,
projectRootPath: payload.projectRootPath,
projectKind: payload.projectKind,
@@ -191,7 +196,6 @@ export type DaemonServerInfo = {
};
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
endSeq: number;
}
@@ -1119,6 +1123,7 @@ export const useSessionStore = create<SessionStore>()(
id: agent.id,
serverId,
title: agent.title ?? null,
terminal: agent.terminal,
status: agent.status,
lastActivityAt,
cwd: agent.cwd,
@@ -0,0 +1,52 @@
import { create } from "zustand";
interface BuildTerminalAgentReopenKeyInput {
serverId: string;
agentId: string;
}
interface RequestTerminalAgentReopenInput {
serverId: string;
agentId: string;
}
interface TerminalAgentReopenStore {
reopenIntentVersionByAgentKey: Record<string, number>;
requestReopen: (input: RequestTerminalAgentReopenInput) => void;
}
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 buildTerminalAgentReopenKey(
input: BuildTerminalAgentReopenKeyInput,
): string | null {
const serverId = trimNonEmpty(input.serverId);
const agentId = trimNonEmpty(input.agentId);
if (!serverId || !agentId) {
return null;
}
return `${serverId}:${agentId}`;
}
export const useTerminalAgentReopenStore = create<TerminalAgentReopenStore>()((set) => ({
reopenIntentVersionByAgentKey: {},
requestReopen: ({ serverId, agentId }) => {
const key = buildTerminalAgentReopenKey({ serverId, agentId });
if (!key) {
return;
}
set((state) => ({
reopenIntentVersionByAgentKey: {
...state.reopenIntentVersionByAgentKey,
[key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1,
},
}));
},
}));
@@ -2,6 +2,7 @@ import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import {
buildDeterministicWorkspaceTabId,
createLauncherId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
@@ -110,6 +111,11 @@ interface OpenTabInLayoutResult {
tabId: string;
}
interface OpenLauncherTabInLayoutInput {
layout: WorkspaceLayout;
now: number;
}
interface RetargetTabInLayoutInput {
layout: WorkspaceLayout;
tabId: string;
@@ -121,6 +127,17 @@ interface RetargetTabInLayoutResult {
tabId: string;
}
interface ConvertDraftToAgentInLayoutInput {
layout: WorkspaceLayout;
tabId: string;
agentId: string;
}
interface ConvertDraftToAgentInLayoutResult {
layout: WorkspaceLayout;
tabId: string;
}
interface ReorderFocusedPaneTabsInLayoutInput {
layout: WorkspaceLayout;
tabIds: string[];
@@ -181,6 +198,20 @@ interface ReorderPaneTabsInLayoutInput {
tabIds: string[];
}
export interface WorkspaceTabReconcileState {
layout: WorkspaceLayout;
pinnedAgentIds?: ReadonlySet<string> | null;
}
export interface WorkspaceTabSnapshot {
agentsHydrated: boolean;
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
knownAgentIds: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingDraftCreate?: boolean;
}
const DEFAULT_PANE_ID = "main";
const MIN_SPLIT_SIZE = 0.1;
@@ -750,6 +781,37 @@ function updateTabInTree(root: SplitNodeInternal, input: UpdateTabInTreeInput):
});
}
function replaceTabInTree(
root: SplitNodeInternal,
input: {
tabId: string;
nextTabId: string;
target: WorkspaceTabTarget;
},
): SplitNodeInternal {
const panePath = findPanePathContainingTab(root, input.tabId);
invariant(panePath, `Tab not found: ${input.tabId}`);
return replaceNodeAtPath(root, panePath, (node) => {
invariant(node.kind === "pane", "Expected pane while replacing tab");
return {
kind: "pane",
pane: normalizePaneAfterTabChange({
...node.pane,
tabs: node.pane.tabs.map((tab) =>
tab.tabId === input.tabId
? {
...tab,
tabId: input.nextTabId,
target: input.target,
}
: tab,
),
focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
}),
};
});
}
function updateGroupSizesInTree(
root: SplitNodeInternal,
input: UpdateGroupSizesInTreeInput,
@@ -957,22 +1019,12 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
return detachTabFromTree(asInternalNode(root), { tabId }).root;
}
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
function insertNewTabIntoFocusedPane(input: {
layout: WorkspaceLayout;
target: WorkspaceTabTarget;
now: number;
}): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
const existingTab = collectAllTabs(layout.root).find((tab) =>
workspaceTabTargetsEqual(tab.target, input.target),
);
if (existingTab) {
return {
tabId: existingTab.tabId,
layout:
focusTabInLayout({
layout,
tabId: existingTab.tabId,
}) ?? input.layout,
};
}
const focusedPane =
findPaneById(layout.root, layout.focusedPaneId) ??
collectAllPanes(layout.root)[0] ??
@@ -999,6 +1051,38 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes
};
}
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
const layout = asInternalLayout(input.layout);
const existingTab = collectAllTabs(layout.root).find((tab) =>
workspaceTabTargetsEqual(tab.target, input.target),
);
if (existingTab) {
return {
tabId: existingTab.tabId,
layout:
focusTabInLayout({
layout,
tabId: existingTab.tabId,
}) ?? input.layout,
};
}
return insertNewTabIntoFocusedPane(input);
}
export function openLauncherTabInLayout(
input: OpenLauncherTabInLayoutInput,
): OpenTabInLayoutResult {
return insertNewTabIntoFocusedPane({
layout: input.layout,
target: {
kind: "launcher",
launcherId: createLauncherId(),
},
now: input.now,
});
}
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
const internalLayout = asInternalLayout(input.layout);
const pane = findPaneContainingTab(internalLayout.root, input.tabId);
@@ -1054,11 +1138,34 @@ export function retargetTabInLayout(
};
}
const existingTargetTab =
collectAllTabs(layout.root).find(
(tab) => tab.tabId !== input.tabId && workspaceTabTargetsEqual(tab.target, input.target),
) ?? null;
if (existingTargetTab) {
const nextLayout =
closeTabInLayout({
layout: input.layout,
tabId: input.tabId,
}) ?? input.layout;
return {
layout:
focusTabInLayout({
layout: nextLayout,
tabId: existingTargetTab.tabId,
}) ?? nextLayout,
tabId: existingTargetTab.tabId,
};
}
return {
// Preserve the existing tab id so launcher->entity transitions keep the same
// React key during the first render. Reconciliation can canonicalize later.
tabId: input.tabId,
layout: {
root: updateTabInTree(layout.root, {
root: replaceTabInTree(layout.root, {
tabId: input.tabId,
nextTabId: input.tabId,
target: input.target,
}),
focusedPaneId: layout.focusedPaneId,
@@ -1066,6 +1173,52 @@ export function retargetTabInLayout(
};
}
export function convertDraftToAgentInLayout(
input: ConvertDraftToAgentInLayoutInput,
): ConvertDraftToAgentInLayoutResult | null {
const layout = asInternalLayout(input.layout);
const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null;
if (!currentTab || currentTab.target.kind !== "draft") {
return null;
}
const target: WorkspaceTabTarget = {
kind: "agent",
agentId: input.agentId,
};
const canonicalTabId = buildDeterministicWorkspaceTabId(target);
const existingCanonicalTab =
collectAllTabs(layout.root).find((tab) => tab.tabId === canonicalTabId) ?? null;
if (existingCanonicalTab && existingCanonicalTab.tabId !== input.tabId) {
const nextLayout =
closeTabInLayout({
layout: input.layout,
tabId: input.tabId,
}) ?? input.layout;
return {
layout:
focusTabInLayout({
layout: nextLayout,
tabId: canonicalTabId,
}) ?? nextLayout,
tabId: canonicalTabId,
};
}
return {
tabId: canonicalTabId,
layout: {
root: replaceTabInTree(layout.root, {
tabId: input.tabId,
nextTabId: canonicalTabId,
target,
}),
focusedPaneId: layout.focusedPaneId,
},
};
}
export function reorderFocusedPaneTabsInLayout(
input: ReorderFocusedPaneTabsInLayoutInput,
): WorkspaceLayout | null {
@@ -1234,3 +1387,201 @@ export function reorderPaneTabsInLayout(
focusedPaneId: layout.focusedPaneId,
};
}
function normalizeStringSet(values: Iterable<string>): Set<string> {
const next = new Set<string>();
for (const value of values) {
const normalized = trimNonEmpty(value);
if (normalized) {
next.add(normalized);
}
}
return next;
}
function isEntityTarget(
target: WorkspaceTabTarget,
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
return target.kind === "agent" || target.kind === "terminal";
}
function isAgentTab(tab: WorkspaceTab): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } {
return tab.target.kind === "agent";
}
function isTerminalTab(
tab: WorkspaceTab,
): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } {
return tab.target.kind === "terminal";
}
function openEntityTabWithoutFocusing(layout: WorkspaceLayout, target: WorkspaceTabTarget): WorkspaceLayout {
const internalLayout = asInternalLayout(layout);
const focusedPane =
findPaneById(internalLayout.root, internalLayout.focusedPaneId) ??
collectAllPanes(internalLayout.root)[0] ??
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
invariant(focusedPane, "Workspace layout must always have a pane");
const tabId = buildDeterministicWorkspaceTabId(target);
return {
root: insertTabIntoPane(internalLayout.root, {
paneId: focusedPane.id,
tab: {
tabId,
target,
createdAt: Date.now(),
},
focusTabId: focusedPane.focusedTabId ?? tabId,
}),
focusedPaneId: internalLayout.focusedPaneId,
};
}
export function reconcileWorkspaceTabs(
state: WorkspaceTabReconcileState,
snapshot: WorkspaceTabSnapshot,
): WorkspaceTabReconcileState {
let nextLayout = state.layout;
const originalFocusedTabId =
findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null;
let reconciledFocusedTabId = originalFocusedTabId;
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const visibleAgentIds = new Set(activeAgentIds);
for (const agentId of pinnedAgentIds) {
if (knownAgentIds.has(agentId)) {
visibleAgentIds.add(agentId);
}
}
const initialTabs = collectAllTabs(nextLayout.root);
const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId));
const entityGroups = new Map<
string,
{
target: WorkspaceTabTarget;
tabs: WorkspaceTab[];
}
>();
for (const tab of initialTabs) {
if (!isEntityTarget(tab.target)) {
continue;
}
const canonicalTarget = normalizeWorkspaceTabTarget(tab.target);
if (!canonicalTarget) {
continue;
}
const canonicalTabId = buildDeterministicWorkspaceTabId(canonicalTarget);
const currentGroup = entityGroups.get(canonicalTabId);
if (currentGroup) {
currentGroup.tabs.push(tab);
continue;
}
entityGroups.set(canonicalTabId, {
target: canonicalTarget,
tabs: [tab],
});
}
for (const [canonicalTabId, group] of entityGroups) {
const keeper = group.tabs.find((tab) => tab.tabId === canonicalTabId) ?? group.tabs[0] ?? null;
if (!keeper) {
continue;
}
if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) {
reconciledFocusedTabId = canonicalTabId;
}
if (
keeper.tabId !== canonicalTabId ||
!workspaceTabTargetsEqual(keeper.target, group.target)
) {
nextLayout = {
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
tabId: keeper.tabId,
nextTabId: canonicalTabId,
target: group.target,
}),
focusedPaneId: nextLayout.focusedPaneId,
};
}
for (const tab of group.tabs) {
if (tab.tabId === keeper.tabId) {
continue;
}
nextLayout =
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
}) ?? nextLayout;
}
}
for (const tab of collectAllTabs(nextLayout.root)) {
if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) {
nextLayout =
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
}) ?? nextLayout;
}
if (isTerminalTab(tab) && snapshot.terminalsHydrated && !standaloneTerminalIds.has(tab.target.terminalId)) {
nextLayout =
closeTabInLayout({
layout: nextLayout,
tabId: tab.tabId,
}) ?? nextLayout;
}
}
const currentEntityTabs = collectAllTabs(nextLayout.root);
const currentAgentIds = new Set(
currentEntityTabs.filter(isAgentTab).map((tab) => tab.target.agentId),
);
const currentTerminalIds = new Set(
currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId),
);
const sortedVisibleAgentIds = [...visibleAgentIds].sort();
for (const agentId of sortedVisibleAgentIds) {
if (currentAgentIds.has(agentId)) {
continue;
}
if (snapshot.hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) {
continue;
}
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
kind: "agent",
agentId,
});
currentAgentIds.add(agentId);
}
const sortedTerminalIds = [...standaloneTerminalIds].sort();
for (const terminalId of sortedTerminalIds) {
if (currentTerminalIds.has(terminalId)) {
continue;
}
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
kind: "terminal",
terminalId,
});
currentTerminalIds.add(terminalId);
}
if (reconciledFocusedTabId) {
nextLayout =
focusTabInLayout({
layout: nextLayout,
tabId: reconciledFocusedTabId,
}) ?? nextLayout;
}
return {
...state,
layout: nextLayout,
};
}
@@ -262,6 +262,58 @@ describe("workspace-layout-store actions", () => {
]);
});
it("openLauncherTab creates duplicate launcher tabs for repeated Cmd+T/new-tab opens", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openLauncherTab(workspaceKey);
const secondTabId = store.openLauncherTab(workspaceKey);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111");
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222");
expect(firstTabId).not.toBe(secondTabId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]);
expect(collectAllTabs(layout.root)).toEqual([
{
tabId: firstTabId,
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" },
createdAt: expect.any(Number),
},
{
tabId: secondTabId,
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" },
createdAt: expect.any(Number),
},
]);
});
it("splitPaneEmpty plus openLauncherTab opens a launcher tab in the new pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const newPaneId = store.splitPaneEmpty(workspaceKey, {
targetPaneId: "main",
position: "right",
});
const launcherTabId = store.openLauncherTab(workspaceKey);
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
expect(launcherTabId).toMatch(/^launcher_/);
expect(layout.focusedPaneId).toBe(newPaneId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([launcherTabId!]);
expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(launcherTabId);
});
it("focusTab moves workspace focus to the pane containing the tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
@@ -288,7 +340,7 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
});
it("retargetTab updates the existing tab target without moving it to a different pane", () => {
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12121212-1212-1212-1212-121212121212",
);
@@ -296,30 +348,125 @@ describe("workspace-layout-store actions", () => {
const store = useWorkspaceLayoutStore.getState();
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
const secondTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-2" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: secondTabId!,
targetPaneId: "main",
position: "right",
});
const nextTabId = store.retargetTab(workspaceKey, secondTabId!, {
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const splitPane = findPaneById(layout.root, splitPaneId!);
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
expect(nextTabId).toBe("agent_agent-1");
expect(splitPane?.tabIds).toEqual(["agent_agent-1"]);
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
expect(convertedTab).toEqual({
tabId: "agent_agent-1",
target: { kind: "agent", agentId: "agent-1" },
createdAt: expect.any(Number),
});
});
it("retargetTab keeps a launcher tab in place while updating its target", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"33333333-3333-3333-3333-333333333333",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const launcherTabId = store.openLauncherTab(workspaceKey);
const nextTabId = store.retargetTab(workspaceKey, launcherTabId!, {
kind: "file",
path: "/repo/worktree/launcher.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(launcherTabId).toBe("launcher_33333333-3333-3333-3333-333333333333");
expect(nextTabId).toBe(launcherTabId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([launcherTabId!]);
expect(collectAllTabs(layout.root)).toEqual([
{
tabId: launcherTabId!,
target: { kind: "file", path: "/repo/worktree/launcher.ts" },
createdAt: expect.any(Number),
},
]);
});
it("retargetTab closes a launcher tab and focuses the existing canonical target tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const existingFileTabId = store.openTab(workspaceKey, {
kind: "file",
path: "/repo/worktree/existing.ts",
});
const launcherTabId = store.openLauncherTab(workspaceKey);
const splitPaneId = store.splitPane(workspaceKey, {
tabId: launcherTabId!,
targetPaneId: "main",
position: "right",
});
const secondLauncherTabId = store.openLauncherTab(workspaceKey);
const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, {
kind: "file",
path: "/repo/worktree/existing.ts",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
expect(launcherTabId).toBe("launcher_44444444-4444-4444-4444-444444444444");
expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555");
expect(secondLauncherTabId).toMatch(/^launcher_/);
expect(secondLauncherTabId).not.toBe(launcherTabId);
expect(nextTabId).toBe(existingFileTabId);
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
existingFileTabId!,
launcherTabId!,
]);
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId);
});
it("retargetTab closes a launcher tab and focuses an existing matching target tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const firstLauncherTabId = store.openLauncherTab(workspaceKey);
const firstAgentTabId = store.retargetTab(workspaceKey, firstLauncherTabId!, {
kind: "agent",
agentId: "agent-1",
});
const secondLauncherTabId = store.openLauncherTab(workspaceKey);
const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, {
kind: "agent",
agentId: "agent-1",
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const splitPane = findPaneById(layout.root, splitPaneId!);
const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId);
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
expect(nextTabId).toBe(secondTabId);
expect(splitPane?.tabIds).toEqual([secondTabId!]);
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
expect(retargetedTab).toEqual({
tabId: secondTabId,
target: { kind: "agent", agentId: "agent-1" },
createdAt: expect.any(Number),
});
expect(firstAgentTabId).toBe(firstLauncherTabId);
expect(nextTabId).toBe(firstLauncherTabId);
expect(collectAllTabs(layout.root)).toEqual([
{
tabId: firstLauncherTabId!,
target: { kind: "agent", agentId: "agent-1" },
createdAt: expect.any(Number),
},
]);
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstLauncherTabId);
});
it("reorderTabs reorders tabs within the focused pane", () => {
@@ -702,4 +849,102 @@ describe("workspace-layout-store actions", () => {
splitSizesByWorkspace: {},
});
});
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"67676767-6767-6767-6767-676767676767",
);
const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState();
const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-existing" });
const agentTabId = store.openTab(workspaceKey, { kind: "agent", agentId: "agent-1" });
const splitPaneId = store.splitPane(workspaceKey, {
tabId: agentTabId!,
targetPaneId: "main",
position: "right",
});
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
expect(nextTabId).toBe("agent_agent-1");
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
expect(layout.focusedPaneId).toBe(splitPaneId);
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
});
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
const workspaceKey = createWorkspaceKey();
useWorkspaceLayoutStore.setState((state) => ({
...state,
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "pane",
pane: {
id: "main",
tabIds: ["draft_agent", "agent_agent-1", "terminal_orphan", "draft-1"],
focusedTabId: "draft_agent",
tabs: [
{
tabId: "draft_agent",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 1,
},
{
tabId: "agent_agent-1",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 2,
},
{
tabId: "terminal_orphan",
target: { kind: "terminal", terminalId: "term-stale" },
createdAt: 3,
},
{
tabId: "draft-1",
target: { kind: "draft", draftId: "draft-1" },
createdAt: 4,
},
],
} as any,
},
focusedPaneId: "main",
},
},
pinnedAgentIdsByWorkspace: {
[workspaceKey]: new Set<string>(["agent-2"]),
},
}));
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
knownAgentIds: ["agent-1", "agent-2"],
standaloneTerminalIds: ["term-1"],
hasActivePendingDraftCreate: false,
});
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
const tabs = collectAllTabs(layout.root);
expect(tabs.map((tab) => tab.tabId)).toEqual([
"agent_agent-1",
"draft-1",
"agent_agent-2",
"terminal_term-1",
]);
expect(tabs.find((tab) => tab.tabId === "agent_agent-1")).toEqual({
tabId: "agent_agent-1",
target: { kind: "agent", agentId: "agent-1" },
createdAt: 2,
});
expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
});
});
@@ -11,6 +11,7 @@ import {
closeTabInLayout,
collectAllPanes,
collectAllTabs,
convertDraftToAgentInLayout,
createDefaultLayout,
findPaneById,
findPaneContainingTab,
@@ -20,7 +21,9 @@ import {
insertSplit,
moveTabToPaneInLayout,
normalizeLayout,
openLauncherTabInLayout,
openTabInLayout,
reconcileWorkspaceTabs,
removePaneFromTree,
removeTabFromTree,
reorderFocusedPaneTabsInLayout,
@@ -31,6 +34,8 @@ import {
type SplitGroup,
type SplitNode,
type SplitPane,
type WorkspaceTabReconcileState,
type WorkspaceTabSnapshot,
type WorkspaceLayout,
} from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
@@ -48,16 +53,26 @@ export {
removePaneFromTree,
removeTabFromTree,
};
export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout };
export type {
SplitGroup,
SplitNode,
SplitPane,
WorkspaceLayout,
WorkspaceTabReconcileState,
WorkspaceTabSnapshot,
};
interface WorkspaceLayoutStore {
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openLauncherTab: (workspaceKey: string) => string | null;
closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void;
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
splitPane: (
@@ -128,6 +143,26 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
return result.tabId;
},
openLauncherTab: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return null;
}
const result = openLauncherTabInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
now: Date.now(),
});
set((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
},
}));
return result.tabId;
},
closeTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
@@ -202,6 +237,59 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
return result.tabId;
},
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) {
return null;
}
const result = convertDraftToAgentInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
tabId: normalizedTabId,
agentId: normalizedAgentId,
});
if (!result) {
return null;
}
set((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: result.layout,
},
}));
return result.tabId;
},
reconcileTabs: (workspaceKey, snapshot) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
return;
}
set((state) => {
const currentLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const nextState = reconcileWorkspaceTabs(
{
layout: currentLayout,
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
},
snapshot,
);
if (nextState.layout === currentLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextState.layout,
},
};
});
},
reorderTabs: (workspaceKey, tabIds) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useWorkspaceSetupStore } from "./workspace-setup-store";
describe("workspace-setup-store", () => {
beforeEach(() => {
useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null });
});
it("tracks deferred project setup by path instead of a created workspace", () => {
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
serverId: "server-1",
projectPath: "/Users/test/project",
projectName: "project",
creationMethod: "open_project",
navigationMethod: "replace",
});
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
serverId: "server-1",
projectPath: "/Users/test/project",
projectName: "project",
creationMethod: "open_project",
navigationMethod: "replace",
});
});
it("clears pending setup state", () => {
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
serverId: "server-1",
projectPath: "/Users/test/project",
creationMethod: "create_worktree",
navigationMethod: "navigate",
});
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull();
});
});
@@ -0,0 +1,28 @@
import { create } from "zustand";
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
export interface PendingWorkspaceSetup {
serverId: string;
projectPath: string;
projectName?: string;
creationMethod: WorkspaceCreationMethod;
navigationMethod: WorkspaceSetupNavigationMethod;
}
interface WorkspaceSetupStoreState {
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
clearWorkspaceSetup: () => void;
}
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
pendingWorkspaceSetup: null,
beginWorkspaceSetup: (value) => {
set({ pendingWorkspaceSetup: value });
},
clearWorkspaceSetup: () => {
set({ pendingWorkspaceSetup: null });
},
}));
@@ -140,6 +140,41 @@ describe("workspace-tabs-store retargetTab", () => {
expect(order).toEqual([draftTabId]);
});
it("openLauncherTab creates distinct launcher tabs without deduplicating", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const firstTabId = useWorkspaceTabsStore.getState().openLauncherTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const secondTabId = useWorkspaceTabsStore.getState().openLauncherTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const state = useWorkspaceTabsStore.getState();
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111");
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222");
expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]);
expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([
{
tabId: "launcher_11111111-1111-1111-1111-111111111111",
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" },
createdAt: expect.any(Number),
},
{
tabId: "launcher_22222222-2222-2222-2222-222222222222",
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" },
createdAt: expect.any(Number),
},
]);
});
it("retargeting a background draft keeps the currently focused tab focused", () => {
const draftTabId = "draft_background";
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
+26 -67
View File
@@ -1,12 +1,19 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
buildDeterministicWorkspaceTabId,
createLauncherId,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity";
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string };
| { kind: "file"; path: string }
| { kind: "launcher"; launcherId: string };
export type WorkspaceTab = {
tabId: string;
@@ -38,63 +45,6 @@ export function buildWorkspaceTabPersistenceKey(input: {
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 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 {
if (target.kind === "draft") {
return target.draftId;
}
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
return `file_${target.path}`;
}
function normalizeTabOrder(list: unknown): string[] {
if (!Array.isArray(list)) {
return [];
@@ -133,6 +83,7 @@ type WorkspaceTabsState = {
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null;
openOrFocusTab: (input: {
serverId: string;
workspaceId: string;
@@ -169,19 +120,20 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
},
ensureTab: ({ serverId, workspaceId, target }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTarget = normalizeTabTarget(target);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!key || !normalizedTarget) {
return null;
}
const deterministicTabId = buildDeterministicTabId(normalizedTarget);
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
let resolvedTabId = deterministicTabId;
const now = Date.now();
set((state) => {
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const tabWithSameTarget =
currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ??
null;
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
resolvedTabId = effectiveTabId;
@@ -196,7 +148,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
];
}
const existing = currentTabs[existingIndex];
if (existing && tabTargetsEqual(existing.target, normalizedTarget)) {
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
return currentTabs;
}
return currentTabs.map((tab, index) =>
@@ -218,6 +170,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
return resolvedTabId;
},
openLauncherTab: ({ serverId, workspaceId }) => {
return get().openOrFocusTab({
serverId,
workspaceId,
target: { kind: "launcher", launcherId: createLauncherId() },
});
},
openOrFocusTab: ({ serverId, workspaceId, target }) => {
const tabId = get().ensureTab({ serverId, workspaceId, target });
if (!tabId) {
@@ -310,7 +269,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeTabTarget(target);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!key || !normalizedTabId || !normalizedTarget) {
return null;
}
@@ -325,7 +284,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
}
const currentTarget = currentTabs[index]?.target;
if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
return state;
}
@@ -390,7 +349,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
for (const key in state.uiTabsByWorkspace) {
const tabs = (state.uiTabsByWorkspace[key] ?? [])
.map((tab) => {
const normalizedTarget = normalizeTabTarget(tab.target);
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
const normalizedTabId = trimNonEmpty(tab.tabId);
if (!normalizedTarget || !normalizedTabId) {
return null;
@@ -479,13 +438,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
continue;
}
const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target);
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
if (!normalizedTarget) {
continue;
}
const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget);
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
if (!usedOrder.has(tabId)) {
usedOrder.add(tabId);
orderFromTabs.push(tabId);