Improve app tests (#1197)

* docs(testing): require ports-and-adapters unit tests or real e2e — no in-between

State the end-state explicitly so the test-suite cleanup has a written bar.

* Make useProjects a ports-and-adapters unit test

Extract the per-host workspace aggregation out of useProjects into a
pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime
adapter. The hook becomes a thin useQuery shim.

The test loses jsdom, @testing-library/react, QueryClientProvider, and
all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the
real aggregation against an in-memory ProjectsRuntime adapter.

* Make useLoadOlderAgentHistory a ports-and-adapters unit test

Extract the load-older sequence into a pure async function that takes
its client, in-flight tracker, toast, and logger as injected
dependencies. The hook reads the session store at call time and wires
the real adapters in. Tests now drive the pure function with typed
fakes — no JSDOM, no @testing-library/react renderHook, no console
spy.

* Make useAgentHistory a ports-and-adapters unit test

Drop the dead __private__ namespace from use-agent-history.ts, expose
fetchAgentHistoryPage and an AgentHistoryClient port as normal exports.
The hook keeps its useInfiniteQuery wiring; the page fetcher is now
testable in isolation.

The test loses jsdom, @testing-library/react, QueryClientProvider, the
vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing
loops. It exercises the page fetcher directly against an in-memory
AgentHistoryClient adapter that records each call.

* Make usePrPaneData a ports-and-adapters unit test

Extract the timeline fetch into a pure fetchPrPaneTimelinePage that
takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry
port. Extract the rest of the hook's wiring into pure exports:
extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The
module-level unsupported-tuple Set becomes a swappable in-memory
registry; production still wires the same module-level default.

The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime,
QueryClientProvider, createRoot, act, focusManager/onlineManager
toggling, and a hand-rolled waitForExpectation polling loop. It drives
each pure function and the timeline fetcher directly against typed
fakes and asserts on recorded state.

* Make useProvidersSnapshot a ports-and-adapters unit test

Extract the network and cache work into pure functions taking a
typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot,
refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and
selectorOpenRefetchDecision. The hook still drives them through
react-query and the host-runtime websocket subscription; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, act, and a
hand-rolled listener-bucket trap that reached into the spied
client.on subscriber. It drives each pure function directly against
a typed FakeProvidersSnapshotClient and a real QueryClient.

* Make useSettings a ports-and-adapters unit test

Extract the pure load/save logic into use-settings.pure.ts taking a
typed SettingsDeps adapter: a KeyValueStorage port and a
DesktopSettingsBridge port. The hook file keeps the public 0-arg API
(loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings,
saveAppSettings) by wrapping the pure functions with productionDeps that
wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and
migrateLegacyDesktopSettings as before.

The test loses three vi.mock blocks (async-storage, @/desktop/host,
@/desktop/settings/desktop-settings), two vi.hoisted blobs, and the
vi.resetModules + await import("./use-settings") per-test pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage and a FakeDesktopBridge that records applied
migrations, asserting on observable storage state rather than mock
function calls.

* Make useAgentCommandsQuery a ports-and-adapters unit test

Extract the daemon call into fetchAgentCommands, a pure async
function taking a typed AgentCommandsClient adapter. The hook still
wraps it in useQuery and resolves the host-runtime client; production
wires the same DaemonClient as before.

The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, and
waitFor. It calls fetchAgentCommands directly against a typed
FakeAgentCommandsClient.

* Make useChangesPreferences a ports-and-adapters unit test

Extract the AsyncStorage-touching load and save logic into
use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter.
The hook file keeps the public API (useChangesPreferences,
loadChangesPreferencesFromStorage) by wrapping the pure functions with a
productionStorage wired to AsyncStorage as before.

The test loses vi.hoisted + vi.mock of
@react-native-async-storage/async-storage, vi.resetModules, and the
per-test dynamic await import("./use-changes-preferences") pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage, asserting on observable storage entries rather
than mock function calls. Adds coverage for saveChangesPreferences,
including the no-prior-cache fallback path.

* Make useArchiveAgent a ports-and-adapters unit test

Extract the queryClient-only pure helpers (toArchiveKey,
selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the
queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The
hook file keeps the React surface (usePendingArchiveAgentIds,
useArchiveAgent, applyArchivedAgentCloseResults) by importing from the
pure module, and the __private__ reach-around export is gone.

The test loses @vitest-environment jsdom, @testing-library/react, the
renderHook/act/waitFor imports, and the __private__ reach-around. It
calls the pure helpers directly against a real QueryClient and the real
session store. The renderHook test of usePendingArchiveAgentIds is
dropped — it asserted on react-query's subscription mechanics rather
than on our logic.

* Make useSidebarWorkspacesList a ports-and-adapters unit test

Extract the pure pieces — types, applyStoredOrdering,
appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new
computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into
use-sidebar-workspaces-list.pure.ts. The hook reads its persistent
sidebar order via the pure helper inside useEffect and derives its
loading state via the pure helper, instead of inlining the logic across
two effects. createSidebarWorkspaceEntry stays in the hook file since
it pulls selectPrHintFromStatus, but the hook re-exports it for
existing callers.

The test loses @vitest-environment jsdom, react-dom/client,
@testing-library/react, and the three Probe components that mounted
React just to assert on effect mechanics. It calls
computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with
no module mocks or hoisted globals. The "does not subscribe while
disabled" assertion is dropped — it was testing useSyncExternalStore's
subscribe gate, not our logic.

* Make useAgentInitialization a ports-and-adapters unit test

Extract ensureAgentIsInitialized and refreshAgent — the entire
imperative bodies of the hook's two callbacks — into
use-agent-initialization.pure.ts, taking setAgentInitializing as an
injected port. Add createSetAgentInitializing as a factory that binds
serverId to the zustand setInitializingAgents action. The hook itself
collapses to ~20 lines of useMemo + useCallback bindings.

The test drops @vitest-environment jsdom, @testing-library/react,
renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent
directly with a bound setAgentInitializing fake. No React mounting, no
module mocks, no hoisted globals.

* Make useClientActivity a ports-and-adapters unit test

Extract the activity-tracker state machine — lastActivityAt bookkeeping,
heartbeat throttling, app-visibility transitions, system-idle monotonic
update, and focused-agent change handling — into
use-client-activity.pure.ts. createClientActivityTracker takes the
heartbeat client, deviceType, and a now() port; the hook wires DOM /
AppState / Electron-idle listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, and four module mocks (@/constants/platform, @/desktop/electron/idle,
react-native, @getpaseo/client/internal/daemon-client). It calls the
tracker directly with a fake heartbeat client and a test clock; the
fake records emitted heartbeats as observable state.

* Make useHoverSafeZone a ports-and-adapters unit test

Extract the safe-zone state machine — wasInside dedupe, bridge-rect
geometry between trigger and content, and inside/outside transitions —
into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes
getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone;
the hook just wires document pointermove, window pointerout, and
window blur listeners to tracker methods.

The test drops @vitest-environment jsdom, react-dom/client mounting,
act, @testing-library/react renderHook, the vi.mock("@/constants/platform")
shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect
patching helper. It calls the tracker directly with fake rect getters
and asserts on recorded enter/leave counts.

* Make useArchiveSubagent a ports-and-adapters unit test

* Make openImagePathsWithDesktopDialog a ports-and-adapters unit test

Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead
of reaching for getDesktopHost() inside the function. useImageAttachmentPicker
passes getDesktopHost()?.dialog at the call site; the native sibling matches
the new signature.

The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a
typed in-memory fake dialog that records the options it was called with. No
global module substitution, no spies — the test reads the fake's recorded
state.

* Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test

* Make UpdateCalloutSource a ports-and-adapters unit test

Extract resolveUpdateCalloutDescriptor as a pure function that maps
updater state to a structured callout descriptor. UpdateCalloutSource
becomes a thin React shim that materializes the descriptor's icon and
description as ReactNodes before registering with the sidebar callout
API.

Replaces a 259-line JSDOM + react-dom/client test that mocked five
modules (unistyles theme, lucide icons, async-storage, openExternalUrl,
useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to
assert deterministic title/description/action/dismissal-key derivations.
The new test exercises the resolver directly with zero React, zero DOM,
zero mocks.

* Delete dead useWorkspaceNavigation hook + collapse re-export indirection

The hook had zero production callers — only its own test, which used
vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to
verify a useCallback wrapper. The file also re-exported navigateToWorkspace
from the navigation store, so five production importers and two sibling
tests reached the store through a hook-module path that had nothing to
do with hooks.

Retargets every importer to @/stores/navigation-active-workspace-store
directly and removes the indirection module + its slop test.

* Make useCheckoutStatusQuery a ports-and-adapters unit test

Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a
sibling checkout-status-cache.ts so both pure functions operate on an
injected QueryClient and CheckoutStatusClient, with no React or host
runtime imports. useCheckoutStatusQuery becomes a thin shell that
composes useQuery + useEffect and delegates the cwd-filter + cache write
to applyCheckoutStatusUpdate.

Replaces a 318-line JSDOM + react-dom/client + fake-timers test that
mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component
to read the hook's data, and captured the subscription handler in a
hoisted Set. The new test exercises both functions directly against a
real QueryClient — zero React, zero DOM, zero mocks, zero fake timers.

* Make workspace-navigation a ports-and-adapters unit test

* Make navigateToAgent a ports-and-adapters unit test

* Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test

* Make openProjectDirectly a ports-and-adapters unit test

* Make navigation-active-workspace-store a ports-and-adapters unit test

* Make desktop-attachment-store a ports-and-adapters unit test

* Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test

Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and
take the desktop IPC invoker as a parameter. The single caller in
use-client-activity.ts now imports invokeDesktopCommand directly and
passes it; the test wires a typed fake invoker.

Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four
vi.spyOn(console, "warn") log assertions. Tests now assert the
documented behaviour (returns ms, or null) against an in-memory fake.

* Make useDesktopAppUpdater a ports-and-adapters unit test

Extract the check/install state machine from the React hook into a pure
createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/
checkForUpdates/installUpdate. The hook now wires real production deps
into the runtime and bridges its snapshot via useSyncExternalStore;
React-driven concerns (pending-update interval, initial silent check)
stay in the hook.

Drops JSDOM, @testing-library/react, renderHook, the three vi.mock
calls and the vi.hoisted state shim from the test. The new test wires
a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls
and exposes deferNextCheck/failNextCheck/nextInstallResult so the
behaviour assertions read as plain English (status transitions through
checking, available, pending, up-to-date, error; race cancellation drops
older results; install errors get reported once).

* Make sidebar-collapsed-sections-store a ports-and-adapters unit test

* Make session-store-hooks a ports-and-adapters unit test

* Make panel-store a ports-and-adapters unit test

* Make workspace-tabs-store a ports-and-adapters unit test

* Make desktop-preview-url a ports-and-adapters unit test

* Make client-id a ports-and-adapters unit test

* Make browser-store a ports-and-adapters unit test

* Make local-file-attachment-store a ports-and-adapters unit test

* Make draft-store a ports-and-adapters unit test

* Make rich-clipboard a ports-and-adapters unit test

* Make desktop-daemon-transport a ports-and-adapters unit test

* Make image-attachment-picker.native a ports-and-adapters unit test

Extract the pure normalize logic into image-attachment-picker.native.pure.ts
and take the PNG exporter as a port. The .native.ts entry wires the real
expo-image-manipulator adapter; the test wires a fake exporter and asserts
on its recorded uris.

Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator
stub from the test. Production callers of normalizePickedImageAssets are
unchanged.

* Make tool-call-icon a ports-and-adapters unit test

Split the pure icon-identity decision into tool-call-icon-name.ts and
keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The
resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...);
componentForToolCallIcon does the React component mapping; the existing
resolveToolCallIcon is the composition.

Drops vi.mock("lucide-react-native") from the test and the brittle
"expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't
evaluate lucide-react-native, which is why the mock existed in the
first place. The test now asserts on string identifiers and imports
only the pure module.

buildToolCallPresentation's resolveIcon port is unchanged; the single
caller (components/message.tsx) keeps passing resolveToolCallIcon and
gets the same component back.

* Make desktop-permissions a ports-and-adapters unit test

The test was the heaviest globalThis-juggling test in the app package:
vi.doMock("react-native") + vi.resetModules() per case to swap the
platform, ensureWindow/setNavigator/restoreGlobals to swap
globalThis.Notification, globalThis.navigator, and window.paseoDesktop.
That whole setup existed because the production module reached into
four ambient sources (Notification, navigator, getDesktopHost(),
isWeb/isNative) without a port.

Extract a DesktopPermissionEnvironment interface — { isWeb,
getDesktopHost, getNotification, getNavigator } — and rebuild the
module around a createDesktopPermissions(env) factory. The real
environment binds to the actual globals at module load and the
existing top-level exports (shouldShowDesktopPermissionSection,
getDesktopPermissionSnapshot, requestDesktopPermission) are thin
references onto it, so the only caller (use-desktop-permissions.ts) is
unchanged.

The test now constructs a fakeEnvironment per case and calls into
createDesktopPermissions directly — no JSDOM, no vi.doMock, no
vi.resetModules, no globalThis writes. The eight behaviors are
preserved.

* Make provider-icons a ports-and-adapters unit test

Split the pure provider→icon-identity decision into provider-icon-name.ts
and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts.
resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}),
and getProviderIcon composes it with the catalog/builtin component maps.

Drops vi.mock("lucide-react-native") from the test — the previous
"expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit
project couldn't evaluate lucide-react-native. The new test asserts on
discriminated-union identifiers and imports only the pure module.

The 13 outside callers of getProviderIcon are unchanged.

* Make crypto polyfill a ports-and-adapters unit test

* Delete redundant JSDOM Index route test

packages/app/src/app/index.test.tsx mounted the Index component through
JSDOM + createRoot + @testing-library/react, mocked five modules
(expo-router, _layout, desktop-daemon, startup-splash-screen,
navigation-active-workspace-store), and asserted that <Redirect> got
rendered with the right href across six scenarios.

Each of those six scenarios is a one-to-one duplicate of a pure case
already covered in host-runtime-bootstrap.test.ts, which tests the same
two decision functions (resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection) directly. The component is pure
wiring: it reads four hooks, calls the two decision functions, and
renders the result. There is no logic in Index to verify that the pure
tests do not already cover.

Drop the JSDOM file; the wiring is covered by app E2E.

* Make review-draft-store a ports-and-adapters unit test

* Make new-workspace-empty a ports-and-adapters unit test

* Extract shared drag-reorder state machine for web sortable lists

* Extract pure subagents track presentation helpers

Move formatHeaderLabel and resolveRowLabel out of track.tsx into a
colocated track-presentation.ts. Six header-copy tests that previously
mounted React under JSDOM with eight vi.mocks now run as plain unit
tests against the pure helpers.

* Make terminal-file-drop a ports-and-adapters unit test

* Extract pure import-session-sheet view-model helpers

Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries,
sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle,
getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a
colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise
provider resolution, dedupe/sort, error label fallback, title fallback, and
the empty-state state machine directly — no JSDOM, no vi.mock.

The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next
ticks can replace its status-message and empty-state cases with the pure
coverage.

* Make subagents track a ports-and-adapters unit test

Move the row-presentation data builder from track.tsx into the colocated
pure track-presentation.ts and cover statusBucket, titleState, and label
in plain node tests. Delete the 235-line JSDOM track.test.tsx with its
eight vi.mocks and createRoot harness — the remaining behaviours (empty
returns null, useState toggle, onPress wiring) are React idioms covered
by the framework, not domain logic.

* Make isolated-bottom-sheet-modal a ports-and-adapters unit test

* Stop UI from leaking through subagents barrel

The subagents/index.ts barrel re-exported SubagentsTrack (the React
component) alongside pure logic like selectSubagentsForParent. Any pure
consumer of the barrel transitively pulled in lucide-react-native and
react-native-unistyles, forcing tests that only touch pure logic to
declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider
icons just to get the import graph to load.

Drop SubagentsTrack from the barrel. The one external caller
(panels/agent-panel.tsx) deep-imports from @/subagents/track instead.
The workspace-subagents-integration test loses 56 lines of pretend-UI
mocks; only the AsyncStorage mock remains because workspace-layout-store
uses it directly through persist middleware.

* Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts

The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`,
`.native.ts`, `.test.ts`) without being one. The file exports a single
tracker; rename it after its role. Test sits next to its subject by name.

* Promote use-settings to a directory module

* Promote sidebar-collapsed-sections-store to a directory module

* Promote navigate-to-agent to a directory module

The `.pure.ts` filename suffix mimics tooling-resolved variants
(`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure
resolver and its wired wrapper into `utils/navigate-to-agent/`, where
the directory carries the domain and `resolve.ts` names the role. The
pure function is renamed `resolveNavigateToAgent` so it no longer
collides with the wrapper's exported `navigateToAgent`.

* Collapse use-client-activity.pure.ts into client-activity-tracker.ts

* Collapse open-project.pure.ts into open-project.ts

* Promote use-changes-preferences to a directory module

Replaces the .pure.ts / .test-utils.ts double-suffix with a
directory home that mirrors hooks/use-settings/:

  hooks/use-changes-preferences/
    index.ts          ← React hook; wires AsyncStorage
    storage.ts        ← pure load/save
    storage.test.ts   ← tests against storage
    fakes.ts          ← in-memory KeyValueStorage adapter

External callers continue importing @/hooks/use-changes-preferences
unchanged (resolves to index.ts).

* Promote draft-store to a directory module

* Collapse use-archive-agent.pure.ts into use-archive-agent.ts

* Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts

* Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts

* Inline use-agent-initialization.pure.ts into use-agent-initialization.ts

* Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts

* Rename review/store.pure.ts to review/state.ts

* Promote browser-store to a directory module

* Promote navigation-active-workspace-store to a directory module

* Promote panel-store to a directory module

* Promote session-store-hooks to a directory module

* Promote workspace-tabs-store to a directory module

* Rename use-archive-subagent.pure.ts to archive-subagent.ts

* Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts

* Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts
This commit is contained in:
Mohamed Boudra
2026-05-28 12:34:31 +08:00
committed by GitHub
parent 5cede0a7bb
commit 3ac182cffc
195 changed files with 13063 additions and 11542 deletions
@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { normalizeWorkspaceBrowserUrl, useBrowserStore } from "./browser-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
describe("browser store", () => {
it("normalizes local development hosts to http by default", () => {
expect(normalizeWorkspaceBrowserUrl("localhost:8081")).toBe("http://localhost:8081");
expect(normalizeWorkspaceBrowserUrl("localhost/path")).toBe("http://localhost/path");
expect(normalizeWorkspaceBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path");
expect(normalizeWorkspaceBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8");
expect(normalizeWorkspaceBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173");
});
it("normalizes public hosts to https by default", () => {
expect(normalizeWorkspaceBrowserUrl("example.com")).toBe("https://example.com");
expect(normalizeWorkspaceBrowserUrl("//example.com/path")).toBe("https://example.com/path");
});
it("keeps explicit protocols unchanged", () => {
expect(normalizeWorkspaceBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081");
expect(normalizeWorkspaceBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081");
expect(normalizeWorkspaceBrowserUrl("file:///tmp/example.html")).toBe(
"file:///tmp/example.html",
);
});
it("normalizes browser URLs when creating and updating records", () => {
useBrowserStore.setState({ browsersById: {} });
const browserId = useBrowserStore.getState().createBrowser({ initialUrl: "localhost:8081" });
expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe("http://localhost:8081");
useBrowserStore.getState().updateBrowser(browserId, { url: "example.com/path" });
expect(useBrowserStore.getState().browsersById[browserId]?.url).toBe(
"https://example.com/path",
);
});
});
-177
View File
@@ -1,177 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export interface BrowserRecord {
browserId: string;
url: string;
title: string;
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
faviconUrl: string | null;
lastError: string | null;
createdAt: number;
}
type BrowserRecordPatch = Partial<Omit<BrowserRecord, "browserId" | "createdAt">>;
interface BrowserStoreState {
browsersById: Record<string, BrowserRecord>;
createBrowser: (input?: { initialUrl?: string }) => string;
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
removeBrowser: (browserId: string) => 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;
}
function normalizeBrowserUrl(value: string | null | undefined): string {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return "https://example.com";
}
if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) {
return `http://${trimmed}`;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) {
return trimmed;
}
if (trimmed.startsWith("//")) {
return `https:${trimmed}`;
}
return `https://${trimmed}`;
}
function createBrowserId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useBrowserStore = create<BrowserStoreState>()(
persist(
(set) => ({
browsersById: {},
createBrowser: (input) => {
const browserId = createBrowserId();
const now = Date.now();
const initialUrl = normalizeBrowserUrl(input?.initialUrl);
set((state) => ({
browsersById: {
...state.browsersById,
[browserId]: {
browserId,
url: initialUrl,
title: "",
isLoading: false,
canGoBack: false,
canGoForward: false,
faviconUrl: null,
lastError: null,
createdAt: now,
},
},
}));
return browserId;
},
updateBrowser: (browserId, patch) => {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
set((state) => {
const existing = state.browsersById[normalizedBrowserId];
if (!existing) {
return state;
}
const nextRecord: BrowserRecord = {
...existing,
...patch,
url: normalizeBrowserUrl(patch.url ?? existing.url),
};
if (
nextRecord.url === existing.url &&
nextRecord.title === existing.title &&
nextRecord.isLoading === existing.isLoading &&
nextRecord.canGoBack === existing.canGoBack &&
nextRecord.canGoForward === existing.canGoForward &&
nextRecord.faviconUrl === existing.faviconUrl &&
nextRecord.lastError === existing.lastError
) {
return state;
}
return {
browsersById: {
...state.browsersById,
[normalizedBrowserId]: nextRecord,
},
};
});
},
removeBrowser: (browserId) => {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return;
}
set((state) => {
if (!state.browsersById[normalizedBrowserId]) {
return state;
}
const next = { ...state.browsersById };
delete next[normalizedBrowserId];
return { browsersById: next };
});
},
}),
{
name: "workspace-browser-store",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
browsersById: Object.fromEntries(
Object.entries(state.browsersById).map(([browserId, browser]) => [
browserId,
{ ...browser, isLoading: false, lastError: null },
]),
),
}),
},
),
);
export function getBrowserRecord(browserId: string): BrowserRecord | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null;
}
export function createWorkspaceBrowser(input?: { initialUrl?: string }): {
browserId: string;
url: string;
} {
const browserId = useBrowserStore.getState().createBrowser(input);
const record = getBrowserRecord(browserId);
return {
browserId,
url: record?.url ?? normalizeBrowserUrl(input?.initialUrl),
};
}
export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string {
return normalizeBrowserUrl(value);
}
@@ -0,0 +1,89 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyBrowserPatch,
type BrowserIndexState,
type BrowserRecord,
type BrowserRecordPatch,
createBrowserRecord,
normalizeBrowserUrl,
removeBrowserFromIndex,
sanitizeBrowsersForPersist,
trimNonEmpty,
} from "./state";
export type { BrowserRecord } from "./state";
interface BrowserStoreState extends BrowserIndexState {
createBrowser: (input?: { initialUrl?: string }) => string;
updateBrowser: (browserId: string, patch: BrowserRecordPatch) => void;
removeBrowser: (browserId: string) => void;
}
function createBrowserId(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export const useBrowserStore = create<BrowserStoreState>()(
persist(
(set) => ({
browsersById: {},
createBrowser: (input) => {
const browserId = createBrowserId();
const record = createBrowserRecord({
browserId,
initialUrl: input?.initialUrl,
now: Date.now(),
});
set((state) => ({
browsersById: {
...state.browsersById,
[browserId]: record,
},
}));
return browserId;
},
updateBrowser: (browserId, patch) => {
set((state) => applyBrowserPatch(state, browserId, patch));
},
removeBrowser: (browserId) => {
set((state) => removeBrowserFromIndex(state, browserId));
},
}),
{
name: "workspace-browser-store",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => sanitizeBrowsersForPersist(state),
},
),
);
export function getBrowserRecord(browserId: string): BrowserRecord | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
return useBrowserStore.getState().browsersById[normalizedBrowserId] ?? null;
}
export function createWorkspaceBrowser(input?: { initialUrl?: string }): {
browserId: string;
url: string;
} {
const browserId = useBrowserStore.getState().createBrowser(input);
const record = getBrowserRecord(browserId);
return {
browserId,
url: record?.url ?? normalizeBrowserUrl(input?.initialUrl),
};
}
export function normalizeWorkspaceBrowserUrl(value: string | null | undefined): string {
return normalizeBrowserUrl(value);
}
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
applyBrowserPatch,
type BrowserIndexState,
createBrowserRecord,
normalizeBrowserUrl,
removeBrowserFromIndex,
sanitizeBrowsersForPersist,
} from "./state";
function withRecords(records: ReturnType<typeof createBrowserRecord>[]): BrowserIndexState {
return {
browsersById: Object.fromEntries(records.map((record) => [record.browserId, record])),
};
}
describe("normalizeBrowserUrl", () => {
it("normalizes local development hosts to http by default", () => {
expect(normalizeBrowserUrl("localhost:8081")).toBe("http://localhost:8081");
expect(normalizeBrowserUrl("localhost/path")).toBe("http://localhost/path");
expect(normalizeBrowserUrl("127.0.0.1:3000/path")).toBe("http://127.0.0.1:3000/path");
expect(normalizeBrowserUrl("192.168.0.8")).toBe("http://192.168.0.8");
expect(normalizeBrowserUrl("[::1]:5173")).toBe("http://[::1]:5173");
});
it("normalizes public hosts to https by default", () => {
expect(normalizeBrowserUrl("example.com")).toBe("https://example.com");
expect(normalizeBrowserUrl("//example.com/path")).toBe("https://example.com/path");
});
it("keeps explicit protocols unchanged", () => {
expect(normalizeBrowserUrl("http://localhost:8081")).toBe("http://localhost:8081");
expect(normalizeBrowserUrl("https://localhost:8081")).toBe("https://localhost:8081");
expect(normalizeBrowserUrl("file:///tmp/example.html")).toBe("file:///tmp/example.html");
});
it("falls back to a default URL when input is blank", () => {
expect(normalizeBrowserUrl(null)).toBe("https://example.com");
expect(normalizeBrowserUrl(" ")).toBe("https://example.com");
});
});
describe("createBrowserRecord", () => {
it("normalizes the initial URL and starts with idle state", () => {
const record = createBrowserRecord({
browserId: "b1",
initialUrl: "localhost:8081",
now: 1000,
});
expect(record).toEqual({
browserId: "b1",
url: "http://localhost:8081",
title: "",
isLoading: false,
canGoBack: false,
canGoForward: false,
faviconUrl: null,
lastError: null,
createdAt: 1000,
});
});
});
describe("applyBrowserPatch", () => {
it("normalizes URL updates", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
const next = applyBrowserPatch(initial, "b1", { url: "example.com/path" });
expect(next.browsersById.b1?.url).toBe("https://example.com/path");
});
it("returns the same state reference when nothing changes", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
const next = applyBrowserPatch(initial, "b1", { url: "https://a.test", title: "" });
expect(next).toBe(initial);
});
it("returns the same state when the browser id is unknown", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
const next = applyBrowserPatch(initial, "missing", { title: "x" });
expect(next).toBe(initial);
});
it("returns the same state when the browser id is blank", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
expect(applyBrowserPatch(initial, " ", { title: "x" })).toBe(initial);
});
});
describe("removeBrowserFromIndex", () => {
it("removes the named browser", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
createBrowserRecord({ browserId: "b2", initialUrl: "https://b.test", now: 0 }),
]);
const next = removeBrowserFromIndex(initial, "b1");
expect(Object.keys(next.browsersById)).toEqual(["b2"]);
});
it("returns the same state when the browser id is unknown", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
expect(removeBrowserFromIndex(initial, "missing")).toBe(initial);
});
it("returns the same state when the browser id is blank", () => {
const initial = withRecords([
createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 }),
]);
expect(removeBrowserFromIndex(initial, " ")).toBe(initial);
});
});
describe("sanitizeBrowsersForPersist", () => {
it("clears transient fields on every record", () => {
const base = createBrowserRecord({ browserId: "b1", initialUrl: "https://a.test", now: 0 });
const state: BrowserIndexState = {
browsersById: {
b1: { ...base, isLoading: true, lastError: "network down" },
},
};
const persisted = sanitizeBrowsersForPersist(state);
expect(persisted.browsersById.b1?.isLoading).toBe(false);
expect(persisted.browsersById.b1?.lastError).toBe(null);
});
});
@@ -0,0 +1,130 @@
export interface BrowserRecord {
browserId: string;
url: string;
title: string;
isLoading: boolean;
canGoBack: boolean;
canGoForward: boolean;
faviconUrl: string | null;
lastError: string | null;
createdAt: number;
}
export type BrowserRecordPatch = Partial<Omit<BrowserRecord, "browserId" | "createdAt">>;
export interface BrowserIndexState {
browsersById: Record<string, BrowserRecord>;
}
export 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 normalizeBrowserUrl(value: string | null | undefined): string {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return "https://example.com";
}
if (/^(localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[\da-fA-F:.]+])(?::\d+)?(?:[/?#]|$)/.test(trimmed)) {
return `http://${trimmed}`;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) {
return trimmed;
}
if (trimmed.startsWith("//")) {
return `https:${trimmed}`;
}
return `https://${trimmed}`;
}
export function createBrowserRecord(input: {
browserId: string;
initialUrl: string | null | undefined;
now: number;
}): BrowserRecord {
return {
browserId: input.browserId,
url: normalizeBrowserUrl(input.initialUrl),
title: "",
isLoading: false,
canGoBack: false,
canGoForward: false,
faviconUrl: null,
lastError: null,
createdAt: input.now,
};
}
export function applyBrowserPatch<S extends BrowserIndexState>(
state: S,
browserId: string,
patch: BrowserRecordPatch,
): S {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return state;
}
const existing = state.browsersById[normalizedBrowserId];
if (!existing) {
return state;
}
const nextRecord: BrowserRecord = {
...existing,
...patch,
url: normalizeBrowserUrl(patch.url ?? existing.url),
};
if (
nextRecord.url === existing.url &&
nextRecord.title === existing.title &&
nextRecord.isLoading === existing.isLoading &&
nextRecord.canGoBack === existing.canGoBack &&
nextRecord.canGoForward === existing.canGoForward &&
nextRecord.faviconUrl === existing.faviconUrl &&
nextRecord.lastError === existing.lastError
) {
return state;
}
return {
...state,
browsersById: {
...state.browsersById,
[normalizedBrowserId]: nextRecord,
},
};
}
export function removeBrowserFromIndex<S extends BrowserIndexState>(
state: S,
browserId: string,
): S {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return state;
}
if (!state.browsersById[normalizedBrowserId]) {
return state;
}
const next = { ...state.browsersById };
delete next[normalizedBrowserId];
return { ...state, browsersById: next };
}
export function sanitizeBrowsersForPersist(state: BrowserIndexState): {
browsersById: Record<string, BrowserRecord>;
} {
return {
browsersById: Object.fromEntries(
Object.entries(state.browsersById).map(([browserId, browser]) => [
browserId,
{ ...browser, isLoading: false, lastError: null },
]),
),
};
}
@@ -1,8 +1,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
import type { AttachmentMetadata } from "@/attachments/types";
import {
garbageCollectAttachments,
persistAttachmentFromDataUrl,
@@ -10,37 +9,24 @@ import {
} from "@/attachments/service";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import {
applyClearDraftRecord,
collectReferencedAttachmentIdsFromState,
DRAFT_STORE_VERSION,
isAttachmentMetadata,
isCanonicalDraftInput,
isLegacyDraftImage,
normalizeComposerAttachment,
pruneFinalizedDraftRecords,
toDraftInputIfReady,
type DraftInput,
type DraftLifecycleState,
type DraftRecord,
type DraftStoreState,
} from "./state";
import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration";
const DRAFT_STORE_VERSION = 4;
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
interface LegacyDraftImage {
uri: string;
mimeType?: string;
}
type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage;
export interface DraftInput {
text: string;
attachments: UserComposerAttachment[];
}
export type DraftLifecycleState = "active" | "abandoned" | "sent";
type CanonicalDraftInput = DraftInput;
interface DraftRecord {
input: CanonicalDraftInput;
lifecycle: DraftLifecycleState;
updatedAt: number;
version: number;
}
interface DraftStoreState {
drafts: Record<string, DraftRecord>;
createModalDraft: DraftRecord | null;
}
export type { DraftInput, DraftLifecycleState } from "./state";
interface DraftStoreActions {
getDraftInput: (draftKey: string) => DraftInput | undefined;
@@ -79,189 +65,44 @@ function createDraftRecord(input: {
};
}
function isAttachmentMetadata(value: unknown): value is AttachmentMetadata {
if (!value || typeof value !== "object") {
return false;
const migrateLegacyImages: MigrateLegacyImages = async (images) => {
if (images.length === 0) {
return [];
}
const record = value as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.mimeType === "string" &&
typeof record.storageType === "string" &&
typeof record.storageKey === "string" &&
typeof record.createdAt === "number"
);
}
function isLegacyDraftImage(value: unknown): value is LegacyDraftImage {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.uri === "string";
}
function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata {
return {
id: image.id,
mimeType: image.mimeType,
storageType: image.storageType,
storageKey: image.storageKey,
createdAt: image.createdAt,
...(typeof image.fileName === "string" || image.fileName === null
? { fileName: image.fileName }
: {}),
...(typeof image.byteSize === "number" || image.byteSize === null
? { byteSize: image.byteSize }
: {}),
};
}
function normalizePersistedImage(value: unknown): PersistedDraftImage | null {
if (isAttachmentMetadata(value)) {
return normalizeAttachmentMetadata(value);
}
if (isLegacyDraftImage(value)) {
return {
uri: value.uri,
...(value.mimeType ? { mimeType: value.mimeType } : {}),
};
}
return null;
}
function isUserComposerAttachment(value: unknown): value is UserComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (record.kind === "image") {
const metadata = record.metadata;
return isAttachmentMetadata(metadata);
}
if (record.kind !== "github_issue" && record.kind !== "github_pr") {
return false;
}
return GitHubSearchItemSchema.safeParse(record.item).success;
}
function normalizeComposerAttachment(attachment: UserComposerAttachment): UserComposerAttachment {
if (attachment.kind === "image") {
return {
kind: "image",
metadata: normalizeAttachmentMetadata(attachment.metadata),
};
}
return attachment;
}
function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null {
if (!isUserComposerAttachment(value)) {
return null;
}
return normalizeComposerAttachment(value);
}
function legacyImagesToAttachments(
images: readonly AttachmentMetadata[],
): UserComposerAttachment[] {
return images.map((metadata) => ({
kind: "image",
metadata,
}));
}
function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
if (!value || typeof value !== "object") {
return false;
}
const input = value as Record<string, unknown>;
// COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09.
return (
typeof input.text === "string" &&
Array.isArray(input.attachments) &&
input.attachments.every(isUserComposerAttachment)
);
}
function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput | undefined {
if (!record) {
return undefined;
}
if (record.lifecycle !== "active") {
return undefined;
}
if (!isCanonicalDraftInput(record.input)) {
return undefined;
}
return {
text: record.input.text,
attachments: record.input.attachments.map(normalizeComposerAttachment),
};
}
function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set<string> {
const referencedIds = new Set<string>();
for (const draftRecord of Object.values(state.drafts)) {
if (draftRecord.lifecycle !== "active") {
continue;
}
if (!isCanonicalDraftInput(draftRecord.input)) {
continue;
}
for (const attachment of draftRecord.input.attachments) {
if (attachment.kind === "image") {
referencedIds.add(attachment.metadata.id);
const migrated = await Promise.all(
images.map(async (entry) => {
if (isAttachmentMetadata(entry)) {
return entry;
}
}
}
const modalRecord = state.createModalDraft;
if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) {
for (const attachment of modalRecord.input.attachments) {
if (attachment.kind === "image") {
referencedIds.add(attachment.metadata.id);
if (!isLegacyDraftImage(entry)) {
return null;
}
}
}
return referencedIds;
}
try {
if (entry.uri.startsWith("data:")) {
return await persistAttachmentFromDataUrl({
dataUrl: entry.uri,
mimeType: entry.mimeType,
});
}
function pruneFinalizedDraftRecords(input: {
drafts: Record<string, DraftRecord>;
nowMs: number;
}): Record<string, DraftRecord> {
let changed = false;
const next: Record<string, DraftRecord> = {};
for (const [draftKey, record] of Object.entries(input.drafts)) {
if (record.lifecycle !== "active" && input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS) {
changed = true;
continue;
}
next[draftKey] = record;
}
return changed ? next : input.drafts;
}
return await persistAttachmentFromFileUri({
uri: entry.uri,
mimeType: entry.mimeType,
});
} catch (error) {
console.warn("[DraftStore] Failed to migrate legacy draft attachment", {
uri: entry.uri,
error,
});
return null;
}
}),
);
function applyClearDraftRecord(input: {
record: DraftRecord;
lifecycle?: Exclude<DraftLifecycleState, "active">;
nowMs: number;
}): DraftRecord | null {
if (!input.lifecycle) {
return null;
}
return {
...input.record,
input: { text: "", attachments: [] },
lifecycle: input.lifecycle,
updatedAt: input.nowMs,
version: input.record.version + 1,
};
}
return migrated.filter((entry): entry is AttachmentMetadata => entry !== null);
};
async function runAttachmentGc(): Promise<void> {
gcScheduled = false;
@@ -370,120 +211,6 @@ async function migrateAllLegacyDrafts(): Promise<void> {
}
}
async function migrateLegacyImages(
images: readonly PersistedDraftImage[],
): Promise<AttachmentMetadata[]> {
if (images.length === 0) {
return [];
}
const migrated = await Promise.all(
images.map(async (entry) => {
if (isAttachmentMetadata(entry)) {
return entry;
}
if (!isLegacyDraftImage(entry)) {
return null;
}
try {
if (entry.uri.startsWith("data:")) {
return await persistAttachmentFromDataUrl({
dataUrl: entry.uri,
mimeType: entry.mimeType,
});
}
return await persistAttachmentFromFileUri({
uri: entry.uri,
mimeType: entry.mimeType,
});
} catch (error) {
console.warn("[DraftStore] Failed to migrate legacy draft attachment", {
uri: entry.uri,
error,
});
return null;
}
}),
);
return migrated.filter((entry): entry is AttachmentMetadata => entry !== null);
}
async function migrateDraftInput(input: { rawInput: unknown }): Promise<CanonicalDraftInput> {
const rawInput =
input.rawInput && typeof input.rawInput === "object"
? (input.rawInput as Record<string, unknown>)
: {};
const attachments = Array.isArray(rawInput.attachments)
? rawInput.attachments
.map((entry) => normalizePersistedComposerAttachment(entry))
.filter((entry): entry is UserComposerAttachment => entry !== null)
: [];
const legacyImages = Array.isArray(rawInput.images)
? rawInput.images
.map((entry) => normalizePersistedImage(entry))
.filter((entry): entry is PersistedDraftImage => entry !== null)
: [];
const migratedImages = await migrateLegacyImages(legacyImages);
return {
text: typeof rawInput.text === "string" ? rawInput.text : "",
attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)],
};
}
function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState {
if (lifecycle === "sent" || lifecycle === "abandoned") {
return lifecycle as DraftLifecycleState;
}
return "active";
}
function extractRawInput(record: Record<string, unknown>): unknown {
if ("input" in record && record.input && typeof record.input === "object") {
return record.input;
}
return record;
}
async function buildMigratedDraftRecord(parsed: Record<string, unknown>): Promise<DraftRecord> {
return {
input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }),
lifecycle: resolvePersistedLifecycle(parsed.lifecycle),
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
version: typeof parsed.version === "number" ? parsed.version : 1,
};
}
async function migratePersistedState(state: unknown): Promise<DraftStoreState> {
const input = (state ?? {}) as {
drafts?: Record<string, unknown>;
createModalDraft?: unknown;
};
const nextDrafts: Record<string, DraftRecord> = {};
for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) {
if (!rawRecord || typeof rawRecord !== "object") {
continue;
}
nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord as Record<string, unknown>);
}
let createModalDraft: DraftRecord | null = null;
if (input.createModalDraft && typeof input.createModalDraft === "object") {
createModalDraft = await buildMigratedDraftRecord(
input.createModalDraft as Record<string, unknown>,
);
}
return {
drafts: nextDrafts,
createModalDraft,
};
}
export const useDraftStore = create<DraftStore>()(
persist(
(set, get) => ({
@@ -508,9 +235,10 @@ export const useDraftStore = create<DraftStore>()(
return ready;
}
const migratedDraft = await migrateDraftInput({
rawInput: current.input,
});
const migratedDraft = await migrateDraftInput(
{ rawInput: current.input },
{ migrateLegacyImages },
);
set((state) => {
const existing = state.drafts[draftKey];
@@ -639,7 +367,10 @@ export const useDraftStore = create<DraftStore>()(
version: DRAFT_STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState) => {
return migratePersistedState(persistedState);
return migratePersistedState(persistedState, {
migrateLegacyImages,
nowMs: Date.now(),
});
},
onRehydrateStorage: () => {
return () => {
@@ -650,11 +381,3 @@ export const useDraftStore = create<DraftStore>()(
},
),
);
export const __draftStoreTestUtils = {
migrateDraftInput,
migratePersistedState,
normalizeAttachmentMetadata,
pruneFinalizedDraftRecords,
applyClearDraftRecord,
};
@@ -1,7 +1,10 @@
import "@/test/window-local-storage";
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { ComposerAttachment } from "@/attachments/types";
import { __draftStoreTestUtils, useDraftStore } from "./draft-store";
import { migratePersistedState, type MigrateLegacyImages } from "./migration";
import { isAttachmentMetadata } from "./state";
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
images.filter(isAttachmentMetadata);
function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "review" }> {
return {
@@ -44,34 +47,33 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie
}
describe("draft-store migration", () => {
beforeEach(() => {
useDraftStore.setState({ drafts: {}, createModalDraft: null });
});
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
const migrated = await __draftStoreTestUtils.migratePersistedState({
drafts: {
"agent:server:agent": {
input: {
text: "hello",
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: 1700000000000,
previewUri: "asset://should-not-persist",
},
],
const migrated = await migratePersistedState(
{
drafts: {
"agent:server:agent": {
input: {
text: "hello",
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: 1700000000000,
previewUri: "asset://should-not-persist",
},
],
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 1,
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 1,
},
createModalDraft: null,
},
createModalDraft: null,
});
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 },
);
expect(migrated.drafts["agent:server:agent"]?.input).toEqual({
text: "hello",
@@ -118,8 +120,9 @@ describe("draft-store migration", () => {
createModalDraft: null,
};
const once = await __draftStoreTestUtils.migratePersistedState(original);
const twice = await __draftStoreTestUtils.migratePersistedState(once);
const ports = { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 };
const once = await migratePersistedState(original, ports);
const twice = await migratePersistedState(once, ports);
expect(twice).toEqual(once);
expect(twice.drafts["agent:server:agent"]?.input).toEqual({
@@ -140,20 +143,23 @@ describe("draft-store migration", () => {
});
it("rejects workspace review attachments from migrated draft attachments", async () => {
const migrated = await __draftStoreTestUtils.migratePersistedState({
drafts: {
"agent:server:agent": {
input: {
text: "hello",
attachments: [workspaceReviewAttachment()],
const migrated = await migratePersistedState(
{
drafts: {
"agent:server:agent": {
input: {
text: "hello",
attachments: [workspaceReviewAttachment()],
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 2,
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 2,
},
createModalDraft: null,
},
createModalDraft: null,
});
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 },
);
expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]);
});
@@ -0,0 +1,135 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import {
isAttachmentMetadata,
isLegacyDraftImage,
isUserComposerAttachment,
normalizeAttachmentMetadata,
normalizeComposerAttachment,
type CanonicalDraftInput,
type DraftLifecycleState,
type DraftRecord,
type DraftStoreState,
type PersistedDraftImage,
} from "./state";
export type MigrateLegacyImages = (
images: readonly PersistedDraftImage[],
) => Promise<AttachmentMetadata[]>;
function normalizePersistedImage(value: unknown): PersistedDraftImage | null {
if (isAttachmentMetadata(value)) {
return normalizeAttachmentMetadata(value);
}
if (isLegacyDraftImage(value)) {
return {
uri: value.uri,
...(value.mimeType ? { mimeType: value.mimeType } : {}),
};
}
return null;
}
function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null {
if (!isUserComposerAttachment(value)) {
return null;
}
return normalizeComposerAttachment(value);
}
function legacyImagesToAttachments(
images: readonly AttachmentMetadata[],
): UserComposerAttachment[] {
return images.map((metadata) => ({
kind: "image",
metadata,
}));
}
export async function migrateDraftInput(
input: { rawInput: unknown },
ports: { migrateLegacyImages: MigrateLegacyImages },
): Promise<CanonicalDraftInput> {
const rawInput =
input.rawInput && typeof input.rawInput === "object"
? (input.rawInput as Record<string, unknown>)
: {};
const attachments = Array.isArray(rawInput.attachments)
? rawInput.attachments
.map((entry) => normalizePersistedComposerAttachment(entry))
.filter((entry): entry is UserComposerAttachment => entry !== null)
: [];
const legacyImages = Array.isArray(rawInput.images)
? rawInput.images
.map((entry) => normalizePersistedImage(entry))
.filter((entry): entry is PersistedDraftImage => entry !== null)
: [];
const migratedImages = await ports.migrateLegacyImages(legacyImages);
return {
text: typeof rawInput.text === "string" ? rawInput.text : "",
attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)],
};
}
function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState {
if (lifecycle === "sent" || lifecycle === "abandoned") {
return lifecycle as DraftLifecycleState;
}
return "active";
}
function extractRawInput(record: Record<string, unknown>): unknown {
if ("input" in record && record.input && typeof record.input === "object") {
return record.input;
}
return record;
}
async function buildMigratedDraftRecord(
parsed: Record<string, unknown>,
ports: { migrateLegacyImages: MigrateLegacyImages },
nowMs: number,
): Promise<DraftRecord> {
return {
input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports),
lifecycle: resolvePersistedLifecycle(parsed.lifecycle),
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs,
version: typeof parsed.version === "number" ? parsed.version : 1,
};
}
export async function migratePersistedState(
state: unknown,
ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number },
): Promise<DraftStoreState> {
const input = (state ?? {}) as {
drafts?: Record<string, unknown>;
createModalDraft?: unknown;
};
const nextDrafts: Record<string, DraftRecord> = {};
for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) {
if (!rawRecord || typeof rawRecord !== "object") {
continue;
}
nextDrafts[draftKey] = await buildMigratedDraftRecord(
rawRecord as Record<string, unknown>,
ports,
ports.nowMs,
);
}
let createModalDraft: DraftRecord | null = null;
if (input.createModalDraft && typeof input.createModalDraft === "object") {
createModalDraft = await buildMigratedDraftRecord(
input.createModalDraft as Record<string, unknown>,
ports,
ports.nowMs,
);
}
return {
drafts: nextDrafts,
createModalDraft,
};
}
@@ -1,6 +1,5 @@
import "@/test/window-local-storage";
import { describe, expect, it } from "vitest";
import { __draftStoreTestUtils } from "./draft-store";
import { applyClearDraftRecord, pruneFinalizedDraftRecords } from "./state";
describe("draft-store lifecycle", () => {
it("prunes finalized tombstones after TTL", () => {
@@ -26,7 +25,7 @@ describe("draft-store lifecycle", () => {
},
};
const pruned = __draftStoreTestUtils.pruneFinalizedDraftRecords({
const pruned = pruneFinalizedDraftRecords({
drafts,
nowMs: nowMs + 6 * 60 * 1000,
});
@@ -37,7 +36,7 @@ describe("draft-store lifecycle", () => {
});
it("normalizes clear-with-lifecycle into a tombstone without attachments", () => {
const cleared = __draftStoreTestUtils.applyClearDraftRecord({
const cleared = applyClearDraftRecord({
record: {
input: {
text: "hello",
@@ -0,0 +1,192 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 4;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
export interface LegacyDraftImage {
uri: string;
mimeType?: string;
}
export type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage;
export interface DraftInput {
text: string;
attachments: UserComposerAttachment[];
}
export type DraftLifecycleState = "active" | "abandoned" | "sent";
export type CanonicalDraftInput = DraftInput;
export interface DraftRecord {
input: CanonicalDraftInput;
lifecycle: DraftLifecycleState;
updatedAt: number;
version: number;
}
export interface DraftStoreState {
drafts: Record<string, DraftRecord>;
createModalDraft: DraftRecord | null;
}
export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.mimeType === "string" &&
typeof record.storageType === "string" &&
typeof record.storageKey === "string" &&
typeof record.createdAt === "number"
);
}
export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.uri === "string";
}
export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata {
return {
id: image.id,
mimeType: image.mimeType,
storageType: image.storageType,
storageKey: image.storageKey,
createdAt: image.createdAt,
...(typeof image.fileName === "string" || image.fileName === null
? { fileName: image.fileName }
: {}),
...(typeof image.byteSize === "number" || image.byteSize === null
? { byteSize: image.byteSize }
: {}),
};
}
export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (record.kind === "image") {
const metadata = record.metadata;
return isAttachmentMetadata(metadata);
}
if (record.kind !== "github_issue" && record.kind !== "github_pr") {
return false;
}
return GitHubSearchItemSchema.safeParse(record.item).success;
}
export function normalizeComposerAttachment(
attachment: UserComposerAttachment,
): UserComposerAttachment {
if (attachment.kind === "image") {
return {
kind: "image",
metadata: normalizeAttachmentMetadata(attachment.metadata),
};
}
return attachment;
}
export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
if (!value || typeof value !== "object") {
return false;
}
const input = value as Record<string, unknown>;
// COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09.
return (
typeof input.text === "string" &&
Array.isArray(input.attachments) &&
input.attachments.every(isUserComposerAttachment)
);
}
export function toDraftInputIfReady(
record: DraftRecord | null | undefined,
): DraftInput | undefined {
if (!record) {
return undefined;
}
if (record.lifecycle !== "active") {
return undefined;
}
if (!isCanonicalDraftInput(record.input)) {
return undefined;
}
return {
text: record.input.text,
attachments: record.input.attachments.map(normalizeComposerAttachment),
};
}
export function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set<string> {
const referencedIds = new Set<string>();
for (const draftRecord of Object.values(state.drafts)) {
if (draftRecord.lifecycle !== "active") {
continue;
}
if (!isCanonicalDraftInput(draftRecord.input)) {
continue;
}
for (const attachment of draftRecord.input.attachments) {
if (attachment.kind === "image") {
referencedIds.add(attachment.metadata.id);
}
}
}
const modalRecord = state.createModalDraft;
if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) {
for (const attachment of modalRecord.input.attachments) {
if (attachment.kind === "image") {
referencedIds.add(attachment.metadata.id);
}
}
}
return referencedIds;
}
export function pruneFinalizedDraftRecords(input: {
drafts: Record<string, DraftRecord>;
nowMs: number;
}): Record<string, DraftRecord> {
let changed = false;
const next: Record<string, DraftRecord> = {};
for (const [draftKey, record] of Object.entries(input.drafts)) {
if (record.lifecycle !== "active" && input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS) {
changed = true;
continue;
}
next[draftKey] = record;
}
return changed ? next : input.drafts;
}
export function applyClearDraftRecord(input: {
record: DraftRecord;
lifecycle?: Exclude<DraftLifecycleState, "active">;
nowMs: number;
}): DraftRecord | null {
if (!input.lifecycle) {
return null;
}
return {
...input.record,
input: { text: "", attachments: [] },
lifecycle: input.lifecycle,
updatedAt: input.nowMs,
version: input.record.version + 1,
};
}
@@ -1,91 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const routerMock = vi.hoisted(() => ({
dismissTo: vi.fn(),
}));
const pathnameState = vi.hoisted(() => ({
value: "/",
}));
const localParamsState = vi.hoisted(() => ({
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
}));
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(async () => null as string | null),
setItem: vi.fn(async () => undefined),
}));
vi.mock("expo-router", () => ({
router: routerMock,
useLocalSearchParams: () => localParamsState.value,
usePathname: () => pathnameState.value,
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
import {
navigateToLastWorkspace,
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
describe("workspace navigation", () => {
beforeEach(() => {
routerMock.dismissTo.mockReset();
asyncStorageMock.setItem.mockClear();
pathnameState.value = "/";
localParamsState.value = {};
});
it("reports when no last workspace is known", () => {
expect(navigateToLastWorkspace()).toBe(false);
});
it("navigates to a workspace route", () => {
navigateToWorkspace("server-1", "workspace-a");
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"paseo:last-workspace-route-selection",
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
);
});
it("reads the active workspace from the current route", () => {
pathnameState.value = "/h/server-1/workspace/workspace-a";
const { result } = renderHook(() => useActiveWorkspaceSelection());
expect(result.current).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("falls back to workspace route params during cold route mount", () => {
localParamsState.value = {
serverId: "server-1",
workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ",
};
const { result } = renderHook(() => useActiveWorkspaceSelection());
expect(result.current).toEqual({
serverId: "server-1",
workspaceId: "/tmp/paseo-missing-workspace",
});
});
it("navigates to the last workspace observed by the route reader", () => {
pathnameState.value = "/h/server-1/workspace/workspace-a";
renderHook(() => useActiveWorkspaceSelection());
expect(navigateToLastWorkspace()).toBe(true);
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
});
@@ -6,18 +6,14 @@ import {
type ActiveWorkspaceSelection,
type LastWorkspaceSelectionStorage,
} from "@/stores/last-workspace-selection";
import {
navigateToLastWorkspace as navigateToLastWorkspacePure,
navigateToWorkspace as navigateToWorkspacePure,
parseActiveWorkspaceSelection,
type NavigateToWorkspaceDeps,
} from "./navigation";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
@@ -36,6 +32,19 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
lastWorkspaceSelectionStorage,
);
function navigateDeps(): NavigateToWorkspaceDeps {
return {
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
openWorkspaceAgentTab: (workspaceKey, agentId) => {
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { kind: "agent", agentId });
},
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => router.dismissTo(route as Href),
};
}
export function hydrateLastWorkspaceSelection(): Promise<void> {
return lastWorkspaceSelectionStore.hydrate();
}
@@ -48,69 +57,19 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
return lastWorkspaceSelectionStore.isHydrated();
}
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function parseWorkspaceSelectionFromRouteParams(params: {
serverId?: string | string[];
workspaceId?: string | string[];
}): ActiveWorkspaceSelection | null {
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
_options: NavigateToWorkspaceOptions = {},
) {
const session = useSessionStore.getState().sessions[serverId];
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces: session?.workspaces,
workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(session?.agents.values() ?? []).filter(
(agent) =>
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces?.values(),
workspaceDirectory: agent.cwd,
}) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
useWorkspaceLayoutStore.getState().openTabFocused(`${serverId}:${resolvedWorkspaceId}`, {
kind: "agent",
agentId: attentionAgentId,
});
}
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
router.dismissTo(route);
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
}
export function navigateToLastWorkspace(): boolean {
const selection = lastWorkspaceSelectionStore.getSelection();
if (!selection) {
return false;
}
navigateToWorkspace(selection.serverId, selection.workspaceId);
return true;
return navigateToLastWorkspacePure({
...navigateDeps(),
getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(),
});
}
export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
@@ -118,9 +77,7 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const selection =
parseHostWorkspaceRouteFromPathname(usePathname()) ??
parseWorkspaceSelectionFromRouteParams(params);
const selection = parseActiveWorkspaceSelection({ pathname: usePathname(), params });
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
useEffect(() => {
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import {
navigateToLastWorkspace,
navigateToWorkspace,
parseActiveWorkspaceSelection,
type NavigateToLastWorkspaceDeps,
type NavigateToWorkspaceDeps,
} from "./navigation";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
interface RecordedAgentTab {
workspaceKey: string;
agentId: string;
}
function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const navigations: string[] = [];
const remembered: ActiveWorkspaceSelection[] = [];
const openedAgentTabs: RecordedAgentTab[] = [];
const deps: NavigateToWorkspaceDeps = {
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
openWorkspaceAgentTab: (workspaceKey, agentId) =>
openedAgentTabs.push({ workspaceKey, agentId }),
rememberLastWorkspace: (selection) => remembered.push(selection),
navigateToRoute: (route) => navigations.push(route),
...overrides,
};
return { deps, navigations, remembered, openedAgentTabs };
}
function createLastSelectionDeps(
initial: ActiveWorkspaceSelection | null,
overrides: Partial<NavigateToWorkspaceDeps> = {},
): {
deps: NavigateToLastWorkspaceDeps;
navigations: string[];
remembered: ActiveWorkspaceSelection[];
} {
let lastSelection = initial;
const base = createFakeDeps({
rememberLastWorkspace: (selection) => {
lastSelection = selection;
base.remembered.push(selection);
},
...overrides,
});
return {
deps: { ...base.deps, getLastWorkspaceSelection: () => lastSelection },
navigations: base.navigations,
remembered: base.remembered,
};
}
describe("workspace navigation", () => {
it("reports when no last workspace is known", () => {
const { deps } = createLastSelectionDeps(null);
expect(navigateToLastWorkspace(deps)).toBe(false);
});
it("navigates to a workspace route and remembers the selection", () => {
const { deps, navigations, remembered } = createFakeDeps();
navigateToWorkspace("server-1", "workspace-a", deps);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]);
});
it("focuses the attention agent's tab when a workspace has one", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedAgentTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace("server-1", "workspace-a", deps);
expect(openedAgentTabs).toEqual([{ workspaceKey: "server-1:workspace-a", agentId: "agent-1" }]);
});
it("reads the active workspace from the current route", () => {
const selection = parseActiveWorkspaceSelection({
pathname: "/h/server-1/workspace/workspace-a",
params: {},
});
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
});
it("falls back to workspace route params during cold route mount", () => {
const selection = parseActiveWorkspaceSelection({
pathname: "/",
params: {
serverId: "server-1",
workspaceId: "b64_L3RtcC9wYXNlby1taXNzaW5nLXdvcmtzcGFjZQ",
},
});
expect(selection).toEqual({
serverId: "server-1",
workspaceId: "/tmp/paseo-missing-workspace",
});
});
it("navigates to the last workspace once a route observation has been remembered", () => {
const { deps, navigations } = createLastSelectionDeps(null);
const observed = parseActiveWorkspaceSelection({
pathname: "/h/server-1/workspace/workspace-a",
params: {},
});
expect(observed).not.toBeNull();
if (observed) {
deps.rememberLastWorkspace(observed);
}
expect(navigateToLastWorkspace(deps)).toBe(true);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
});
});
@@ -0,0 +1,102 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export interface RouteSelectionInput {
pathname: string;
params: {
serverId?: string | string[];
workspaceId?: string | string[];
};
}
export interface NavigateToWorkspaceDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
openWorkspaceAgentTab: (workspaceKey: string, agentId: string) => void;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
}
export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps {
getLastWorkspaceSelection: () => ActiveWorkspaceSelection | null;
}
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value[0];
return typeof firstValue === "string" ? firstValue.trim() : "";
}
return "";
}
function parseWorkspaceSelectionFromRouteParams(params: {
serverId?: string | string[];
workspaceId?: string | string[];
}): ActiveWorkspaceSelection | null {
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue ? decodeWorkspaceIdFromPathSegment(workspaceValue) : null;
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
export function parseActiveWorkspaceSelection(
input: RouteSelectionInput,
): ActiveWorkspaceSelection | null {
return (
parseHostWorkspaceRouteFromPathname(input.pathname) ??
parseWorkspaceSelectionFromRouteParams(input.params)
);
}
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
deps: NavigateToWorkspaceDeps,
): void {
const workspaces = deps.getSessionWorkspaces(serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(serverId)).filter(
(agent) =>
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: agent.cwd,
}) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
deps.openWorkspaceAgentTab(`${serverId}:${resolvedWorkspaceId}`, attentionAgentId);
}
deps.rememberLastWorkspace({ serverId, workspaceId });
deps.navigateToRoute(buildHostWorkspaceRoute(serverId, workspaceId));
}
export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): boolean {
const selection = deps.getLastWorkspaceSelection();
if (!selection) {
return false;
}
navigateToWorkspace(selection.serverId, selection.workspaceId, deps);
return true;
}
@@ -4,66 +4,61 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import {
buildExplorerCheckoutKey,
coerceExplorerTabForCheckout,
isExplorerTab,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "./explorer-tab-memory";
import { type ExplorerCheckoutContext } from "./explorer-checkout-context";
} from "../explorer-tab-memory";
import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import {
buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch,
clampExplorerFilesSplitRatio,
clampExplorerWidth,
clampSidebarWidth,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO,
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
selectPanelVisibility,
type DesktopSidebarState,
type ExplorerPanelIntent,
type MobilePanelView,
type PanelLayoutInput,
type PanelVisibilityState,
type SortOption,
} from "./state";
import { isWeb } from "@/constants/platform";
export type { ExplorerTab } from "./explorer-tab-memory";
export type { ExplorerCheckoutContext } from "./explorer-checkout-context";
/**
* Mobile panel state machine.
*
* On mobile, exactly one panel can be visible at a time:
* - 'agent': Main agent view (no overlay panel)
* - 'agent-list': Agent list sidebar (left overlay)
* - 'file-explorer': File explorer sidebar (right overlay)
*
* This makes impossible states unrepresentable - you cannot have both
* sidebars open at the same time on mobile.
*/
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
/**
* Desktop sidebar state.
*
* On desktop, sidebars are independent toggleable panels that don't overlay
* the main content - they sit alongside it. Both can be open simultaneously.
*/
interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
focusModeEnabled: boolean;
}
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_SIDEBAR_WIDTH = 320;
export const MIN_SIDEBAR_WIDTH = 200;
export const MAX_SIDEBAR_WIDTH = 600;
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
export interface PanelVisibilityState {
isAgentListOpen: boolean;
isFileExplorerOpen: boolean;
}
export interface PanelLayoutInput {
isCompact: boolean;
}
export interface ExplorerPanelIntent extends PanelLayoutInput {
checkout: ExplorerCheckoutContext;
}
export type { ExplorerTab } from "../explorer-tab-memory";
export type { ExplorerCheckoutContext } from "../explorer-checkout-context";
export type {
DesktopSidebarState,
ExplorerPanelIntent,
MobilePanelView,
PanelLayoutInput,
PanelVisibilityState,
SortOption,
} from "./state";
export {
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
DEFAULT_EXPLORER_SIDEBAR_WIDTH,
DEFAULT_SIDEBAR_WIDTH,
MAX_EXPLORER_FILES_SPLIT_RATIO,
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_FILES_SPLIT_RATIO,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
selectPanelVisibility,
};
export interface PanelState {
// Mobile: which panel is currently shown
@@ -109,173 +104,6 @@ export interface PanelState {
setExplorerFilesSplitRatio: (ratio: number) => void;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.max(min, Math.min(max, value));
}
function clampSidebarWidth(width: number): number {
return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
}
function clampWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
}
function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
function resolveExplorerTabFromCheckout(state: PanelState, checkout: ExplorerCheckoutContext) {
return resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
});
}
function buildOpenFileExplorerPatch(
state: PanelState,
input: ExplorerPanelIntent,
): Partial<PanelState> {
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
mobileView: "file-explorer",
explorerTab: resolvedTab,
};
}
return {
desktop: { ...state.desktop, fileExplorerOpen: true },
explorerTab: resolvedTab,
};
}
export function selectPanelVisibility(
state: PanelState,
input: PanelLayoutInput,
): PanelVisibilityState {
if (input.isCompact) {
return {
isAgentListOpen: state.mobileView === "agent-list",
isFileExplorerOpen: state.mobileView === "file-explorer",
};
}
return {
isAgentListOpen: state.desktop.agentListOpen,
isFileExplorerOpen: state.desktop.fileExplorerOpen,
};
}
export function selectIsAgentListOpen(state: PanelState, input: PanelLayoutInput): boolean {
return selectPanelVisibility(state, input).isAgentListOpen;
}
export function selectIsFileExplorerOpen(state: PanelState, input: PanelLayoutInput): boolean {
return selectPanelVisibility(state, input).isFileExplorerOpen;
}
type MigratablePanelState = Partial<PanelState> & Record<string, unknown>;
function migratePanelV2Explorer(state: MigratablePanelState): void {
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
if (typeof state.explorerFilesSplitRatio !== "number") {
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
} else {
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio);
}
}
function migratePanelV3Explorer(state: MigratablePanelState): void {
if (
isWeb &&
typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520)
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
}
function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void {
if (
version < 4 ||
typeof state.explorerTabByCheckout !== "object" ||
!state.explorerTabByCheckout
) {
state.explorerTabByCheckout = {};
return;
}
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
continue;
}
next[key] = value;
}
state.explorerTabByCheckout = next;
}
function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
const desktop = state.desktop as Record<string, unknown> | undefined;
if (!desktop) {
return;
}
if ("zoomed" in desktop) {
desktop.focusModeEnabled = desktop.zoomed;
delete desktop.zoomed;
}
if ("focused" in desktop) {
desktop.focusModeEnabled = desktop.focused;
delete desktop.focused;
}
if (typeof desktop.focusModeEnabled !== "boolean") {
desktop.focusModeEnabled = false;
}
}
function migratePanelState(persistedState: unknown, version: number): PanelState {
const state = persistedState as MigratablePanelState;
if (version < 2) {
migratePanelV2Explorer(state);
}
if (version < 3) {
migratePanelV3Explorer(state);
}
if (!isExplorerTab(state.explorerTab)) {
state.explorerTab = "changes";
}
migratePanelExplorerTabByCheckout(state, version);
if (version < 8) {
migratePanelDesktopFocusMode(state);
}
if (version < 6 || typeof state.sidebarWidth !== "number") {
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
if (
version < 9 ||
typeof state.expandedPathsByWorkspace !== "object" ||
!state.expandedPathsByWorkspace
) {
state.expandedPathsByWorkspace = {};
}
if (
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
return state as PanelState;
}
const DEFAULT_DESKTOP_OPEN = isWeb;
export const usePanelStore = create<PanelState>()(
@@ -392,16 +220,7 @@ export const usePanelStore = create<PanelState>()(
set((state) => buildOpenFileExplorerPatch(state, input)),
toggleFileExplorerForCheckout: (input) =>
set((state) => {
const isOpen = selectIsFileExplorerOpen(state, input);
if (!isOpen) {
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
return { mobileView: "agent" as const };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}),
set((state) => buildToggleFileExplorerPatch(state, input)),
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
@@ -441,7 +260,7 @@ export const usePanelStore = create<PanelState>()(
}),
})),
setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerWidth: (width) => set({ explorerWidth: clampExplorerWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
setExplorerFilesSplitRatio: (ratio) =>
set({
@@ -454,7 +273,8 @@ export const usePanelStore = create<PanelState>()(
name: "panel-state",
version: 10,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => migratePanelState(persistedState, version),
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
@@ -1,35 +1,20 @@
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 { describe, expect, it } from "vitest";
import {
buildExplorerCheckoutKey,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "@/stores/explorer-tab-memory";
import {
buildOpenFileExplorerPatch,
buildToggleFileExplorerPatch,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
selectPanelVisibility,
usePanelStore,
type PanelState,
} from "@/stores/panel-store";
type PanelCoreState,
} from "./state";
function resetPanelStore() {
usePanelStore.setState({
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
return {
mobileView: "agent",
desktop: {
agentListOpen: false,
@@ -38,29 +23,10 @@ function resetPanelStore() {
},
explorerTab: "changes",
explorerTabByCheckout: {},
});
}
function createPanelState(input: {
mobileView: PanelState["mobileView"];
agentListOpen: boolean;
fileExplorerOpen: boolean;
}): PanelState {
return {
...usePanelStore.getState(),
mobileView: input.mobileView,
desktop: {
...usePanelStore.getState().desktop,
agentListOpen: input.agentListOpen,
fileExplorerOpen: input.fileExplorerOpen,
},
...overrides,
};
}
beforeEach(() => {
resetPanelStore();
});
describe("panel-store explorer tab resolution", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
@@ -132,10 +98,9 @@ describe("panel-store explorer tab resolution", () => {
describe("panel-store visibility selectors", () => {
it("uses mobileView for compact layout visibility", () => {
const state = createPanelState({
const state = makePanelState({
mobileView: "file-explorer",
agentListOpen: true,
fileExplorerOpen: false,
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
expect(selectPanelVisibility(state, { isCompact: true })).toEqual({
@@ -147,10 +112,9 @@ describe("panel-store visibility selectors", () => {
});
it("uses desktop flags for expanded layout visibility", () => {
const state = createPanelState({
const state = makePanelState({
mobileView: "file-explorer",
agentListOpen: true,
fileExplorerOpen: false,
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
expect(selectPanelVisibility(state, { isCompact: false })).toEqual({
@@ -166,83 +130,71 @@ describe("panel-store checkout-intent file explorer actions", () => {
it("opens the compact explorer and resolves the tab from the explicit checkout", () => {
const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true };
const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!;
usePanelStore.setState({
const state = makePanelState({
explorerTab: "changes",
explorerTabByCheckout: { [key]: "files" },
});
usePanelStore.getState().openFileExplorerForCheckout({
isCompact: true,
checkout,
});
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
expect(usePanelStore.getState().mobileView).toBe("file-explorer");
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false);
expect(usePanelStore.getState().explorerTab).toBe("files");
expect(patch.mobileView).toBe("file-explorer");
expect(patch.desktop).toBeUndefined();
expect(patch.explorerTab).toBe("files");
});
it("opens the expanded explorer and resolves the tab from the explicit checkout", () => {
const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: true };
const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!;
usePanelStore.setState({
const state = makePanelState({
explorerTab: "changes",
explorerTabByCheckout: { [key]: "files" },
});
usePanelStore.getState().openFileExplorerForCheckout({
isCompact: false,
checkout,
});
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
expect(usePanelStore.getState().mobileView).toBe("agent");
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true);
expect(usePanelStore.getState().explorerTab).toBe("files");
expect(patch.mobileView).toBeUndefined();
expect(patch.desktop?.fileExplorerOpen).toBe(true);
expect(patch.explorerTab).toBe("files");
});
it("toggles the explorer closed without changing the active tab", () => {
usePanelStore.setState({
desktop: {
agentListOpen: false,
fileExplorerOpen: true,
focusModeEnabled: false,
},
const state = makePanelState({
desktop: { agentListOpen: false, fileExplorerOpen: true, focusModeEnabled: false },
explorerTab: "files",
});
usePanelStore.getState().toggleFileExplorerForCheckout({
const patch = buildToggleFileExplorerPatch(state, {
isCompact: false,
checkout: { serverId: "server-1", cwd: "/tmp/repo", isGit: true },
});
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(false);
expect(usePanelStore.getState().explorerTab).toBe("files");
expect(patch).toEqual({
desktop: { agentListOpen: false, fileExplorerOpen: false, focusModeEnabled: false },
});
});
it("coerces changes to files for a non-git checkout", () => {
const checkout = { serverId: "server-1", cwd: "/tmp/repo", isGit: false };
const key = buildExplorerCheckoutKey(checkout.serverId, checkout.cwd)!;
usePanelStore.setState({
const state = makePanelState({
explorerTab: "changes",
explorerTabByCheckout: { [key]: "changes" },
});
usePanelStore.getState().openFileExplorerForCheckout({
isCompact: false,
checkout,
});
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
expect(usePanelStore.getState().explorerTab).toBe("files");
expect(patch.explorerTab).toBe("files");
});
it("opens with the default files tab for an explicit non-git checkout with no stored tab", () => {
usePanelStore.setState({ explorerTab: "changes", explorerTabByCheckout: {} });
const state = makePanelState({ explorerTab: "changes", explorerTabByCheckout: {} });
usePanelStore.getState().openFileExplorerForCheckout({
const patch = buildOpenFileExplorerPatch(state, {
isCompact: false,
checkout: { serverId: "server-1", cwd: "/tmp/non-git", isGit: false },
});
expect(usePanelStore.getState().desktop.fileExplorerOpen).toBe(true);
expect(usePanelStore.getState().explorerTab).toBe("files");
expect(patch.desktop?.fileExplorerOpen).toBe(true);
expect(patch.explorerTab).toBe("files");
});
});
@@ -0,0 +1,253 @@
import {
buildExplorerCheckoutKey,
isExplorerTab,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "../explorer-tab-memory";
import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
export interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
focusModeEnabled: boolean;
}
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_SIDEBAR_WIDTH = 320;
export const MIN_SIDEBAR_WIDTH = 200;
export const MAX_SIDEBAR_WIDTH = 600;
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint.
export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000;
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
export interface PanelVisibilityState {
isAgentListOpen: boolean;
isFileExplorerOpen: boolean;
}
export interface PanelLayoutInput {
isCompact: boolean;
}
export interface ExplorerPanelIntent extends PanelLayoutInput {
checkout: ExplorerCheckoutContext;
}
export interface PanelCoreState {
mobileView: MobilePanelView;
desktop: DesktopSidebarState;
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return min;
}
return Math.max(min, Math.min(max, value));
}
export function clampSidebarWidth(width: number): number {
return clampNumber(width, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
}
export function clampExplorerWidth(width: number): number {
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
}
export function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
export function selectPanelVisibility(
state: PanelCoreState,
input: PanelLayoutInput,
): PanelVisibilityState {
if (input.isCompact) {
return {
isAgentListOpen: state.mobileView === "agent-list",
isFileExplorerOpen: state.mobileView === "file-explorer",
};
}
return {
isAgentListOpen: state.desktop.agentListOpen,
isFileExplorerOpen: state.desktop.fileExplorerOpen,
};
}
export function selectIsAgentListOpen(state: PanelCoreState, input: PanelLayoutInput): boolean {
return selectPanelVisibility(state, input).isAgentListOpen;
}
export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayoutInput): boolean {
return selectPanelVisibility(state, input).isFileExplorerOpen;
}
function resolveExplorerTabFromCheckout(
state: PanelCoreState,
checkout: ExplorerCheckoutContext,
): ExplorerTab {
return resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
});
}
export interface OpenFileExplorerPatch {
mobileView?: MobilePanelView;
desktop?: DesktopSidebarState;
explorerTab: ExplorerTab;
}
export function buildOpenFileExplorerPatch(
state: PanelCoreState,
input: ExplorerPanelIntent,
): OpenFileExplorerPatch {
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
mobileView: "file-explorer",
explorerTab: resolvedTab,
};
}
return {
desktop: { ...state.desktop, fileExplorerOpen: true },
explorerTab: resolvedTab,
};
}
export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch
| { mobileView: MobilePanelView }
| { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch(
state: PanelCoreState,
input: ExplorerPanelIntent,
): ToggleFileExplorerPatch {
const isOpen = selectIsFileExplorerOpen(state, input);
if (!isOpen) {
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
return { mobileView: "agent" };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
type MigratablePanelState = Record<string, unknown>;
function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void {
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
if (typeof state.explorerFilesSplitRatio !== "number") {
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
} else {
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(state.explorerFilesSplitRatio);
}
}
function migratePanelV3Explorer(state: MigratablePanelState, isWeb: boolean): void {
if (
isWeb &&
typeof state.explorerWidth === "number" &&
(state.explorerWidth === 400 || state.explorerWidth === 520)
) {
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
}
}
function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: number): void {
if (
version < 4 ||
typeof state.explorerTabByCheckout !== "object" ||
!state.explorerTabByCheckout
) {
state.explorerTabByCheckout = {};
return;
}
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
continue;
}
next[key] = value;
}
state.explorerTabByCheckout = next;
}
function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
const desktop = state.desktop as Record<string, unknown> | undefined;
if (!desktop) {
return;
}
if ("zoomed" in desktop) {
desktop.focusModeEnabled = desktop.zoomed;
delete desktop.zoomed;
}
if ("focused" in desktop) {
desktop.focusModeEnabled = desktop.focused;
delete desktop.focused;
}
if (typeof desktop.focusModeEnabled !== "boolean") {
desktop.focusModeEnabled = false;
}
}
export function migratePanelState(
persistedState: unknown,
version: number,
options: { isWeb: boolean },
): MigratablePanelState {
const state = (persistedState ?? {}) as MigratablePanelState;
const { isWeb } = options;
if (version < 2) {
migratePanelV2Explorer(state, isWeb);
}
if (version < 3) {
migratePanelV3Explorer(state, isWeb);
}
if (!isExplorerTab(state.explorerTab)) {
state.explorerTab = "changes";
}
migratePanelExplorerTabByCheckout(state, version);
if (version < 8) {
migratePanelDesktopFocusMode(state);
}
if (version < 6 || typeof state.sidebarWidth !== "number") {
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
if (
version < 9 ||
typeof state.expandedPathsByWorkspace !== "object" ||
!state.expandedPathsByWorkspace
) {
state.expandedPathsByWorkspace = {};
}
if (
version < 10 ||
typeof state.diffExpandedPathsByWorkspace !== "object" ||
!state.diffExpandedPathsByWorkspace
) {
state.diffExpandedPathsByWorkspace = {};
}
return state;
}
export { buildExplorerCheckoutKey, resolveExplorerTabForCheckout };
export type { ExplorerTab, ExplorerCheckoutContext };
@@ -1,366 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
useHasWorkspaces,
useRecommendedProjectPaths,
useResolveWorkspaceIdByCwd,
useWorkspace,
useWorkspaceExecutionAuthority,
useWorkspaceFields,
useWorkspaceKeys,
useWorkspaceStatusesForBadges,
useWorkspaceStructure,
} from "./session-store-hooks";
import { useSidebarOrderStore } from "./sidebar-order-store";
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
const SERVER_ID = "test-server";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
act(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
});
}
afterEach(() => {
act(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
});
});
describe("useWorkspace", () => {
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
const workspace = createWorkspace({ id: "workspace-a" });
act(() => {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]]));
});
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
expect(result.current).toBe(workspace);
});
it("keeps the descriptor reference for unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]);
});
expect(result.current).not.toBe(before);
});
it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => {
const workspace = createWorkspace({ id: "workspace-a", scripts: [] });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
const before = result.current;
act(() => {
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]]));
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceExecutionAuthority", () => {
it("preserves the old missing-workspace message with the requested id", () => {
initializeWorkspaces([]);
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id"));
expect(result.current).toMatchObject({
ok: false,
message: "Workspace not found: missing-id",
});
});
it("keeps deep-equal authority references under unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceFields", () => {
it("keeps deep-equal projection references until projected fields change", () => {
const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" });
initializeWorkspaces([workspace]);
const selectIdentity = (current: { id: string; name: string }) => ({
identity: {
id: current.id,
name: current.name,
},
});
const { result } = renderHook(() =>
useWorkspaceFields(SERVER_ID, workspace.id, selectIdentity),
);
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
...workspace,
name: "A renamed",
status: "running",
},
]);
});
expect(result.current).not.toBe(before);
});
});
describe("useWorkspaceStructure", () => {
it("changes for membership updates but not status-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
});
const afterAdd = result.current;
expect(afterAdd).not.toBe(before);
expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
});
expect(result.current).toBe(afterAdd);
});
it("changes when a structure-relevant project identity field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectDisplayName: "Project 1",
});
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
});
expect(result.current).not.toBe(before);
});
it("changes when persisted sidebar project order changes", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
projectId: "project-a",
projectDisplayName: "Project A",
});
const workspaceB = createWorkspace({
id: "workspace-b",
projectId: "project-b",
projectDisplayName: "Project B",
});
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
const before = result.current;
act(() => {
useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]);
});
expect(result.current).not.toBe(before);
});
});
describe("useWorkspaceKeys", () => {
it("changes for reorder updates but not content-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID));
const before = result.current;
expect(before).toEqual(["workspace-a", "workspace-b"]);
act(() => {
useSessionStore.getState().setWorkspaces(
SERVER_ID,
new Map([
[workspaceB.id, workspaceB],
[workspaceA.id, workspaceA],
]),
);
});
const afterReorder = result.current;
expect(afterReorder).not.toBe(before);
expect(afterReorder).toEqual(["workspace-b", "workspace-a"]);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
});
expect(result.current).toBe(afterReorder);
});
});
describe("useRecommendedProjectPaths", () => {
it("updates when an existing workspace project root changes", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]);
});
expect(result.current).toEqual(["/repo/b"]);
});
it("keeps the path list reference under unrelated workspace updates", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useHasWorkspaces", () => {
it("stays stable when workspace membership changes without flipping the boolean", () => {
const workspaceA = createWorkspace({ id: "workspace-a" });
const workspaceB = createWorkspace({ id: "workspace-b" });
initializeWorkspaces([workspaceA]);
const { result } = renderHook(() => useHasWorkspaces(SERVER_ID));
const before = result.current;
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
});
expect(result.current).toBe(before);
});
});
describe("useResolveWorkspaceIdByCwd", () => {
it("resolves by cwd and stays stable under unrelated updates", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
workspaceDirectory: "/repo/a",
});
const workspaceB = createWorkspace({
id: "workspace-b",
workspaceDirectory: "/repo/b",
});
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a"));
const before = result.current;
expect(before).toBe("workspace-a");
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
});
expect(result.current).toBe(before);
});
});
describe("useWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
initializeWorkspaces([workspaceA, workspaceB]);
const { result } = renderHook(() => useWorkspaceStatusesForBadges());
const before = result.current;
expect(before).toEqual(["done", "attention"]);
act(() => {
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
});
expect(result.current).toBe(before);
act(() => {
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
});
expect(result.current).not.toBe(before);
expect(result.current).toEqual(["failed", "attention"]);
});
});
@@ -0,0 +1,138 @@
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import {
composeWorkspaceStructure,
selectHasWorkspaces,
selectProjectOrder,
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceExecutionAuthority,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
type WorkspaceStructure,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
import type { WorkspaceExecutionAuthorityResult } from "@/utils/workspace-execution";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
// These are the ONLY supported ways to read workspaces from the session store.
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
// or the sessions dict — it breaks re-render isolation.
export type {
DesktopBadgeWorkspaceStatus,
WorkspaceStructure,
WorkspaceStructureProject,
} from "./selectors";
export function useWorkspace(
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspace(state, serverId, workspaceId),
workspaceEqualityFns.identity,
);
}
export function useWorkspaceFields<T>(
serverId: string | null,
workspaceId: string | null,
project: (w: WorkspaceDescriptor) => T,
): T | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceFields(state, serverId, workspaceId, project),
workspaceEqualityFns.deep,
);
}
export function useWorkspaceExecutionAuthority(
serverId: string | null,
workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceExecutionAuthority(state, serverId, workspaceId),
workspaceEqualityFns.deep,
);
}
export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure {
const projects = useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStructureProjects(state, serverId),
workspaceEqualityFns.deep,
);
const projectOrder = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) => selectProjectOrder(state, serverId),
workspaceEqualityFns.deep,
);
const workspaceOrderByScope = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) => selectWorkspaceOrderByScopeForServer(state, serverId),
workspaceEqualityFns.deep,
);
return useMemo(
() =>
composeWorkspaceStructure({
serverId,
projects,
projectOrder,
workspaceOrderByScope,
}),
[projectOrder, projects, serverId, workspaceOrderByScope],
);
}
export function useWorkspaceKeys(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceKeys(state, serverId),
workspaceEqualityFns.deep,
);
}
export function useRecommendedProjectPaths(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectRecommendedProjectPaths(state, serverId),
workspaceEqualityFns.deep,
);
}
export function useHasWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectHasWorkspaces(state, serverId),
workspaceEqualityFns.identity,
);
}
export function useResolveWorkspaceIdByCwd(
serverId: string | null,
cwd: string | null | undefined,
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectResolveWorkspaceIdByCwd(state, serverId, cwd),
workspaceEqualityFns.identity,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
}
@@ -0,0 +1,423 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
composeWorkspaceStructure,
selectHasWorkspaces,
selectProjectOrder,
selectRecommendedProjectPaths,
selectResolveWorkspaceIdByCwd,
selectWorkspace,
selectWorkspaceExecutionAuthority,
selectWorkspaceFields,
selectWorkspaceKeys,
selectWorkspaceOrderByScopeForServer,
selectWorkspaceStatusesForBadges,
selectWorkspaceStructureProjects,
workspaceEqualityFns,
type SidebarOrderSnapshot,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
const SERVER_ID = "test-server";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
}
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
}
interface Subscribable<S> {
getState(): S;
subscribe(listener: (state: S, prev: S) => void): () => void;
}
interface TrackedSelector<T> {
readonly current: T;
stop(): void;
}
// Mirrors what useStoreWithEqualityFn does: hold onto the previous selection and only
// publish a new value when the equality function rejects it. Lets tests assert reference
// stability without React or a DOM.
function trackSelector<S, T>(
store: Subscribable<S>,
select: (state: S) => T,
eq: (a: T, b: T) => boolean,
): TrackedSelector<T> {
let current = select(store.getState());
const stop = store.subscribe((state) => {
const candidate = select(state);
if (!eq(candidate, current)) {
current = candidate;
}
});
return {
get current() {
return current;
},
stop,
};
}
function emptySidebarOrder(): SidebarOrderSnapshot {
return {
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
};
}
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
describe("selectWorkspace", () => {
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
const workspace = createWorkspace({ id: "workspace-a" });
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]]));
expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, workspace.id)).toBe(workspace);
});
it("keeps the descriptor reference for unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspace(state, SERVER_ID, workspaceA.id),
workspaceEqualityFns.identity,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]);
expect(tracked.current).not.toBe(before);
tracked.stop();
});
it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => {
const workspace = createWorkspace({ id: "workspace-a", scripts: [] });
initializeWorkspaces([workspace]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspace(state, SERVER_ID, workspace.id),
workspaceEqualityFns.identity,
);
const before = tracked.current;
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]]));
expect(tracked.current).toBe(before);
tracked.stop();
});
});
describe("selectWorkspaceExecutionAuthority", () => {
it("preserves the old missing-workspace message with the requested id", () => {
initializeWorkspaces([]);
expect(
selectWorkspaceExecutionAuthority(useSessionStore.getState(), SERVER_ID, "missing-id"),
).toMatchObject({
ok: false,
message: "Workspace not found: missing-id",
});
});
it("keeps deep-equal authority references under unrelated workspace updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceExecutionAuthority(state, SERVER_ID, workspaceA.id),
workspaceEqualityFns.deep,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
expect(tracked.current).toBe(before);
tracked.stop();
});
});
describe("selectWorkspaceFields", () => {
it("keeps deep-equal projection references until projected fields change", () => {
const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" });
initializeWorkspaces([workspace]);
const selectIdentity = (current: { id: string; name: string }) => ({
identity: { id: current.id, name: current.name },
});
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceFields(state, SERVER_ID, workspace.id, selectIdentity),
workspaceEqualityFns.deep,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
expect(tracked.current).toBe(before);
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, name: "A renamed", status: "running" }]);
expect(tracked.current).not.toBe(before);
tracked.stop();
});
});
describe("workspace structure composition", () => {
function snapshotStructure(
serverId: string,
sidebar: SidebarOrderSnapshot,
): ReturnType<typeof composeWorkspaceStructure> {
return composeWorkspaceStructure({
serverId,
projects: selectWorkspaceStructureProjects(useSessionStore.getState(), serverId),
projectOrder: selectProjectOrder(sidebar, serverId),
workspaceOrderByScope: selectWorkspaceOrderByScopeForServer(sidebar, serverId),
});
}
it("changes for membership updates but not status-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceStructureProjects(state, SERVER_ID),
workspaceEqualityFns.deep,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
const afterAdd = tracked.current;
expect(afterAdd).not.toBe(before);
expect(afterAdd[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
expect(tracked.current).toBe(afterAdd);
tracked.stop();
});
it("changes when a structure-relevant project identity field changes", () => {
const workspace = createWorkspace({
id: "workspace-a",
projectDisplayName: "Project 1",
});
initializeWorkspaces([workspace]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceStructureProjects(state, SERVER_ID),
workspaceEqualityFns.deep,
);
const before = tracked.current;
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
expect(tracked.current).not.toBe(before);
tracked.stop();
});
it("changes the composed structure when persisted sidebar project order changes", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
projectId: "project-a",
projectDisplayName: "Project A",
});
const workspaceB = createWorkspace({
id: "workspace-b",
projectId: "project-b",
projectDisplayName: "Project B",
});
initializeWorkspaces([workspaceA, workspaceB]);
const before = snapshotStructure(SERVER_ID, emptySidebarOrder());
const after = snapshotStructure(SERVER_ID, {
...emptySidebarOrder(),
projectOrderByServerId: { [SERVER_ID]: ["project-b", "project-a"] },
});
expect(after.projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]);
expect(after).not.toEqual(before);
});
});
describe("selectWorkspaceKeys", () => {
it("changes for reorder updates but not content-only updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceKeys(state, SERVER_ID),
workspaceEqualityFns.deep,
);
const before = tracked.current;
expect(before).toEqual(["workspace-a", "workspace-b"]);
useSessionStore.getState().setWorkspaces(
SERVER_ID,
new Map([
[workspaceB.id, workspaceB],
[workspaceA.id, workspaceA],
]),
);
const afterReorder = tracked.current;
expect(afterReorder).not.toBe(before);
expect(afterReorder).toEqual(["workspace-b", "workspace-a"]);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
expect(tracked.current).toBe(afterReorder);
tracked.stop();
});
});
describe("selectRecommendedProjectPaths", () => {
it("updates when an existing workspace project root changes", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]);
expect(selectRecommendedProjectPaths(useSessionStore.getState(), SERVER_ID)).toEqual([
"/repo/b",
]);
});
it("keeps the path list reference under unrelated workspace updates", () => {
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
initializeWorkspaces([workspace]);
const tracked = trackSelector(
useSessionStore,
(state) => selectRecommendedProjectPaths(state, SERVER_ID),
workspaceEqualityFns.deep,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
expect(tracked.current).toBe(before);
tracked.stop();
});
});
describe("selectHasWorkspaces", () => {
it("stays stable when workspace membership changes without flipping the boolean", () => {
const workspaceA = createWorkspace({ id: "workspace-a" });
const workspaceB = createWorkspace({ id: "workspace-b" });
initializeWorkspaces([workspaceA]);
const tracked = trackSelector(
useSessionStore,
(state) => selectHasWorkspaces(state, SERVER_ID),
workspaceEqualityFns.identity,
);
const before = tracked.current;
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
expect(tracked.current).toBe(before);
tracked.stop();
});
});
describe("selectResolveWorkspaceIdByCwd", () => {
it("resolves by cwd and stays stable under unrelated updates", () => {
const workspaceA = createWorkspace({
id: "workspace-a",
workspaceDirectory: "/repo/a",
});
const workspaceB = createWorkspace({
id: "workspace-b",
workspaceDirectory: "/repo/b",
});
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectResolveWorkspaceIdByCwd(state, SERVER_ID, "/repo/a"),
workspaceEqualityFns.identity,
);
const before = tracked.current;
expect(before).toBe("workspace-a");
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
expect(tracked.current).toBe(before);
tracked.stop();
});
});
describe("selectWorkspaceStatusesForBadges", () => {
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
initializeWorkspaces([workspaceA, workspaceB]);
const tracked = trackSelector(
useSessionStore,
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
const before = tracked.current;
expect(before).toEqual(["done", "attention"]);
useSessionStore
.getState()
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
expect(tracked.current).toBe(before);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
expect(tracked.current).not.toBe(before);
expect(tracked.current).toEqual(["failed", "attention"]);
tracked.stop();
});
});
@@ -1,7 +1,4 @@
import equal from "fast-deep-equal";
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import {
@@ -10,11 +7,7 @@ import {
resolveWorkspaceMapKeyByIdentity,
type WorkspaceExecutionAuthorityResult,
} from "@/utils/workspace-execution";
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
// These are the ONLY supported ways to read workspaces from the session store.
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
// or the sessions dict — it breaks re-render isolation.
import type { WorkspaceDescriptor } from "../session-store";
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
@@ -30,11 +23,23 @@ export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
export interface SessionsSnapshot {
sessions: Record<string, { workspaces: Map<string, WorkspaceDescriptor> }>;
}
export interface SidebarOrderSnapshot {
projectOrderByServerId: Record<string, string[]>;
workspaceOrderByServerAndProject: Record<string, string[]>;
}
const EMPTY_WORKSPACE_KEYS: string[] = [];
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
export const workspaceEqualityFns = {
identity: Object.is as (a: unknown, b: unknown) => boolean,
deep: equal as (a: unknown, b: unknown) => boolean,
};
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`;
}
@@ -113,8 +118,8 @@ function applyStoredOrdering<T>(input: {
return ordered;
}
function selectWorkspace(
state: SessionStoreSnapshot,
export function selectWorkspace(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
@@ -129,53 +134,32 @@ function selectWorkspace(
return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
}
export function useWorkspace(
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspace(state, serverId, workspaceId),
Object.is,
);
}
export function useWorkspaceFields<T>(
export function selectWorkspaceFields<T>(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
project: (w: WorkspaceDescriptor) => T,
): T | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const workspace = selectWorkspace(state, serverId, workspaceId);
return workspace ? project(workspace) : null;
},
equal,
);
const workspace = selectWorkspace(state, serverId, workspaceId);
return workspace ? project(workspace) : null;
}
export function useWorkspaceExecutionAuthority(
export function selectWorkspaceExecutionAuthority(
state: SessionsSnapshot,
serverId: string | null,
workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (serverId === null || workspaceId === null) {
return null;
}
return getWorkspaceExecutionAuthority({
workspaces: state.sessions[serverId]?.workspaces,
workspaceId,
});
},
equal,
);
if (serverId === null || workspaceId === null) {
return null;
}
return getWorkspaceExecutionAuthority({
workspaces: state.sessions[serverId]?.workspaces,
workspaceId,
});
}
function selectWorkspaceStructureProjects(
state: SessionStoreSnapshot,
export function selectWorkspaceStructureProjects(
state: SessionsSnapshot,
serverId: string | null,
): WorkspaceStructureProject[] {
if (!serverId) {
@@ -231,8 +215,14 @@ function selectWorkspaceStructureProjects(
return projects;
}
function selectWorkspaceOrderByScopeForServer(
workspaceOrderByScope: Record<string, string[]>,
export function selectProjectOrder(state: SidebarOrderSnapshot, serverId: string | null): string[] {
return serverId
? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS)
: EMPTY_WORKSPACE_KEYS;
}
export function selectWorkspaceOrderByScopeForServer(
state: SidebarOrderSnapshot,
serverId: string | null,
): Record<string, string[]> {
if (!serverId) {
@@ -240,7 +230,7 @@ function selectWorkspaceOrderByScopeForServer(
}
const prefix = `${serverId.trim()}::`;
const relevantOrderByScope: Record<string, string[]> = {};
for (const [scopeKey, order] of Object.entries(workspaceOrderByScope)) {
for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject)) {
if (scopeKey.startsWith(prefix)) {
relevantOrderByScope[scopeKey] = order;
}
@@ -248,136 +238,96 @@ function selectWorkspaceOrderByScopeForServer(
return relevantOrderByScope;
}
export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure {
const projects = useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStructureProjects(state, serverId),
equal,
);
const projectOrder = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) =>
serverId
? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS)
: EMPTY_WORKSPACE_KEYS,
equal,
);
const workspaceOrderByScope = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) =>
selectWorkspaceOrderByScopeForServer(state.workspaceOrderByServerAndProject, serverId),
equal,
);
export function composeWorkspaceStructure(input: {
serverId: string | null;
projects: WorkspaceStructureProject[];
projectOrder: readonly string[];
workspaceOrderByScope: Record<string, readonly string[]>;
}): WorkspaceStructure {
if (!input.serverId || input.projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
}
return useMemo(() => {
if (!serverId || projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
}
const orderedProjects = applyStoredOrdering({
items: input.projects.map((project) => {
const workspaceOrder =
input.workspaceOrderByScope[
getWorkspaceOrderScopeKey(input.serverId as string, project.projectKey)
] ?? EMPTY_WORKSPACE_KEYS;
const workspaceItems = project.workspaceKeys.map((workspaceId) => ({
workspaceId,
workspaceKey: `${input.serverId as string}:${workspaceId}`,
}));
return {
...project,
workspaceKeys: applyStoredOrdering({
items: workspaceItems,
storedOrder: workspaceOrder,
getKey: (workspace) => workspace.workspaceKey,
}).map((workspace) => workspace.workspaceId),
};
}),
storedOrder: input.projectOrder,
getKey: (project) => project.projectKey,
});
const orderedProjects = applyStoredOrdering({
items: projects.map((project) => {
const workspaceOrder =
workspaceOrderByScope[getWorkspaceOrderScopeKey(serverId, project.projectKey)] ??
EMPTY_WORKSPACE_KEYS;
const workspaceItems = project.workspaceKeys.map((workspaceId) => ({
workspaceId,
workspaceKey: `${serverId}:${workspaceId}`,
}));
return {
...project,
workspaceKeys: applyStoredOrdering({
items: workspaceItems,
storedOrder: workspaceOrder,
getKey: (workspace) => workspace.workspaceKey,
}).map((workspace) => workspace.workspaceId),
};
}),
storedOrder: projectOrder,
getKey: (project) => project.projectKey,
});
return { projects: orderedProjects };
}, [projectOrder, projects, serverId, workspaceOrderByScope]);
return { projects: orderedProjects };
}
export function useWorkspaceKeys(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS;
},
equal,
);
export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS;
}
export function useRecommendedProjectPaths(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces) {
return EMPTY_WORKSPACE_KEYS;
}
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
},
equal,
);
export function selectRecommendedProjectPaths(
state: SessionsSnapshot,
serverId: string | null,
): string[] {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces) {
return EMPTY_WORKSPACE_KEYS;
}
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}
export function useHasWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return false;
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
},
Object.is,
);
export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string | null): boolean {
if (!serverId) {
return false;
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
}
export function useResolveWorkspaceIdByCwd(
export function selectResolveWorkspaceIdByCwd(
state: SessionsSnapshot,
serverId: string | null,
cwd: string | null | undefined,
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId || !cwd) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
},
Object.is,
);
if (!serverId || !cwd) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
},
equal,
);
export function selectWorkspaceStatusesForBadges(
state: SessionsSnapshot,
): DesktopBadgeWorkspaceStatus[] {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
}
@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(),
removeItem: vi.fn(),
},
}));
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
describe("sidebar-collapsed-sections-store", () => {
beforeEach(() => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(),
});
});
it("tracks collapsed project keys as a Set", () => {
const store = useSidebarCollapsedSectionsStore.getState();
store.setProjectCollapsed("project-a", true);
store.toggleProjectCollapsed("project-b");
store.toggleProjectCollapsed("project-a");
expect(Array.from(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys)).toEqual([
"project-b",
]);
});
it("serializes collapsed project keys for preference storage", () => {
useSidebarCollapsedSectionsStore.setState({
collapsedProjectKeys: new Set(["project-a", "project-b"]),
});
const partialize = useSidebarCollapsedSectionsStore.persist.getOptions().partialize;
expect(partialize?.(useSidebarCollapsedSectionsStore.getState())).toEqual({
collapsedProjectKeys: ["project-a", "project-b"],
});
});
it("restores collapsed project keys from persisted preferences", () => {
const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge;
const restored = merge?.(
{
collapsedProjectKeys: ["project-a", "project-b", 42],
},
useSidebarCollapsedSectionsStore.getState(),
);
expect(Array.from(restored?.collapsedProjectKeys ?? [])).toEqual(["project-a", "project-b"]);
});
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
const merge = useSidebarCollapsedSectionsStore.persist.getOptions().merge;
const currentState = useSidebarCollapsedSectionsStore.getState();
expect(merge?.({}, currentState)).toBe(currentState);
expect(merge?.({ collapsedProjectKeys: [] }, currentState)).toBe(currentState);
});
});
@@ -1,91 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface SidebarCollapsedSectionsState {
collapsedProjectKeys: Set<string>;
toggleProjectCollapsed: (projectKey: string) => void;
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
}
interface PersistedSidebarCollapsedSectionsState {
collapsedProjectKeys?: string[];
}
function serializeCollapsedProjectKeys(keys: Set<string>): string[] {
return Array.from(keys);
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
return new Set(value.filter((key): key is string => typeof key === "string"));
}
function areSetsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const key of left) {
if (!right.has(key)) {
return false;
}
}
return true;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist(
(set) => ({
collapsedProjectKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
}),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => {
const next = new Set(state.collapsedProjectKeys);
if (collapsed) {
next.add(projectKey);
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
}),
}),
{
name: "sidebar-collapsed-sections",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
collapsedProjectKeys: serializeCollapsedProjectKeys(state.collapsedProjectKeys),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as PersistedSidebarCollapsedSectionsState | undefined;
if (!persisted?.collapsedProjectKeys) {
return currentState;
}
const collapsedProjectKeys = deserializeCollapsedProjectKeys(
persisted.collapsedProjectKeys,
);
if (areSetsEqual(currentState.collapsedProjectKeys, collapsedProjectKeys)) {
return currentState;
}
return {
...currentState,
collapsedProjectKeys,
};
},
},
),
);
@@ -0,0 +1,37 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
type CollapsedProjectsState,
mergePersistedCollapsedProjects,
serializeCollapsedProjects,
setProjectCollapsed,
toggleProjectCollapsed,
} from "./state";
interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
toggleProjectCollapsed: (projectKey: string) => void;
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist(
(set) => ({
collapsedProjectKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => toggleProjectCollapsed(state, projectKey)),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => setProjectCollapsed(state, projectKey, collapsed)),
}),
{
name: "sidebar-collapsed-sections",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => serializeCollapsedProjects(state),
merge: (persistedState, currentState) =>
mergePersistedCollapsedProjects(
persistedState as { collapsedProjectKeys?: unknown } | undefined,
currentState,
),
},
),
);
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
type CollapsedProjectsState,
mergePersistedCollapsedProjects,
serializeCollapsedProjects,
setProjectCollapsed,
toggleProjectCollapsed,
} from "@/stores/sidebar-collapsed-sections-store/state";
function emptyState(): CollapsedProjectsState {
return { collapsedProjectKeys: new Set() };
}
describe("sidebar collapsed projects transitions", () => {
it("tracks collapsed project keys as a Set", () => {
let state = emptyState();
state = setProjectCollapsed(state, "project-a", true);
state = toggleProjectCollapsed(state, "project-b");
state = toggleProjectCollapsed(state, "project-a");
expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]);
});
it("serializes collapsed project keys for preference storage", () => {
const state: CollapsedProjectsState = {
collapsedProjectKeys: new Set(["project-a", "project-b"]),
};
expect(serializeCollapsedProjects(state)).toEqual({
collapsedProjectKeys: ["project-a", "project-b"],
});
});
it("restores collapsed project keys from persisted preferences", () => {
const restored = mergePersistedCollapsedProjects(
{ collapsedProjectKeys: ["project-a", "project-b", 42] },
emptyState(),
);
expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]);
});
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
const currentState = emptyState();
expect(mergePersistedCollapsedProjects(undefined, currentState)).toBe(currentState);
expect(mergePersistedCollapsedProjects({}, currentState)).toBe(currentState);
expect(mergePersistedCollapsedProjects({ collapsedProjectKeys: [] }, currentState)).toBe(
currentState,
);
});
});
@@ -0,0 +1,73 @@
export interface CollapsedProjectsState {
collapsedProjectKeys: Set<string>;
}
export interface PersistedCollapsedProjects {
collapsedProjectKeys?: unknown;
}
export function toggleProjectCollapsed(
state: CollapsedProjectsState,
projectKey: string,
): CollapsedProjectsState {
const next = new Set(state.collapsedProjectKeys);
if (next.has(projectKey)) {
next.delete(projectKey);
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
}
export function setProjectCollapsed(
state: CollapsedProjectsState,
projectKey: string,
collapsed: boolean,
): CollapsedProjectsState {
const next = new Set(state.collapsedProjectKeys);
if (collapsed) {
next.add(projectKey);
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
}
export function serializeCollapsedProjects(state: CollapsedProjectsState): {
collapsedProjectKeys: string[];
} {
return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys) };
}
export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>(
persisted: PersistedCollapsedProjects | undefined,
current: S,
): S {
if (!persisted?.collapsedProjectKeys) {
return current;
}
const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys);
if (areSetsEqual(current.collapsedProjectKeys, restored)) {
return current;
}
return { ...current, collapsedProjectKeys: restored };
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
return new Set(value.filter((key): key is string => typeof key === "string"));
}
function areSetsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const key of left) {
if (!right.has(key)) {
return false;
}
}
return true;
}
@@ -9,62 +9,6 @@ import { selectSubagentsForParent } from "@/subagents";
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store";
vi.mock("lucide-react-native", () => ({
Archive: () => null,
Check: () => null,
ChevronDown: () => null,
ChevronRight: () => null,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? factory({
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500" },
iconSize: { sm: 14, md: 18 },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
border: "#444",
borderAccent: "#555",
accent: "#0a84ff",
palette: {
amber: { 500: "#ffbf00", 700: "#aa8000" },
blue: { 500: "#0a84ff" },
red: { 500: "#ff453a" },
green: { 500: "#30d158" },
},
},
})
: factory,
},
useUnistyles: () => ({ theme: { colors: {} } }),
withUnistyles: <T>(component: T) => component,
}));
vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: unknown }) => children,
TooltipTrigger: ({ children }: { children: unknown }) => children,
TooltipContent: () => null,
}));
vi.mock("@/screens/workspace/workspace-tab-presentation", () => ({
WorkspaceTabIcon: () => null,
}));
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
@@ -1,353 +0,0 @@
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 {
buildWorkspaceTabPersistenceKey,
useWorkspaceTabsStore,
} from "@/stores/workspace-tabs-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
describe("buildWorkspaceTabPersistenceKey", () => {
it("preserves opaque workspace ids instead of normalizing them like paths", () => {
expect(
buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: " setup\\workspace\\ ",
}),
).toBe("server-1:setup\\workspace\\");
});
});
describe("workspace-tabs-store retargetTab", () => {
beforeEach(() => {
useWorkspaceTabsStore.setState({
uiTabsByWorkspace: {},
tabOrderByWorkspace: {},
focusedTabIdByWorkspace: {},
});
});
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
const draftTabId = "draft_123";
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "left" },
});
useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
});
useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "right" },
});
useWorkspaceTabsStore.getState().focusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
});
const before = useWorkspaceTabsStore.getState();
const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? [];
const retargeted = useWorkspaceTabsStore.getState().retargetTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created" },
});
const after = useWorkspaceTabsStore.getState();
const afterOrder = after.tabOrderByWorkspace[workspaceKey] ?? [];
const tabs = after.uiTabsByWorkspace[workspaceKey] ?? [];
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
expect(retargeted).toBe(draftTabId);
expect(afterOrder).toEqual(beforeOrder);
expect(after.focusedTabIdByWorkspace[workspaceKey]).toBe(draftTabId);
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
});
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const terminalTabId = useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
});
expect(terminalTabId).toBe("terminal_term-1");
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBeUndefined();
const focusedTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
});
expect(focusedTabId).toBe("terminal_term-1");
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
"terminal_term-1",
);
});
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
const draftTabId = "draft_x";
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
});
useWorkspaceTabsStore.getState().retargetTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created-agent" },
});
const ensured = useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "created-agent" },
});
const state = useWorkspaceTabsStore.getState();
const tabs = state.uiTabsByWorkspace[workspaceKey] ?? [];
const order = state.tabOrderByWorkspace[workspaceKey] ?? [];
const matchingTabs = tabs.filter(
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent",
);
expect(ensured).toBe(draftTabId);
expect(matchingTabs).toHaveLength(1);
expect(order).toEqual([draftTabId]);
});
it("openDraftTab creates a draft tab and deduplicates by draftId", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const firstTabId = useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: "draft-1",
});
const secondTabId = useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: "draft-2",
});
const state = useWorkspaceTabsStore.getState();
expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("draft-2");
expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]);
expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([
{
tabId: "draft-1",
target: { kind: "draft", draftId: "draft-1" },
createdAt: expect.any(Number),
},
{
tabId: "draft-2",
target: { kind: "draft", draftId: "draft-2" },
createdAt: expect.any(Number),
},
]);
});
it("keeps draft setup on a retargeted tab", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const tabId = useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "agent-1" },
});
if (!tabId) {
throw new Error("Expected tab id");
}
expect(tabId).toBe("agent_agent-1");
useWorkspaceTabsStore.getState().retargetTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId,
target: {
kind: "draft",
draftId: "draft-replacement",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: { effort: "high" },
},
},
});
expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({
kind: "draft",
draftId: "draft-replacement",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: { effort: "high" },
},
});
});
it("updates an existing draft tab when the setup changes", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const first = useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "draft", draftId: "draft-1" },
});
const second = useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: {
kind: "draft",
draftId: "draft-1",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: {},
},
},
});
expect(second).toBe(first);
expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]).toHaveLength(1);
expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({
kind: "draft",
draftId: "draft-1",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: {},
},
});
});
it("retargeting a background draft keeps the currently focused tab focused", () => {
const draftTabId = "draft_background";
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
});
const focusedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
});
useWorkspaceTabsStore.getState().retargetTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created-agent" },
});
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
focusedFileTabId,
);
});
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const fileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
});
const terminalTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
});
expect(fileTabId).toBe("file_/repo/worktree/src/index.ts");
expect(terminalTabId).toBe("terminal_term-1");
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(
terminalTabId,
);
const reopenedFileTabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
});
expect(reopenedFileTabId).toBe(fileTabId);
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
});
it("builds a deterministic setup tab keyed by workspace id", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "setup", workspaceId: WORKSPACE_ID },
});
expect(tabId).toBe(`setup_${WORKSPACE_ID}`);
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId);
});
});
@@ -1,696 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceDraftTabSetup,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
export interface WorkspaceDraftTabSetup {
provider: AgentProvider;
cwd: string;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown>;
}
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget
| { kind: "setup"; workspaceId: string };
export interface WorkspaceTab {
tabId: string;
target: WorkspaceTabTarget;
createdAt: number;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function buildWorkspaceTabPersistenceKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
const serverId = trimNonEmpty(input.serverId);
const workspaceId = trimNonEmpty(input.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainRecord(value) ? value : undefined;
}
function normalizeTabOrder(list: unknown): string[] {
if (!Array.isArray(list)) {
return [];
}
const next: string[] = [];
const used = new Set<string>();
for (const value of list) {
const tabId = trimNonEmpty(typeof value === "string" ? value : null);
if (!tabId || used.has(tabId)) {
continue;
}
used.add(tabId);
next.push(tabId);
}
return next;
}
function ensureInOrder(input: { current: string[]; tabId: string }): string[] {
if (input.current.includes(input.tabId)) {
return input.current;
}
return [...input.current, input.tabId];
}
function retargetTabAtIndex(
tab: WorkspaceTab,
index: number,
targetIndex: number,
normalizedTarget: WorkspaceTabTarget,
): WorkspaceTab {
return index === targetIndex ? { ...tab, target: normalizedTarget } : tab;
}
function buildNextTabsForEnsure(args: {
currentTabs: WorkspaceTab[];
existingIndex: number;
effectiveTabId: string;
normalizedTarget: WorkspaceTabTarget;
createdAt: number;
}): WorkspaceTab[] {
const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args;
if (existingIndex < 0) {
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }];
}
const existing = currentTabs[existingIndex];
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
return currentTabs;
}
return currentTabs.map((tab, index) =>
retargetTabAtIndex(tab, index, existingIndex, normalizedTarget),
);
}
interface MigrationRawSources {
rawUiTabsByWorkspace: Record<string, unknown>;
rawFocused: Record<string, unknown>;
rawOrder: Record<string, unknown>;
legacyOrder: Record<string, unknown>;
}
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
const top = toObjectRecord(persistedState) ?? {};
const rawState = toObjectRecord(top.state) ?? top;
return {
rawUiTabsByWorkspace:
toObjectRecord(
rawState.uiTabsByWorkspace ??
rawState.openTabsByWorkspace ??
top.uiTabsByWorkspace ??
top.openTabsByWorkspace,
) ?? {},
rawFocused:
toObjectRecord(
rawState.focusedTabIdByWorkspace ??
rawState.lastFocusedTabByWorkspace ??
top.focusedTabIdByWorkspace,
) ?? {},
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
legacyOrder:
toObjectRecord(
rawState.tabOrderByWorkspace ??
rawState.tabOrderLegacyByWorkspace ??
top.tabOrderLegacyByWorkspace,
) ?? {},
};
}
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
const kind = typeof raw.kind === "string" ? raw.kind : null;
if (kind === "draft" && typeof raw.draftId === "string") {
const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
return normalizeWorkspaceTabTarget({
kind: "draft",
draftId: raw.draftId,
...(setup ? { setup } : {}),
});
}
if (kind === "agent" && typeof raw.agentId === "string") {
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
}
if (kind === "terminal" && typeof raw.terminalId === "string") {
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
}
if (kind === "browser" && typeof raw.browserId === "string") {
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
}
if (kind === "file" && typeof raw.path === "string") {
return normalizeWorkspaceTabTarget({
kind: "file",
path: raw.path,
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
});
}
if (kind === "setup" && typeof raw.workspaceId === "string") {
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
}
return null;
}
function migrateSingleTab(rawTab: unknown): WorkspaceTab | null {
const record = toObjectRecord(rawTab);
if (!record) {
return null;
}
const rawTarget = toObjectRecord(record.target);
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
if (!normalizedTarget) {
return null;
}
const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null);
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
const rawCreatedAt = record.createdAt;
return {
tabId,
target: normalizedTarget,
createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : Date.now(),
};
}
interface MigratedTabsForKey {
nextUiTabs: WorkspaceTab[];
orderFromTabs: string[];
}
function migrateUiTabsForKey(rawEntries: unknown): MigratedTabsForKey {
const entries = Array.isArray(rawEntries) ? rawEntries : [];
const nextUiTabs: WorkspaceTab[] = [];
const orderFromTabs: string[] = [];
const usedOrder = new Set<string>();
for (const rawTab of entries) {
const migrated = migrateSingleTab(rawTab);
if (!migrated) {
continue;
}
if (!usedOrder.has(migrated.tabId)) {
usedOrder.add(migrated.tabId);
orderFromTabs.push(migrated.tabId);
}
nextUiTabs.push(migrated);
}
return { nextUiTabs, orderFromTabs };
}
function mergeExplicitTabOrder(
tabOrderByWorkspace: Record<string, string[]>,
rawOrder: Record<string, unknown>,
): void {
for (const key in rawOrder) {
const normalizedOrder = normalizeTabOrder(rawOrder[key]);
if (normalizedOrder.length === 0) {
continue;
}
const existing = tabOrderByWorkspace[key] ?? [];
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]);
}
}
function convertLegacyOrderEntry(entry: unknown): string | null {
const raw = typeof entry === "string" ? entry.trim() : "";
if (!raw) {
return null;
}
if (raw.startsWith("agent:")) {
const agentId = raw.slice("agent:".length).trim();
return agentId ? `agent_${agentId}` : null;
}
if (raw.startsWith("terminal:")) {
const terminalId = raw.slice("terminal:".length).trim();
return terminalId ? `terminal_${terminalId}` : null;
}
return null;
}
function normalizeLegacyOrderList(list: unknown[]): string[] {
const result: string[] = [];
for (const entry of list) {
const converted = convertLegacyOrderEntry(entry);
if (converted) {
result.push(converted);
}
}
return result;
}
function mergeLegacyTabOrder(
tabOrderByWorkspace: Record<string, string[]>,
legacyOrder: Record<string, unknown>,
): void {
for (const key in legacyOrder) {
const list = legacyOrder[key];
if (!Array.isArray(list) || list.length === 0) {
continue;
}
const normalizedLegacyOrder = normalizeLegacyOrderList(list);
if (normalizedLegacyOrder.length === 0) {
continue;
}
const existing = tabOrderByWorkspace[key] ?? [];
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
}
}
function resolveFocusedTabId(rawValue: unknown): string | null {
if (typeof rawValue === "string") {
return trimNonEmpty(rawValue);
}
if (!rawValue || typeof rawValue !== "object") {
return null;
}
const value = rawValue as {
kind?: string;
agentId?: string;
terminalId?: string;
draftId?: string;
};
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
return `agent_${value.agentId.trim()}`;
}
if (
value.kind === "terminal" &&
typeof value.terminalId === "string" &&
value.terminalId.trim()
) {
return `terminal_${value.terminalId.trim()}`;
}
if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) {
return value.draftId.trim();
}
return null;
}
function migrateFocusedTabIds(
focusedTabIdByWorkspace: Record<string, string>,
rawFocused: Record<string, unknown>,
): void {
for (const key in rawFocused) {
const resolved = resolveFocusedTabId(rawFocused[key]);
if (resolved) {
focusedTabIdByWorkspace[key] = resolved;
}
}
}
function migrateWorkspaceTabsState(persistedState: unknown): {
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
tabOrderByWorkspace: Record<string, string[]>;
focusedTabIdByWorkspace: Record<string, string>;
} {
const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } =
extractMigrationRawSources(persistedState);
const uiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
const tabOrderByWorkspace: Record<string, string[]> = {};
const focusedTabIdByWorkspace: Record<string, string> = {};
for (const key in rawUiTabsByWorkspace) {
const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(rawUiTabsByWorkspace[key]);
if (nextUiTabs.length > 0) {
uiTabsByWorkspace[key] = nextUiTabs;
}
if (orderFromTabs.length > 0) {
tabOrderByWorkspace[key] = orderFromTabs;
}
}
mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder);
mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder);
migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused);
return {
uiTabsByWorkspace,
tabOrderByWorkspace,
focusedTabIdByWorkspace,
};
}
interface WorkspaceTabsState {
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
tabOrderByWorkspace: Record<string, string[]>;
focusedTabIdByWorkspace: Record<string, string>;
openDraftTab: (input: {
serverId: string;
workspaceId: string;
draftId: string;
}) => string | null;
ensureTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
openOrFocusTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
retargetTab: (input: {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
}) => string | null;
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
}
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
persist(
(set, get) => ({
uiTabsByWorkspace: {},
tabOrderByWorkspace: {},
focusedTabIdByWorkspace: {},
openDraftTab: ({ serverId, workspaceId, draftId }) => {
const normalizedDraftId = trimNonEmpty(draftId);
if (!normalizedDraftId) {
return null;
}
return get().openOrFocusTab({
serverId,
workspaceId,
target: { kind: "draft", draftId: normalizedDraftId },
});
},
ensureTab: ({ serverId, workspaceId, target }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!key || !normalizedTarget) {
return null;
}
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
let resolvedTabId = deterministicTabId;
const now = Date.now();
set((state) => {
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const tabWithSameTarget =
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ??
null;
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
resolvedTabId = effectiveTabId;
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
const nextTabs = buildNextTabsForEnsure({
currentTabs,
existingIndex,
effectiveTabId,
normalizedTarget,
createdAt: now,
});
return {
uiTabsByWorkspace:
nextTabs === currentTabs
? state.uiTabsByWorkspace
: { ...state.uiTabsByWorkspace, [key]: nextTabs },
tabOrderByWorkspace:
nextOrder === currentOrder
? state.tabOrderByWorkspace
: { ...state.tabOrderByWorkspace, [key]: nextOrder },
};
});
return resolvedTabId;
},
openOrFocusTab: ({ serverId, workspaceId, target }) => {
const tabId = get().ensureTab({ serverId, workspaceId, target });
if (!tabId) {
return null;
}
get().focusTab({ serverId, workspaceId, tabId });
return tabId;
},
focusTab: ({ serverId, workspaceId, tabId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTabId = trimNonEmpty(tabId);
if (!key || !normalizedTabId) {
return;
}
set((state) => {
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
return state;
}
return {
...state,
focusedTabIdByWorkspace: {
...state.focusedTabIdByWorkspace,
[key]: normalizedTabId,
},
};
});
},
closeTab: ({ serverId, workspaceId, tabId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTabId = trimNonEmpty(tabId);
if (!key || !normalizedTabId) {
return;
}
set((state) => {
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const nextOrder = currentOrder.filter((value) => value !== normalizedTabId);
let nextUiTabsByWorkspace: typeof state.uiTabsByWorkspace;
if (nextTabs.length === 0) {
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
nextUiTabsByWorkspace = rest;
} else if (nextTabs.length === currentTabs.length) {
nextUiTabsByWorkspace = state.uiTabsByWorkspace;
} else {
nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs };
}
let nextTabOrderByWorkspace: typeof state.tabOrderByWorkspace;
if (nextOrder.length === 0) {
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
nextTabOrderByWorkspace = rest;
} else if (nextOrder.length === currentOrder.length) {
nextTabOrderByWorkspace = state.tabOrderByWorkspace;
} else {
nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder };
}
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
const nextFocused =
currentFocused !== normalizedTabId
? currentFocused
: (nextOrder[nextOrder.length - 1] ?? null);
const nextFocusedByWorkspace = (() => {
if (!nextFocused) {
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
return rest;
}
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
})();
const tabsChanged = nextTabs.length !== currentTabs.length;
const orderChanged = nextOrder.length !== currentOrder.length;
const focusChanged =
(state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null);
if (!tabsChanged && !orderChanged && !focusChanged) {
return state;
}
return {
uiTabsByWorkspace: nextUiTabsByWorkspace,
tabOrderByWorkspace: nextTabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedByWorkspace,
};
});
},
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!key || !normalizedTabId || !normalizedTarget) {
return null;
}
let retargetedTabId: string | null = null;
set((state) => {
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
if (index < 0) {
return state;
}
const currentTarget = currentTabs[index]?.target;
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
return state;
}
const nextTabs = currentTabs.map((tab, tabIndex) =>
tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab,
);
retargetedTabId = normalizedTabId;
return {
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
tabOrderByWorkspace: state.tabOrderByWorkspace,
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
};
});
return retargetedTabId;
},
reorderTabs: ({ serverId, workspaceId, tabIds }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return;
}
const normalized = normalizeTabOrder(tabIds);
set((state) => {
const current = state.tabOrderByWorkspace[key] ?? [];
if (current.length === normalized.length) {
let same = true;
for (let i = 0; i < current.length; i += 1) {
if (current[i] !== normalized[i]) {
same = false;
break;
}
}
if (same) {
return state;
}
}
return {
...state,
tabOrderByWorkspace: {
...state.tabOrderByWorkspace,
[key]: normalized,
},
};
});
},
getWorkspaceTabs: ({ serverId, workspaceId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return [];
}
return get().uiTabsByWorkspace[key] ?? [];
},
purgeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return;
}
set((state) => {
if (
!(key in state.uiTabsByWorkspace) &&
!(key in state.tabOrderByWorkspace) &&
!(key in state.focusedTabIdByWorkspace)
) {
return state;
}
const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace;
const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace;
const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } =
state.focusedTabIdByWorkspace;
return {
...state,
uiTabsByWorkspace: remainingUiTabsByWorkspace,
tabOrderByWorkspace: remainingTabOrderByWorkspace,
focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace,
};
});
},
}),
{
name: "workspace-tabs-state",
version: 5,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => {
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
for (const key in state.uiTabsByWorkspace) {
const tabs = (state.uiTabsByWorkspace[key] ?? [])
.map((tab) => {
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
const normalizedTabId = trimNonEmpty(tab.tabId);
if (!normalizedTarget || !normalizedTabId) {
return null;
}
return {
tabId: normalizedTabId,
target: normalizedTarget,
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(),
} satisfies WorkspaceTab;
})
.filter((tab): tab is WorkspaceTab => tab !== null);
if (tabs.length > 0) {
nextUiTabsByWorkspace[key] = tabs;
}
}
const nextTabOrderByWorkspace: Record<string, string[]> = {};
for (const key in state.tabOrderByWorkspace) {
const order = normalizeTabOrder(state.tabOrderByWorkspace[key]);
if (order.length > 0) {
nextTabOrderByWorkspace[key] = order;
}
}
const nextFocusedTabIdByWorkspace: Record<string, string> = {};
for (const key in state.focusedTabIdByWorkspace) {
const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
if (focusedTabId) {
nextFocusedTabIdByWorkspace[key] = focusedTabId;
}
}
return {
uiTabsByWorkspace: nextUiTabsByWorkspace,
tabOrderByWorkspace: nextTabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
};
},
migrate: (persistedState) => migrateWorkspaceTabsState(persistedState),
},
),
);
@@ -0,0 +1,124 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyCloseTab,
applyEnsureTab,
applyFocusTab,
applyOpenDraftTab,
applyOpenOrFocusTab,
applyPurgeWorkspace,
applyReorderTabs,
applyRetargetTab,
initialWorkspaceTabsCoreState,
migrateWorkspaceTabsState,
partializeWorkspaceTabsState,
selectWorkspaceTabs,
type WorkspaceTab,
type WorkspaceTabsCoreState,
type WorkspaceTabTarget,
} from "./state";
export { buildWorkspaceTabPersistenceKey } from "./state";
export type { WorkspaceDraftTabSetup, WorkspaceTab, WorkspaceTabTarget } from "./state";
interface WorkspaceTabsState extends WorkspaceTabsCoreState {
openDraftTab: (input: {
serverId: string;
workspaceId: string;
draftId: string;
}) => string | null;
ensureTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
openOrFocusTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
retargetTab: (input: {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
}) => string | null;
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
purgeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
}
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
persist(
(set, get) => ({
...initialWorkspaceTabsCoreState,
openDraftTab: ({ serverId, workspaceId, draftId }) => {
let resolved: string | null = null;
set((state) => {
const result = applyOpenDraftTab(state, {
serverId,
workspaceId,
draftId,
now: Date.now(),
});
resolved = result.tabId;
return result.state;
});
return resolved;
},
ensureTab: ({ serverId, workspaceId, target }) => {
let resolved: string | null = null;
set((state) => {
const result = applyEnsureTab(state, {
serverId,
workspaceId,
target,
now: Date.now(),
});
resolved = result.tabId;
return result.state;
});
return resolved;
},
openOrFocusTab: ({ serverId, workspaceId, target }) => {
let resolved: string | null = null;
set((state) => {
const result = applyOpenOrFocusTab(state, {
serverId,
workspaceId,
target,
now: Date.now(),
});
resolved = result.tabId;
return result.state;
});
return resolved;
},
focusTab: (input) => set((state) => applyFocusTab(state, input)),
closeTab: (input) => set((state) => applyCloseTab(state, input)),
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
let resolved: string | null = null;
set((state) => {
const result = applyRetargetTab(state, { serverId, workspaceId, tabId, target });
resolved = result.tabId;
return result.state;
});
return resolved;
},
reorderTabs: (input) => set((state) => applyReorderTabs(state, input)),
getWorkspaceTabs: (input) => selectWorkspaceTabs(get(), input),
purgeWorkspace: (input) => set((state) => applyPurgeWorkspace(state, input)),
}),
{
name: "workspace-tabs-state",
version: 5,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => partializeWorkspaceTabsState(state, { now: Date.now() }),
migrate: (persistedState) =>
migrateWorkspaceTabsState(persistedState, { now: Date.now() }) as WorkspaceTabsState,
},
),
);
@@ -0,0 +1,364 @@
import { describe, expect, it } from "vitest";
import {
applyCloseTab,
applyEnsureTab,
applyFocusTab,
applyOpenDraftTab,
applyOpenOrFocusTab,
applyRetargetTab,
buildWorkspaceTabPersistenceKey,
initialWorkspaceTabsCoreState,
type WorkspaceTabsCoreState,
} from "./state";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
const WORKSPACE_KEY = `${SERVER_ID}:${WORKSPACE_ID}`;
const NOW = 1_700_000_000_000;
function emptyState(): WorkspaceTabsCoreState {
return {
uiTabsByWorkspace: {},
tabOrderByWorkspace: {},
focusedTabIdByWorkspace: {},
};
}
describe("buildWorkspaceTabPersistenceKey", () => {
it("preserves opaque workspace ids instead of normalizing them like paths", () => {
expect(
buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: " setup\\workspace\\ ",
}),
).toBe("server-1:setup\\workspace\\");
});
});
describe("workspace-tabs-store reducers", () => {
it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => {
const draftTabId = "draft_123";
let state = emptyState();
state = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "left" },
now: NOW,
}).state;
state = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
now: NOW,
}).state;
state = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "right" },
now: NOW,
}).state;
state = applyFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
});
const beforeOrder = state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
const retargeted = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created" },
});
const afterOrder = retargeted.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
const tabs = retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null;
expect(retargeted.tabId).toBe(draftTabId);
expect(afterOrder).toEqual(beforeOrder);
expect(retargeted.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(draftTabId);
expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" });
});
it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => {
let state = emptyState();
const ensured = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
now: NOW,
});
state = ensured.state;
expect(ensured.tabId).toBe("terminal_term-1");
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBeUndefined();
const focused = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
now: NOW,
});
expect(focused.tabId).toBe("terminal_term-1");
expect(focused.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe("terminal_term-1");
});
it("ensureTab deduplicates by target when a retargeted tab already exists", () => {
const draftTabId = "draft_x";
let state = emptyState();
state = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
now: NOW,
}).state;
state = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created-agent" },
}).state;
const ensured = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "created-agent" },
now: NOW,
});
const tabs = ensured.state.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
const order = ensured.state.tabOrderByWorkspace[WORKSPACE_KEY] ?? [];
const matchingTabs = tabs.filter(
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent",
);
expect(ensured.tabId).toBe(draftTabId);
expect(matchingTabs).toHaveLength(1);
expect(order).toEqual([draftTabId]);
});
it("openDraftTab creates a draft tab and deduplicates by draftId", () => {
let state = emptyState();
const first = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: "draft-1",
now: NOW,
});
state = first.state;
const second = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: "draft-2",
now: NOW,
});
state = second.state;
expect(first.tabId).toBe("draft-1");
expect(second.tabId).toBe("draft-2");
expect(state.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual([first.tabId, second.tabId]);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toEqual([
{
tabId: "draft-1",
target: { kind: "draft", draftId: "draft-1" },
createdAt: NOW,
},
{
tabId: "draft-2",
target: { kind: "draft", draftId: "draft-2" },
createdAt: NOW,
},
]);
});
it("keeps draft setup on a retargeted tab", () => {
let state = emptyState();
const ensured = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "agent-1" },
now: NOW,
});
expect(ensured.tabId).toBe("agent_agent-1");
state = ensured.state;
const retargeted = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: ensured.tabId!,
target: {
kind: "draft",
draftId: "draft-replacement",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: { effort: "high" },
},
},
});
expect(retargeted.state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
kind: "draft",
draftId: "draft-replacement",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: { effort: "high" },
},
});
});
it("updates an existing draft tab when the setup changes", () => {
let state = emptyState();
const first = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "draft", draftId: "draft-1" },
now: NOW,
});
state = first.state;
const second = applyEnsureTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: {
kind: "draft",
draftId: "draft-1",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: {},
},
},
now: NOW,
});
state = second.state;
expect(second.tabId).toBe(first.tabId);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]?.[0]?.target).toEqual({
kind: "draft",
draftId: "draft-1",
setup: {
provider: "mock",
cwd: "/repo/worktree",
modeId: "load-test",
model: "ten-second-stream",
thinkingOptionId: null,
featureValues: {},
},
});
});
it("retargeting a background draft keeps the currently focused tab focused", () => {
const draftTabId = "draft_background";
let state = emptyState();
state = applyOpenDraftTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
draftId: draftTabId,
now: NOW,
}).state;
const file = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
now: NOW,
});
state = file.state;
state = applyRetargetTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: draftTabId,
target: { kind: "agent", agentId: "created-agent" },
}).state;
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(file.tabId);
});
it("openOrFocusTab re-focuses an existing file tab after the workspace focus changed", () => {
let state = emptyState();
const fileResult = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
now: NOW,
});
state = fileResult.state;
const terminalResult = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "terminal", terminalId: "term-1" },
now: NOW,
});
state = terminalResult.state;
expect(fileResult.tabId).toBe("file_/repo/worktree/src/index.ts");
expect(terminalResult.tabId).toBe("terminal_term-1");
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(terminalResult.tabId);
const reopened = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "file", path: "/repo/worktree/src/index.ts" },
now: NOW,
});
expect(reopened.tabId).toBe(fileResult.tabId);
expect(reopened.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(fileResult.tabId);
});
it("builds a deterministic setup tab keyed by workspace id", () => {
const result = applyOpenOrFocusTab(initialWorkspaceTabsCoreState, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "setup", workspaceId: WORKSPACE_ID },
now: NOW,
});
expect(result.tabId).toBe(`setup_${WORKSPACE_ID}`);
expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId);
});
it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => {
let state = emptyState();
const first = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "left" },
now: NOW,
});
state = first.state;
const second = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "right" },
now: NOW,
});
state = second.state;
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(second.tabId);
state = applyCloseTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
tabId: second.tabId!,
});
expect(state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(first.tabId);
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
});
});
@@ -0,0 +1,757 @@
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceDraftTabSetup,
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
} from "@/workspace-tabs/identity";
import type { WorkspaceFileTabTarget } from "@/workspace/file-open";
export interface WorkspaceDraftTabSetup {
provider: AgentProvider;
cwd: string;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown>;
}
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget
| { kind: "setup"; workspaceId: string };
export interface WorkspaceTab {
tabId: string;
target: WorkspaceTabTarget;
createdAt: number;
}
export interface WorkspaceTabsCoreState {
uiTabsByWorkspace: Record<string, WorkspaceTab[]>;
tabOrderByWorkspace: Record<string, string[]>;
focusedTabIdByWorkspace: Record<string, string>;
}
export const initialWorkspaceTabsCoreState: WorkspaceTabsCoreState = {
uiTabsByWorkspace: {},
tabOrderByWorkspace: {},
focusedTabIdByWorkspace: {},
};
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function buildWorkspaceTabPersistenceKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
const serverId = trimNonEmpty(input.serverId);
const workspaceId = trimNonEmpty(input.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isPlainRecord(value) ? value : undefined;
}
function normalizeTabOrder(list: unknown): string[] {
if (!Array.isArray(list)) {
return [];
}
const next: string[] = [];
const used = new Set<string>();
for (const value of list) {
const tabId = trimNonEmpty(typeof value === "string" ? value : null);
if (!tabId || used.has(tabId)) {
continue;
}
used.add(tabId);
next.push(tabId);
}
return next;
}
function ensureInOrder(input: { current: string[]; tabId: string }): string[] {
if (input.current.includes(input.tabId)) {
return input.current;
}
return [...input.current, input.tabId];
}
function retargetTabAtIndex(
tab: WorkspaceTab,
index: number,
targetIndex: number,
normalizedTarget: WorkspaceTabTarget,
): WorkspaceTab {
return index === targetIndex ? { ...tab, target: normalizedTarget } : tab;
}
function buildNextTabsForEnsure(args: {
currentTabs: WorkspaceTab[];
existingIndex: number;
effectiveTabId: string;
normalizedTarget: WorkspaceTabTarget;
createdAt: number;
}): WorkspaceTab[] {
const { currentTabs, existingIndex, effectiveTabId, normalizedTarget, createdAt } = args;
if (existingIndex < 0) {
return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt }];
}
const existing = currentTabs[existingIndex];
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
return currentTabs;
}
return currentTabs.map((tab, index) =>
retargetTabAtIndex(tab, index, existingIndex, normalizedTarget),
);
}
export interface EnsureTabInput {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
now: number;
}
export interface EnsureTabResult {
state: WorkspaceTabsCoreState;
tabId: string | null;
}
export function applyEnsureTab(
state: WorkspaceTabsCoreState,
input: EnsureTabInput,
): EnsureTabResult {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!key || !normalizedTarget) {
return { state, tabId: null };
}
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const tabWithSameTarget =
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ?? null;
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId });
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId);
const nextTabs = buildNextTabsForEnsure({
currentTabs,
existingIndex,
effectiveTabId,
normalizedTarget,
createdAt: input.now,
});
const uiTabsByWorkspace =
nextTabs === currentTabs
? state.uiTabsByWorkspace
: { ...state.uiTabsByWorkspace, [key]: nextTabs };
const tabOrderByWorkspace =
nextOrder === currentOrder
? state.tabOrderByWorkspace
: { ...state.tabOrderByWorkspace, [key]: nextOrder };
if (
uiTabsByWorkspace === state.uiTabsByWorkspace &&
tabOrderByWorkspace === state.tabOrderByWorkspace
) {
return { state, tabId: effectiveTabId };
}
return {
state: { ...state, uiTabsByWorkspace, tabOrderByWorkspace },
tabId: effectiveTabId,
};
}
export interface FocusTabInput {
serverId: string;
workspaceId: string;
tabId: string;
}
export function applyFocusTab(
state: WorkspaceTabsCoreState,
input: FocusTabInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTabId = trimNonEmpty(input.tabId);
if (!key || !normalizedTabId) {
return state;
}
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
return state;
}
return {
...state,
focusedTabIdByWorkspace: {
...state.focusedTabIdByWorkspace,
[key]: normalizedTabId,
},
};
}
export function applyOpenOrFocusTab(
state: WorkspaceTabsCoreState,
input: EnsureTabInput,
): EnsureTabResult {
const ensured = applyEnsureTab(state, input);
if (!ensured.tabId) {
return ensured;
}
const focused = applyFocusTab(ensured.state, {
serverId: input.serverId,
workspaceId: input.workspaceId,
tabId: ensured.tabId,
});
return { state: focused, tabId: ensured.tabId };
}
export interface OpenDraftTabInput {
serverId: string;
workspaceId: string;
draftId: string;
now: number;
}
export function applyOpenDraftTab(
state: WorkspaceTabsCoreState,
input: OpenDraftTabInput,
): EnsureTabResult {
const normalizedDraftId = trimNonEmpty(input.draftId);
if (!normalizedDraftId) {
return { state, tabId: null };
}
return applyOpenOrFocusTab(state, {
serverId: input.serverId,
workspaceId: input.workspaceId,
target: { kind: "draft", draftId: normalizedDraftId },
now: input.now,
});
}
export interface CloseTabInput {
serverId: string;
workspaceId: string;
tabId: string;
}
export function applyCloseTab(
state: WorkspaceTabsCoreState,
input: CloseTabInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTabId = trimNonEmpty(input.tabId);
if (!key || !normalizedTabId) {
return state;
}
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const nextOrder = currentOrder.filter((value) => value !== normalizedTabId);
let nextUiTabsByWorkspace: Record<string, WorkspaceTab[]>;
if (nextTabs.length === 0) {
const { [key]: _removed, ...rest } = state.uiTabsByWorkspace;
nextUiTabsByWorkspace = rest;
} else if (nextTabs.length === currentTabs.length) {
nextUiTabsByWorkspace = state.uiTabsByWorkspace;
} else {
nextUiTabsByWorkspace = { ...state.uiTabsByWorkspace, [key]: nextTabs };
}
let nextTabOrderByWorkspace: Record<string, string[]>;
if (nextOrder.length === 0) {
const { [key]: _removed, ...rest } = state.tabOrderByWorkspace;
nextTabOrderByWorkspace = rest;
} else if (nextOrder.length === currentOrder.length) {
nextTabOrderByWorkspace = state.tabOrderByWorkspace;
} else {
nextTabOrderByWorkspace = { ...state.tabOrderByWorkspace, [key]: nextOrder };
}
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
const nextFocused =
currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null);
const nextFocusedByWorkspace = (() => {
if (!nextFocused) {
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
return rest;
}
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
})();
const tabsChanged = nextTabs.length !== currentTabs.length;
const orderChanged = nextOrder.length !== currentOrder.length;
const focusChanged =
(state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null);
if (!tabsChanged && !orderChanged && !focusChanged) {
return state;
}
return {
uiTabsByWorkspace: nextUiTabsByWorkspace,
tabOrderByWorkspace: nextTabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedByWorkspace,
};
}
export interface RetargetTabInput {
serverId: string;
workspaceId: string;
tabId: string;
target: WorkspaceTabTarget;
}
export function applyRetargetTab(
state: WorkspaceTabsCoreState,
input: RetargetTabInput,
): EnsureTabResult {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
const normalizedTabId = trimNonEmpty(input.tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!key || !normalizedTabId || !normalizedTarget) {
return { state, tabId: null };
}
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
if (index < 0) {
return { state, tabId: null };
}
const currentTarget = currentTabs[index]?.target;
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
return { state, tabId: null };
}
const nextTabs = currentTabs.map((tab, tabIndex) =>
tabIndex === index ? Object.assign({}, tab, { target: normalizedTarget }) : tab,
);
return {
state: {
...state,
uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
},
tabId: normalizedTabId,
};
}
export interface ReorderTabsInput {
serverId: string;
workspaceId: string;
tabIds: string[];
}
export function applyReorderTabs(
state: WorkspaceTabsCoreState,
input: ReorderTabsInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!key) {
return state;
}
const normalized = normalizeTabOrder(input.tabIds);
const current = state.tabOrderByWorkspace[key] ?? [];
if (current.length === normalized.length) {
let same = true;
for (let i = 0; i < current.length; i += 1) {
if (current[i] !== normalized[i]) {
same = false;
break;
}
}
if (same) {
return state;
}
}
return {
...state,
tabOrderByWorkspace: {
...state.tabOrderByWorkspace,
[key]: normalized,
},
};
}
export interface PurgeWorkspaceInput {
serverId: string;
workspaceId: string;
}
export function applyPurgeWorkspace(
state: WorkspaceTabsCoreState,
input: PurgeWorkspaceInput,
): WorkspaceTabsCoreState {
const key = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!key) {
return state;
}
if (
!(key in state.uiTabsByWorkspace) &&
!(key in state.tabOrderByWorkspace) &&
!(key in state.focusedTabIdByWorkspace)
) {
return state;
}
const { [key]: _tabs, ...remainingUiTabsByWorkspace } = state.uiTabsByWorkspace;
const { [key]: _order, ...remainingTabOrderByWorkspace } = state.tabOrderByWorkspace;
const { [key]: _focused, ...remainingFocusedTabIdByWorkspace } = state.focusedTabIdByWorkspace;
return {
...state,
uiTabsByWorkspace: remainingUiTabsByWorkspace,
tabOrderByWorkspace: remainingTabOrderByWorkspace,
focusedTabIdByWorkspace: remainingFocusedTabIdByWorkspace,
};
}
export function selectWorkspaceTabs(
state: WorkspaceTabsCoreState,
input: { serverId: string; workspaceId: string },
): WorkspaceTab[] {
const key = buildWorkspaceTabPersistenceKey(input);
if (!key) {
return [];
}
return state.uiTabsByWorkspace[key] ?? [];
}
interface MigrationRawSources {
rawUiTabsByWorkspace: Record<string, unknown>;
rawFocused: Record<string, unknown>;
rawOrder: Record<string, unknown>;
legacyOrder: Record<string, unknown>;
}
function extractMigrationRawSources(persistedState: unknown): MigrationRawSources {
const top = toObjectRecord(persistedState) ?? {};
const rawState = toObjectRecord(top.state) ?? top;
return {
rawUiTabsByWorkspace:
toObjectRecord(
rawState.uiTabsByWorkspace ??
rawState.openTabsByWorkspace ??
top.uiTabsByWorkspace ??
top.openTabsByWorkspace,
) ?? {},
rawFocused:
toObjectRecord(
rawState.focusedTabIdByWorkspace ??
rawState.lastFocusedTabByWorkspace ??
top.focusedTabIdByWorkspace,
) ?? {},
rawOrder: toObjectRecord(rawState.tabOrderByWorkspace ?? top.tabOrderByWorkspace) ?? {},
legacyOrder:
toObjectRecord(
rawState.tabOrderByWorkspace ??
rawState.tabOrderLegacyByWorkspace ??
top.tabOrderLegacyByWorkspace,
) ?? {},
};
}
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
const kind = typeof raw.kind === "string" ? raw.kind : null;
if (kind === "draft" && typeof raw.draftId === "string") {
const setup = normalizeWorkspaceDraftTabSetup(raw.setup);
return normalizeWorkspaceTabTarget({
kind: "draft",
draftId: raw.draftId,
...(setup ? { setup } : {}),
});
}
if (kind === "agent" && typeof raw.agentId === "string") {
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
}
if (kind === "terminal" && typeof raw.terminalId === "string") {
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
}
if (kind === "browser" && typeof raw.browserId === "string") {
return normalizeWorkspaceTabTarget({ kind: "browser", browserId: raw.browserId });
}
if (kind === "file" && typeof raw.path === "string") {
return normalizeWorkspaceTabTarget({
kind: "file",
path: raw.path,
lineStart: typeof raw.lineStart === "number" ? raw.lineStart : undefined,
lineEnd: typeof raw.lineEnd === "number" ? raw.lineEnd : undefined,
});
}
if (kind === "setup" && typeof raw.workspaceId === "string") {
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
}
return null;
}
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {
const record = toObjectRecord(rawTab);
if (!record) {
return null;
}
const rawTarget = toObjectRecord(record.target);
const normalizedTarget = rawTarget ? coerceWorkspaceTabTarget(rawTarget) : null;
if (!normalizedTarget) {
return null;
}
const rawTabId = trimNonEmpty(typeof record.tabId === "string" ? record.tabId : null);
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
const rawCreatedAt = record.createdAt;
return {
tabId,
target: normalizedTarget,
createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : now,
};
}
interface MigratedTabsForKey {
nextUiTabs: WorkspaceTab[];
orderFromTabs: string[];
}
function migrateUiTabsForKey(rawEntries: unknown, now: number): MigratedTabsForKey {
const entries = Array.isArray(rawEntries) ? rawEntries : [];
const nextUiTabs: WorkspaceTab[] = [];
const orderFromTabs: string[] = [];
const usedOrder = new Set<string>();
for (const rawTab of entries) {
const migrated = migrateSingleTab(rawTab, now);
if (!migrated) {
continue;
}
if (!usedOrder.has(migrated.tabId)) {
usedOrder.add(migrated.tabId);
orderFromTabs.push(migrated.tabId);
}
nextUiTabs.push(migrated);
}
return { nextUiTabs, orderFromTabs };
}
function mergeExplicitTabOrder(
tabOrderByWorkspace: Record<string, string[]>,
rawOrder: Record<string, unknown>,
): void {
for (const key in rawOrder) {
const normalizedOrder = normalizeTabOrder(rawOrder[key]);
if (normalizedOrder.length === 0) {
continue;
}
const existing = tabOrderByWorkspace[key] ?? [];
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]);
}
}
function convertLegacyOrderEntry(entry: unknown): string | null {
const raw = typeof entry === "string" ? entry.trim() : "";
if (!raw) {
return null;
}
if (raw.startsWith("agent:")) {
const agentId = raw.slice("agent:".length).trim();
return agentId ? `agent_${agentId}` : null;
}
if (raw.startsWith("terminal:")) {
const terminalId = raw.slice("terminal:".length).trim();
return terminalId ? `terminal_${terminalId}` : null;
}
return null;
}
function normalizeLegacyOrderList(list: unknown[]): string[] {
const result: string[] = [];
for (const entry of list) {
const converted = convertLegacyOrderEntry(entry);
if (converted) {
result.push(converted);
}
}
return result;
}
function mergeLegacyTabOrder(
tabOrderByWorkspace: Record<string, string[]>,
legacyOrder: Record<string, unknown>,
): void {
for (const key in legacyOrder) {
const list = legacyOrder[key];
if (!Array.isArray(list) || list.length === 0) {
continue;
}
const normalizedLegacyOrder = normalizeLegacyOrderList(list);
if (normalizedLegacyOrder.length === 0) {
continue;
}
const existing = tabOrderByWorkspace[key] ?? [];
tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]);
}
}
function resolveFocusedTabId(rawValue: unknown): string | null {
if (typeof rawValue === "string") {
return trimNonEmpty(rawValue);
}
if (!rawValue || typeof rawValue !== "object") {
return null;
}
const value = rawValue as {
kind?: string;
agentId?: string;
terminalId?: string;
draftId?: string;
};
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
return `agent_${value.agentId.trim()}`;
}
if (
value.kind === "terminal" &&
typeof value.terminalId === "string" &&
value.terminalId.trim()
) {
return `terminal_${value.terminalId.trim()}`;
}
if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) {
return value.draftId.trim();
}
return null;
}
function migrateFocusedTabIds(
focusedTabIdByWorkspace: Record<string, string>,
rawFocused: Record<string, unknown>,
): void {
for (const key in rawFocused) {
const resolved = resolveFocusedTabId(rawFocused[key]);
if (resolved) {
focusedTabIdByWorkspace[key] = resolved;
}
}
}
export function migrateWorkspaceTabsState(
persistedState: unknown,
options: { now: number },
): WorkspaceTabsCoreState {
const { rawUiTabsByWorkspace, rawFocused, rawOrder, legacyOrder } =
extractMigrationRawSources(persistedState);
const uiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
const tabOrderByWorkspace: Record<string, string[]> = {};
const focusedTabIdByWorkspace: Record<string, string> = {};
for (const key in rawUiTabsByWorkspace) {
const { nextUiTabs, orderFromTabs } = migrateUiTabsForKey(
rawUiTabsByWorkspace[key],
options.now,
);
if (nextUiTabs.length > 0) {
uiTabsByWorkspace[key] = nextUiTabs;
}
if (orderFromTabs.length > 0) {
tabOrderByWorkspace[key] = orderFromTabs;
}
}
mergeExplicitTabOrder(tabOrderByWorkspace, rawOrder);
mergeLegacyTabOrder(tabOrderByWorkspace, legacyOrder);
migrateFocusedTabIds(focusedTabIdByWorkspace, rawFocused);
return {
uiTabsByWorkspace,
tabOrderByWorkspace,
focusedTabIdByWorkspace,
};
}
export function partializeWorkspaceTabsState(
state: WorkspaceTabsCoreState,
options: { now: number },
): WorkspaceTabsCoreState {
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
for (const key in state.uiTabsByWorkspace) {
const tabs = (state.uiTabsByWorkspace[key] ?? [])
.map((tab) => {
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
const normalizedTabId = trimNonEmpty(tab.tabId);
if (!normalizedTarget || !normalizedTabId) {
return null;
}
return {
tabId: normalizedTabId,
target: normalizedTarget,
createdAt: typeof tab.createdAt === "number" ? tab.createdAt : options.now,
} satisfies WorkspaceTab;
})
.filter((tab): tab is WorkspaceTab => tab !== null);
if (tabs.length > 0) {
nextUiTabsByWorkspace[key] = tabs;
}
}
const nextTabOrderByWorkspace: Record<string, string[]> = {};
for (const key in state.tabOrderByWorkspace) {
const order = normalizeTabOrder(state.tabOrderByWorkspace[key]);
if (order.length > 0) {
nextTabOrderByWorkspace[key] = order;
}
}
const nextFocusedTabIdByWorkspace: Record<string, string> = {};
for (const key in state.focusedTabIdByWorkspace) {
const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]);
if (focusedTabId) {
nextFocusedTabIdByWorkspace[key] = focusedTabId;
}
}
return {
uiTabsByWorkspace: nextUiTabsByWorkspace,
tabOrderByWorkspace: nextTabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace,
};
}