feat: add sqlite storage and terminal ui
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user