Compare commits

..
Author SHA1 Message Date
Mohamed BoudraandKassam Khoja 12e7a448f0 fix(app): open notification agents once in their current workspace (#7612)
* fix(app): open notification agents once in their current workspace

Keep tap ownership across Activity and React remounts, clear native responses, and resolve agent workspaces through existing navigation after startup settles.

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>

* fix(app): hydrate agent targets with the compact sidebar closed

Hold directory demand for the agent route while resolving its workspace; the compact sidebar does not supply demand before it opens.

* fix(app): keep deferred workspace opens in the layout model

Key pending tab opens by workspace so redirects and retained screens cannot lose or consume another workspace's target. Legacy open URLs seed this state at their route boundary.

* docs(app): document notification tap ownership and native replay

* fix(ci): route feature-owned Electron sources to desktop checks

* refactor(app): separate notification API from React integration

* fix(app): consume pending opens before painting the restored tab

A legacy open link showed the previously focused terminal for a frame before switching to the requested terminal. Seed and consume the pending open in layout effects so a ready target switches before paint.

* fix(e2e): focus the visible retained terminal surface

---------

Co-authored-by: Kassam Khoja <kassam@shopwrk.com>
2026-10-22 14:41:38 +07:00
40 changed files with 805 additions and 1332 deletions
+1
View File
@@ -41,6 +41,7 @@ server:
desktop:
- "packages/desktop/**"
- "packages/app/src/desktop/**"
- "packages/app/src/**/*.electron.{ts,tsx}"
- "packages/server/src/server/browser-tools/**"
- "packages/app/e2e/support/**"
- "packages/app/*config.{cjs,js,ts}"
+1
View File
@@ -36,6 +36,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
+16 -9
View File
@@ -104,17 +104,24 @@ Do not collapse this topology with a catch-all, `getId`, or
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
Notifications and agent URLs name a host and agent. Both use
`navigateToAgent()`, which reads the agent's current workspace from the store
or routes through `/h/[serverId]/agent/[agentId]`. The agent route waits for
the named host, demands its directory, and waits for workspace and layout
hydration before opening the tab. Keep the stable agent URL free of a workspace
hint. [Notifications](notifications.md) owns tap delivery and native lifecycle
constraints.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
When a workspace target cannot open yet, `navigateToWorkspace()` records a
pending open in the layout model under that host and workspace key. The owning
workspace screen consumes it once after hydration. Archived-workspace recovery
reads the same pending target, so startup redirects cannot discard it and a
retained screen cannot consume another workspace's request.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
Legacy `?open=` links seed this model state at the matched route boundary and
then lose the query parameter. Internal navigation does not use the query as a
handoff. Read matched route params locally; global search params let retained
workspace screens observe requests for other workspaces.
## Params
+71
View File
@@ -0,0 +1,71 @@
# Notifications
Use `notification-open` for OS notification taps. It owns delivery, parsing,
process-local deduplication, startup readiness, and native acknowledgement.
Keep agent and workspace navigation in their existing navigation modules.
Import the pure API from `@/notification-open` and the app-shell component from
`@/notification-open/react`; senders must not load the React routing runtime.
Agent payloads name a host and agent. Ignore their workspace hint when opening:
the agent can move after a notification is sent. Use `navigateToAgent`, which
uses the current store record or the agent route to resolve the workspace from
the host. Terminal payloads name their workspace and use `navigateToWorkspace`.
See [agent targets and deferred opens](expo-router.md#agent-targets) for route
ownership and workspace recovery. Notification generation, presence, and push
eligibility remain daemon concerns.
## Native delivery
Keep the router instance at module scope. Android's `singleTask` Activity can
finish on Back while its JS context remains alive. A React ref is lost when the
root remounts; process-local handled IDs must survive that remount.
Clear `lastNotificationResponse` after dispatch. Reading a launch response does
not acknowledge it. Without clearing it, a later Activity launch can reopen the
old agent.
With expo-notifications 57, Android also replays its pending response list to
new native listeners. Clearing the last response does not drain that list.
Deduplication covers listener registration within one JS context. A development
JS reload creates another context and can replay a cold-launch response; do not
add persisted handled IDs to compensate. Recheck this limitation when upgrading
expo-notifications.
iOS drains pending responses when its delegate first handles them. Push data
read by Expo arrives under `userInfo.body`; simulator payloads must match:
```json
{
"aps": { "alert": { "title": "Agent finished", "body": "Open the agent" }, "sound": "default" },
"body": {
"serverId": "host",
"workspaceId": "workspace",
"agentId": "agent",
"reason": "finished"
}
}
```
An Android FCM launch intent needs `google.message_id` and a JSON `body` extra.
An arbitrary Activity launch with data extras does not exercise Expo's FCM path.
Foreground pushes remain in Notification Center without a banner or sound.
This preserves notifications sent with stale presence information.
## Startup and verification
Wait for the root navigator, host registry, remembered selection, and startup
redirects before handing off a tap. A compact agent route must demand the host's
directory itself; its sidebar can be closed for the entire cold launch.
Verify warm and cold taps, stale and missing workspace hints, two distinct taps,
and Android Back followed by launcher reopen or a different notification tap. Check the focused tab and every
workspace's saved layout, since a foreign pinned tab survives navigation.
Use the daemon's payload shape on both iOS and Android; a browser event alone
does not test native replay or acknowledgement.
Existing foreign pins belong to the reconciliation fix in
[#6900](https://github.com/getpaseo/paseo/issues/6900). Desktop main-process window
selection is tracked separately in
[#6875](https://github.com/getpaseo/paseo/issues/6875).
@@ -21,10 +21,8 @@ async function completeSubmittedTurn(
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toBeVisible();
await expectAgentReadyToInterrupt(page);
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
expect(finish.status).toBe("idle");
await expectAgentIdle(page);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false");
return userMessage;
@@ -11,6 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -93,10 +94,20 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
await closeButton.first().click();
}
async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState(null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
}, route);
await expectAppRoute(page, route.split("?")[0]);
}
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
@@ -123,14 +134,33 @@ test.describe("Workspace navigation regression", () => {
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
await expectAppRoute(page, buildHostAgentDetailRoute(target.serverId, target.agentId), {
timeout: 30_000,
});
await expect(page.getByTestId("agent-route-waiting-for-host")).toBeVisible();
await expect(page.getByText(/Notification Host/).first()).toBeVisible();
});
test("cold agent targets hydrate their workspace without opening the compact sidebar", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "notification-cold-compact-" });
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Cold notification agent",
});
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id));
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), workspace.workspaceId), {
timeout: 30_000,
});
await expectComposerVisible(page);
} finally {
await workspace.cleanup();
}
});
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
@@ -321,7 +351,7 @@ test.describe("Workspace navigation regression", () => {
}
});
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
page,
}) => {
const serverId = getServerId();
@@ -435,7 +465,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
@@ -463,6 +493,22 @@ test.describe("Workspace navigation regression", () => {
await expect(secondDeckEntry).toBeHidden();
await expectWorkspaceDeckEntryCount(page, 2);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: firstWorkspace.workspaceId,
agentId: secondAgent.id,
});
await openWorkspaceTabUrl(
page,
buildHostWorkspaceOpenRoute(serverId, firstWorkspace.workspaceId, `agent:${firstAgent.id}`),
);
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
@@ -18,9 +18,6 @@ export async function openCommandCenterForAgent(
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
): Promise<Locator> {
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expect(
page.getByRole("button", { name: /^Select agent mode \(/ }).filter({ visible: true }),
).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
return panel.getByTestId("command-center-input");
}
@@ -68,8 +68,8 @@ export async function navigateToTerminal(
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// The route seeds the legacy URL target into the pending-open model, then
// removes the query. Tab readiness below waits for that intent to be consumed.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
@@ -86,7 +86,8 @@ export async function navigateToTerminal(
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import {
@@ -61,3 +61,26 @@ export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: stri
JSON.stringify(stored),
);
}
export async function expectAgentAbsentFromWorkspaceLayout(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
await expect
.poll(async () => {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (!raw) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema })
.parse(JSON.parse(raw));
const key = `${input.serverId}:${input.workspaceId}`;
const layout = stored.state.layoutByWorkspace[key];
const pins = stored.state.pinnedAgentIdsByWorkspace?.[key] ?? [];
// The persisted tree is validated above; inspect all panes including hidden ones.
return {
containsAgent: JSON.stringify(layout).includes(input.agentId),
pinned: pins.includes(input.agentId),
};
})
.toEqual({ containsAgent: false, pinned: false });
}
+12 -124
View File
@@ -7,7 +7,6 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { LucideProvider } from "lucide-react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
createContext,
@@ -16,7 +15,6 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
@@ -51,11 +49,7 @@ import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-short
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
import {
getIsElectronRuntime,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
@@ -97,7 +91,6 @@ import { KeyboardShiftProvider } from "@/keyboard/shift";
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import {
@@ -132,14 +125,9 @@ import {
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { NotificationOpenRouter } from "@/notification-open/react";
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
polyfillCrypto();
@@ -159,114 +147,6 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
startupBlocker: { kind: "none" },
});
function PushNotificationRouter() {
const router = useRouter();
const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
return;
}
router.navigate(buildNotificationRoute(data));
});
useEffect(() => {
if (isWeb) {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
typeof (payload as { data?: unknown }).data === "object" &&
(payload as { data?: unknown }).data !== null
? (payload as { data: Record<string, unknown> }).data
: undefined;
openNotification(data);
},
);
void Promise.resolve(unlistenResult).then((unlisten) => {
if (typeof unlisten !== "function") {
return;
}
if (cancelled) {
unlisten();
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
const openFromWebClick = (event: Event) => {
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
event.preventDefault();
openNotification(customEvent.detail?.data);
};
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
return () => {
cancelled = true;
removeDesktopNotificationListener?.();
window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener);
};
}
Notifications.setNotificationHandler({
handleNotification: async () => ({
// When the app is open, don't show OS banners.
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: false,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const openFromResponse = (response: Notifications.NotificationResponse) => {
const identifier = response.notification.request.identifier;
if (lastHandledIdRef.current === identifier) {
return;
}
lastHandledIdRef.current = identifier;
const data = response.notification.request.content.data as
| Record<string, unknown>
| undefined;
openNotification(data);
};
const subscription = Notifications.addNotificationResponseReceivedListener(openFromResponse);
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
subscription.remove();
};
}, [openNotification]);
return null;
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const client = useHostRuntimeClient(daemon.serverId);
@@ -945,6 +825,9 @@ function WorkspaceRouteNavigationBridge() {
}
function AppShell() {
useEffect(() => {
if (getDesktopHost()) void ensureOsNotificationPermission();
}, []);
return (
<MobilePanelsProvider>
<HorizontalScrollProvider>
@@ -959,10 +842,15 @@ function AppShell() {
);
}
function NotificationOpenBridge() {
const { startupBlocker } = useHostRuntimeBootstrapState();
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<NotificationOpenBridge />
<SidebarCalloutProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</SidebarCalloutProvider>
@@ -4,6 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
@@ -83,7 +85,16 @@ function HostAgentReadyRouteContent() {
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
useEffect(() => {
if (!serverId) return;
// A compact app can open this route before its sidebar has demanded the directory.
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
}, [serverId]);
const resolution = resolveAgentRoute({
hasHydratedWorkspaces,
hasHydratedLayout,
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
@@ -106,7 +117,7 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -11,7 +11,7 @@ import {
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
areRetainedWorkspaceSelectionListsEqual,
areWorkspaceSelectionsEqual,
@@ -33,7 +33,7 @@ import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -64,6 +64,8 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId };
}
// COMPAT(workspace-open-url): added in v0.12.0, remove after 2027-01-22.
// Bookmarks and older clients still send ?open=; internal navigation uses pending model state.
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
@@ -81,7 +83,9 @@ function clearConsumedOpenIntent(input: {
}) {
input.navigation.setParams({ open: undefined });
if (isWeb) {
stripOpenSearchParamFromBrowserUrl();
// Expo commits the incoming browser URL after setParams. Strip the consumed legacy input
// after that commit so it cannot be restored into the address bar and replayed on reload.
window.setTimeout(stripOpenSearchParamFromBrowserUrl, 0);
}
}
@@ -95,15 +99,9 @@ export default function HostWorkspaceIndexRoute() {
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
@@ -111,14 +109,7 @@ function HostWorkspaceRouteContent() {
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
const openValue = getParamValue(params.open);
useEffect(() => {
if (!serverId || !workspaceId) {
return;
@@ -126,74 +117,31 @@ function HostWorkspaceRouteContent() {
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, [serverId, workspaceId]);
useEffect(() => {
if (!openValue) {
return;
}
if (!rootNavigationState?.key) {
return;
}
if (!hasHydratedWorkspaceLayoutStore) {
return;
}
if (isOpenIntentWaitingForWorkspace) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
useLayoutEffect(() => {
if (!openValue || !serverId || !workspaceId) return;
const openIntent = parseWorkspaceOpenIntent(openValue);
if (openIntent) {
prepareWorkspaceTab({
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
});
prepareWorkspaceTab(
{
serverId,
workspaceId,
target: getOpenIntentTarget(openIntent),
pin: openIntent.kind === "agent",
},
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
);
}
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
});
}, [navigation, openValue, serverId, workspaceId]);
setIntentConsumed(true);
}, [
hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation,
openIntent,
openValue,
rootNavigationState?.key,
serverId,
workspaceId,
]);
if (
openValue &&
!isOpenIntentWaitingForWorkspace &&
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
) {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
return <WorkspaceDeck />;
}
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeck() {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -264,7 +212,6 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
key={getWorkspaceSelectionKey(selection)}
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -277,12 +224,10 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
function WorkspaceDeckEntry({
selection,
active,
recoveryRequested,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -312,7 +257,6 @@ function WorkspaceDeckEntry({
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
</RetainedPanel>
);
@@ -38,3 +38,5 @@ export function addNotificationResponseReceivedListener() {
export async function getLastNotificationResponseAsync() {
return null;
}
export function clearLastNotificationResponse(): void {}
@@ -5,6 +5,8 @@ const VALID_ROUTE = {
serverId: "server-1",
agentId: "agent-1",
cachedWorkspaceId: null,
hasHydratedWorkspaces: true,
hasHydratedLayout: true,
} as const;
describe("resolveAgentRoute", () => {
@@ -32,6 +34,20 @@ describe("resolveAgentRoute", () => {
},
);
it.each([
{ hasHydratedWorkspaces: false, hasHydratedLayout: true },
{ hasHydratedWorkspaces: true, hasHydratedLayout: false },
])("waits for workspace and layout hydration: %j", (hydration) => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
...hydration,
connectionStatus: "online",
lookup: { kind: "found", workspaceId: "workspace-2" },
}),
).toEqual({ kind: "fetchingAgent" });
});
it("fetches the agent after its target host connects", () => {
expect(
resolveAgentRoute({
@@ -24,13 +24,18 @@ export function resolveAgentRoute(input: {
cachedWorkspaceId: string | null | undefined;
connectionStatus: HostRuntimeConnectionStatus;
lookup: AgentRouteLookup;
hasHydratedWorkspaces: boolean;
hasHydratedLayout: boolean;
}): AgentRouteResolution {
if (!input.serverId || !input.agentId) {
return { kind: "invalid" };
}
const hydrationPending = !input.hasHydratedWorkspaces || !input.hasHydratedLayout;
if (hydrationPending && input.connectionStatus === "online") return { kind: "fetchingAgent" };
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
if (cachedWorkspaceId) {
if (cachedWorkspaceId && !hydrationPending) {
return { kind: "resolved", workspaceId: cachedWorkspaceId };
}
@@ -0,0 +1,6 @@
export {
createNotificationOpenRouter,
parseNotificationOpenTarget,
WEB_NOTIFICATION_CLICK_EVENT,
} from "./model";
export type { NotificationOpenSource, NotificationTap } from "./source";
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { createNotificationOpenRouter, parseNotificationOpenTarget } from "./model";
import type { NotificationOpenSource, NotificationTap } from "./source";
class FakeSource implements NotificationOpenSource {
listeners = new Set<(tap: NotificationTap) => void>();
acknowledged: string[] = [];
launchReads = 0;
constructor(readonly launch: NotificationTap | null) {}
subscribe(onTap: (tap: NotificationTap) => void) {
this.listeners.add(onTap);
return () => {
this.listeners.delete(onTap);
};
}
async takeLaunchTap() {
this.launchReads++;
return this.launch;
}
acknowledge(tap: NotificationTap) {
this.acknowledged.push(tap.id);
}
tap(tap: NotificationTap) {
for (const listener of this.listeners) listener(tap);
}
}
const launch: NotificationTap = {
id: "one",
data: { serverId: "host", workspaceId: "stale", agentId: "agent" },
};
async function flush() {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
describe("notification open", () => {
it.each([
[undefined, null],
[null, null],
[[], null],
[{ agentId: "a" }, null],
[{ serverId: " " }, null],
[{ serverId: " h " }, { kind: "host", serverId: "h" }],
[
{ serverId: "h", agentId: "a" },
{ kind: "agent", serverId: "h", agentId: "a" },
],
[
{ serverId: "h", workspaceId: "stale", agentId: "a" },
{ kind: "agent", serverId: "h", agentId: "a" },
],
[
{ serverId: "h", workspaceId: "w", terminalId: "t", agentId: "a" },
{ kind: "terminal", serverId: "h", workspaceId: "w", terminalId: "t" },
],
])("parses %j", (data, expected) => {
expect(parseNotificationOpenTarget(data)).toEqual(expected);
});
function setup(whenReadyToNavigate = () => Promise.resolve()) {
const source = new FakeSource(launch);
const agents: unknown[] = [];
const routes: string[] = [];
const terminals: unknown[] = [];
const router = createNotificationOpenRouter({
source,
openAgent: (target) => {
agents.push(target);
},
openTerminal: (target) => {
terminals.push(target);
},
openRoute: (route) => {
routes.push(route);
},
whenReadyToNavigate,
});
return { source, agents, routes, terminals, router };
}
it("takes the launch tap once across remounts", async () => {
const { source, agents, router } = setup();
router.start()();
const stop = router.start();
await flush();
expect(source.launchReads).toBe(1);
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
stop();
});
it("ignores listener replay of the launch tap after remount", async () => {
const { source, agents, router } = setup();
const stop = router.start();
await flush();
stop();
const stopAgain = router.start();
source.tap(launch);
await flush();
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
expect(source.acknowledged).toEqual(["one"]);
stopAgain();
});
it("waits for readiness before dispatch and acknowledgement", async () => {
let ready = () => {};
const readiness = new Promise<void>((resolve) => {
ready = resolve;
});
const { source, agents, router } = setup(() => readiness);
const stop = router.start();
await flush();
expect(agents).toEqual([]);
expect(source.acknowledged).toEqual([]);
ready();
await flush();
expect(agents).toEqual([{ serverId: "host", agentId: "agent" }]);
expect(source.acknowledged).toEqual(["one"]);
stop();
});
it("acknowledges each distinct tap including invalid targets", async () => {
const { source, agents, router } = setup();
const stop = router.start();
await flush();
source.tap({ id: "two", data: { serverId: "host", agentId: "second" } });
source.tap({ id: "invalid", data: undefined });
await flush();
expect(agents).toEqual([
{ serverId: "host", agentId: "agent" },
{ serverId: "host", agentId: "second" },
]);
expect(source.acknowledged).toEqual(["one", "two", "invalid"]);
stop();
});
it("dispatches terminals and hosts through their navigation capabilities", async () => {
const { source, routes, terminals, router } = setup();
const stop = router.start();
source.tap({
id: "terminal",
data: { serverId: "host", workspaceId: "ws", terminalId: "term" },
});
source.tap({ id: "host", data: { serverId: "host" } });
await flush();
expect(routes).toEqual(["/h/host"]);
expect(terminals).toEqual([
{ kind: "terminal", serverId: "host", workspaceId: "ws", terminalId: "term" },
]);
stop();
});
});
@@ -0,0 +1,67 @@
import { buildHostRootRoute } from "@/utils/host-routes";
import type { NotificationOpenSource, NotificationTap } from "./source";
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
export type NotificationOpenTarget =
| { kind: "agent"; serverId: string; agentId: string }
| { kind: "terminal"; serverId: string; workspaceId: string; terminalId: string }
| { kind: "host"; serverId: string };
function readId(data: object, key: string): string {
const value: unknown = Reflect.get(data, key);
return typeof value === "string" ? value.trim() : "";
}
export function parseNotificationOpenTarget(data: unknown): NotificationOpenTarget | null {
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
const serverId = readId(data, "serverId");
if (!serverId) return null;
const workspaceId = readId(data, "workspaceId");
const terminalId = readId(data, "terminalId");
if (workspaceId && terminalId) return { kind: "terminal", serverId, workspaceId, terminalId };
const agentId = readId(data, "agentId");
if (agentId) return { kind: "agent", serverId, agentId };
return { kind: "host", serverId };
}
interface NotificationOpenRouterDeps {
source: NotificationOpenSource;
openAgent: (target: { serverId: string; agentId: string }) => void;
openRoute: (route: string) => void;
openTerminal: (target: Extract<NotificationOpenTarget, { kind: "terminal" }>) => void;
whenReadyToNavigate: () => Promise<void>;
}
export function createNotificationOpenRouter(deps: NotificationOpenRouterDeps) {
const handledIds = new Set<string>();
let launchTaken = false;
async function receive(tap: NotificationTap) {
if (handledIds.has(tap.id)) return;
handledIds.add(tap.id);
await deps.whenReadyToNavigate();
const target = parseNotificationOpenTarget(tap.data);
if (target?.kind === "agent") {
deps.openAgent({ serverId: target.serverId, agentId: target.agentId });
} else if (target?.kind === "terminal") {
deps.openTerminal(target);
} else if (target?.kind === "host") {
deps.openRoute(buildHostRootRoute(target.serverId));
}
deps.source.acknowledge(tap);
}
return {
start() {
const unsubscribe = deps.source.subscribe((tap) => {
void receive(tap);
});
if (!launchTaken) {
launchTaken = true;
void deps.source.takeLaunchTap().then((tap) => {
return tap ? receive(tap) : undefined;
});
}
return unsubscribe;
},
};
}
@@ -0,0 +1,58 @@
import { useEffect } from "react";
import { router, usePathname, useRootNavigationState, type Href } from "expo-router";
import { useHostRegistryStatus } from "@/runtime/host-runtime";
import {
navigateToWorkspace,
useIsLastWorkspaceSelectionHydrated,
} from "@/stores/navigation-active-workspace-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { createNotificationOpenRouter } from "../model";
import { createNotificationOpenSource } from "../source";
let ready = false;
const waiters = new Set<() => void>();
const notificationRouter = createNotificationOpenRouter({
source: createNotificationOpenSource(),
openAgent: (target) => navigateToAgent({ ...target, pin: true }),
openTerminal: ({ serverId, workspaceId, terminalId }) =>
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "terminal", terminalId },
}),
openRoute: (route) => router.navigate(route as Href),
whenReadyToNavigate: () =>
ready
? Promise.resolve()
: new Promise<void>((resolve) => {
waiters.add(resolve);
}),
});
export function NotificationOpenRouter({ startupBlocked }: { startupBlocked: boolean }) {
const root = useRootNavigationState();
const pathname = usePathname();
const registryStatus = useHostRegistryStatus();
const selectionHydrated = useIsLastWorkspaceSelectionHydrated();
// Index routes redirect during startup. Let their final leaf commit before dispatching a tap.
const atStartupIndex = pathname === "/" || /^\/h\/[^/]+\/?$/.test(pathname);
const canNavigate = Boolean(
root?.key &&
registryStatus === "ready" &&
selectionHydrated &&
!startupBlocked &&
!atStartupIndex,
);
useEffect(() => {
ready = canNavigate;
if (ready) {
for (const resolve of waiters) resolve();
waiters.clear();
}
return () => {
ready = false;
};
}, [canNavigate]);
useEffect(() => notificationRouter.start(), []);
return null;
}
@@ -0,0 +1,42 @@
import { getDesktopHost } from "@/desktop/host";
import { createNotificationOpenSource as createWebSource } from "./source.web";
import type { NotificationOpenSource } from "./source";
export function createNotificationOpenSource(): NotificationOpenSource {
const web = createWebSource();
let nextId = 0;
return {
...web,
subscribe(onTap) {
const stopWeb = web.subscribe(onTap);
let disposed = false;
let stopDesktop: (() => void) | undefined;
const subscription = getDesktopHost()?.events?.on?.(
"notification-click",
(payload: unknown) => {
const data =
typeof payload === "object" && payload !== null && "data" in payload
? payload.data
: undefined;
onTap({
id: `electron:${++nextId}`,
data:
typeof data === "object" && data !== null
? Object.fromEntries(Object.entries(data))
: undefined,
});
},
);
void Promise.resolve(subscription).then((stop) => {
if (disposed) stop?.();
else stopDesktop = stop;
return;
});
return () => {
disposed = true;
stopWeb();
stopDesktop?.();
};
},
};
}
@@ -0,0 +1,36 @@
import * as Notifications from "expo-notifications";
import type { NotificationOpenSource, NotificationTap } from "./source";
function toTap(response: Notifications.NotificationResponse): NotificationTap {
return {
id: response.notification.request.identifier,
data: response.notification.request.content.data,
};
}
export function createNotificationOpenSource(): NotificationOpenSource {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: false,
shouldShowBanner: false,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
return {
subscribe(onTap) {
const subscription = Notifications.addNotificationResponseReceivedListener((response) =>
onTap(toTap(response)),
);
return () => subscription.remove();
},
async takeLaunchTap() {
const response = await Notifications.getLastNotificationResponseAsync();
return response ? toTap(response) : null;
},
acknowledge() {
Notifications.clearLastNotificationResponse();
},
};
}
@@ -0,0 +1,12 @@
export interface NotificationTap {
id: string;
data: Record<string, unknown> | undefined;
}
export interface NotificationOpenSource {
subscribe(onTap: (tap: NotificationTap) => void): () => void;
takeLaunchTap(): Promise<NotificationTap | null>;
acknowledge(tap: NotificationTap): void;
}
export { createNotificationOpenSource } from "./source.native";
@@ -0,0 +1,23 @@
import { WEB_NOTIFICATION_CLICK_EVENT } from "./model";
import type { NotificationOpenSource } from "./source";
import { isWeb } from "@/constants/platform";
export function createNotificationOpenSource(): NotificationOpenSource {
let nextId = 0;
return {
subscribe(onTap) {
if (!isWeb) return () => {};
function receive(event: Event) {
if (!(event instanceof CustomEvent)) return;
event.preventDefault();
onTap({ id: `web:${++nextId}`, data: event.detail?.data });
}
window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
return () => window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, receive);
},
async takeLaunchTap() {
return null;
},
acknowledge() {},
};
}
@@ -93,6 +93,7 @@ import {
type SheetTab,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
usePendingWorkspaceOpen,
useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store";
import {
@@ -271,12 +272,10 @@ interface WorkspaceScreenProps {
serverId: string;
workspaceId: string;
isRouteFocused?: boolean;
recoveryRequested?: boolean;
}
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
isRouteFocused: boolean;
recoveryRequested: boolean;
};
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -419,7 +418,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId,
workspaceId,
isRouteFocused,
recoveryRequested,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
useEffect(() => {
@@ -433,7 +431,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId={serverId}
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
recoveryRequested={recoveryRequested ?? false}
/>
);
});
@@ -748,9 +745,9 @@ function useResolvedWorkspaceRouteState(input: {
function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null,
recoveryRequested: boolean,
pendingOpenTarget: WorkspaceTabTarget | null,
): boolean {
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
}
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
@@ -1015,7 +1012,6 @@ function WorkspaceScreenContent({
serverId,
workspaceId,
isRouteFocused,
recoveryRequested,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
@@ -1149,13 +1145,18 @@ function WorkspaceScreenContent({
const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
);
const pendingOpenTarget = usePendingWorkspaceOpen({
workspaceKey: persistenceKey,
hasHydratedWorkspaces,
workspaceExists: workspaceDescriptor !== null,
});
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
enabled: shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces,
workspaceDescriptor,
recoveryRequested,
pendingOpenTarget,
),
});
@@ -38,6 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps {
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input),
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => {
@@ -21,7 +21,12 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const navigations: string[] = [];
const remembered: ActiveWorkspaceSelection[] = [];
const openedTabs: RecordedTab[] = [];
const deferredTabs: unknown[] = [];
const deps: NavigateToWorkspaceDeps = {
deferOpenTab: (input) => {
deferredTabs.push(input);
return null;
},
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
isWorkspaceLayoutHydrated: () => true,
@@ -33,7 +38,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
navigateToRoute: (route) => navigations.push(route),
...overrides,
};
return { deps, navigations, remembered, openedTabs };
return { deps, navigations, remembered, openedTabs, deferredTabs };
}
function createLastSelectionDeps(
@@ -139,7 +144,7 @@ describe("workspace navigation", () => {
});
it("defers an agent tab until a missing workspace is recovered", () => {
const { deps, navigations, openedTabs } = createFakeDeps({
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map(),
});
@@ -153,7 +158,8 @@ describe("workspace navigation", () => {
);
expect(openedTabs).toEqual([]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(deferredTabs).toHaveLength(1);
});
it("defers an agent tab until persisted workspace layout has hydrated", () => {
@@ -161,7 +167,7 @@ describe("workspace navigation", () => {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const { deps, navigations, openedTabs } = createFakeDeps({
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
isWorkspaceLayoutHydrated: () => false,
});
@@ -177,7 +183,8 @@ describe("workspace navigation", () => {
);
expect(openedTabs).toEqual([]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(deferredTabs).toHaveLength(1);
});
it("reads the active workspace from the current route", () => {
@@ -1,7 +1,6 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import {
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
@@ -13,6 +12,7 @@ import {
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
import type { OpenWorkspaceTabInput } from "@/stores/workspace-layout-store";
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
export interface RouteSelectionInput {
@@ -35,6 +35,7 @@ export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
isWorkspaceLayoutHydrated: () => boolean;
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
}
@@ -91,13 +92,10 @@ export function navigateToWorkspace(
workspaces,
workspaceId: input.workspaceId,
});
const shouldDeferAgentOpen = Boolean(
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
);
const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated();
if (input.target) {
if (!shouldDeferAgentOpen) {
prepareWorkspaceTab({ ...input, target: input.target }, deps);
}
const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab;
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
} else {
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter(
@@ -114,14 +112,7 @@ export function navigateToWorkspace(
}
}
const route =
input.target?.kind === "agent" && shouldDeferAgentOpen
? buildHostWorkspaceOpenRoute(
input.serverId,
input.workspaceId,
`agent:${input.target.agentId}`,
)
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
deps.navigateToRoute(route);
return route;
@@ -5131,3 +5131,43 @@ describe("compact workspace sheet", () => {
expect(received).toEqual([[], ["child"], []]);
});
});
describe("pending workspace opens", () => {
it("retains an open across hydration and consumes it once only for its workspace", async () => {
const values = new Map<string, string>();
const store = createWorkspaceLayoutStore(undefined, {
getItem: async (key) => values.get(key) ?? null,
setItem: async (key, value) => {
values.set(key, value);
},
removeItem: async (key) => {
values.delete(key);
},
});
store.getState().deferOpenTab({
workspaceKey: "server:beta",
target: { kind: "agent", agentId: "b" },
intent: "reveal",
pin: true,
});
await store.persist.rehydrate();
expect(store.getState().consumePendingOpen("server:alpha")).toBeNull();
expect(
store
.getState()
.getWorkspaceTabs("server:alpha")
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
expect(store.getState().consumePendingOpen("server:beta")).toBe("agent_b");
expect(store.getState().consumePendingOpen("server:beta")).toBeNull();
expect(
store
.getState()
.getWorkspaceTabs("server:beta")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: "b" }]);
expect(store.getState().pinnedAgentIdsByWorkspace["server:beta"]).toEqual(new Set(["b"]));
expect(store.getState().pendingOpenByWorkspace).toEqual({});
});
});
@@ -1,5 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { useEffect, useLayoutEffect, useState } from "react";
import { create } from "zustand";
import { persist, type StateStorage } from "zustand/middleware";
import type { z } from "zod";
@@ -130,6 +130,9 @@ export type WorkspaceTabDestination =
interface WorkspaceLayoutStore {
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
pendingOpenByWorkspace: Record<string, OpenWorkspaceTabInput>;
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
consumePendingOpen: (workspaceKey: string) => string | null;
/**
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
@@ -1020,6 +1023,26 @@ export function createWorkspaceLayoutStore(
}
return tabId;
},
pendingOpenByWorkspace: {},
deferOpenTab: (input) => {
set((state) => ({
pendingOpenByWorkspace: {
...state.pendingOpenByWorkspace,
[input.workspaceKey]: input,
},
}));
return null;
},
consumePendingOpen: (workspaceKey) => {
const pending = get().pendingOpenByWorkspace[workspaceKey];
if (!pending) return null;
set((state) => {
const { [workspaceKey]: _pending, ...pendingOpenByWorkspace } =
state.pendingOpenByWorkspace;
return { pendingOpenByWorkspace };
});
return get().openTab(pending);
},
layoutMode: "split-panes",
sheetsByWorkspace: {},
layoutByWorkspace: {},
@@ -2160,6 +2183,7 @@ export function createWorkspaceLayoutStore(
set((state) => {
const hasAny =
normalizedWorkspaceKey in state.pendingOpenByWorkspace ||
normalizedWorkspaceKey in state.layoutByWorkspace ||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
@@ -2173,6 +2197,8 @@ export function createWorkspaceLayoutStore(
if (!hasAny) {
return state;
}
const { [normalizedWorkspaceKey]: _pendingOpen, ...pendingOpenByWorkspace } =
state.pendingOpenByWorkspace;
const {
[normalizedWorkspaceKey]: _autoOpened,
...pullRequestTabAutoOpenedByWorkspace
@@ -2199,6 +2225,7 @@ export function createWorkspaceLayoutStore(
state.sidePaneIdByWorkspace;
return {
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
pendingOpenByWorkspace,
pullRequestTabAutoOpenedByWorkspace,
layoutByWorkspace,
splitSizesByWorkspace,
@@ -2348,3 +2375,27 @@ export function useWorkspaceLayoutStoreHydrated(): boolean {
return hasHydrated;
}
interface PendingWorkspaceOpenReadiness {
workspaceKey: string | null;
hasHydratedWorkspaces: boolean;
workspaceExists: boolean;
}
export function usePendingWorkspaceOpen({
workspaceKey,
hasHydratedWorkspaces,
workspaceExists,
}: PendingWorkspaceOpenReadiness): WorkspaceTabTarget | null {
const workspaceReady = hasHydratedWorkspaces && workspaceExists;
const pending = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.pendingOpenByWorkspace[workspaceKey] : undefined,
);
const layoutHydrated = useWorkspaceLayoutStoreHydrated();
useLayoutEffect(() => {
if (workspaceKey && workspaceReady && layoutHydrated && pending) {
useWorkspaceLayoutStore.getState().consumePendingOpen(workspaceKey);
}
}, [workspaceKey, workspaceReady, layoutHydrated, pending]);
return pending ? pending.target : null;
}
@@ -62,7 +62,7 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("uses the input workspaceId without reading the nav target", () => {
it("uses the input workspaceId only when the store has no record", () => {
const readTargets: { serverId: string; agentId: string }[] = [];
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
deps.readAgentNavTarget = (input) => {
@@ -75,7 +75,7 @@ describe("resolveNavigateToAgent", () => {
deps,
);
expect(readTargets).toEqual([]);
expect(readTargets).toEqual([{ serverId: SERVER_ID, agentId: AGENT_ID }]);
expect(tabNavigations).toEqual([
{
serverId: SERVER_ID,
@@ -86,6 +86,12 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("prefers the current agent workspace over a stale input workspace", () => {
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: "current" });
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: "stale" }, deps);
expect(tabNavigations[0]?.workspaceId).toBe("current");
});
it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: null,
@@ -26,8 +26,8 @@ export function resolveNavigateToAgent(
deps: NavigateToAgentDeps,
): string {
const agentWorkspaceId =
input.workspaceId ??
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId })
.agentWorkspaceId ?? input.workspaceId;
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
if (!workspaceId) {
@@ -1,104 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
describe("resolveNotificationTarget", () => {
it("extracts non-empty server and agent ids", () => {
expect(
resolveNotificationTarget({
serverId: " server-123 ",
agentId: " agent-456 ",
}),
).toEqual({
serverId: "server-123",
agentId: "agent-456",
workspaceId: null,
terminalId: null,
});
});
it("returns null for missing/empty ids", () => {
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
serverId: null,
agentId: null,
workspaceId: null,
terminalId: null,
});
expect(resolveNotificationTarget(undefined)).toEqual({
serverId: null,
agentId: null,
workspaceId: null,
terminalId: null,
});
});
it("does not treat cwd as a workspace id alias", () => {
expect(
resolveNotificationTarget({
serverId: "srv-1",
agentId: "agent-1",
cwd: "/tmp/repo",
}),
).toEqual({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: null,
terminalId: null,
});
});
});
describe("buildNotificationRoute", () => {
it("routes to the agent path when workspace id is present", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: "ws-main",
}),
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
});
it("does not treat an incomplete notification as an agent URL", () => {
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1");
});
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
workspaceId: "ws-main",
terminalId: "term-1",
}),
).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1");
});
it("falls back to host root for a terminal without a workspace id", () => {
expect(
buildNotificationRoute({
serverId: "srv-1",
terminalId: "term-1",
cwd: "/tmp/repo",
}),
).toBe("/h/srv-1");
});
it("falls back to host root when only serverId is present", () => {
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only");
});
it("falls back to root when no server id is present", () => {
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/");
expect(buildNotificationRoute(undefined)).toBe("/");
});
it("encodes path segments", () => {
expect(
buildNotificationRoute({
serverId: "srv/with/slash",
workspaceId: "workspace-1",
agentId: "agent with space",
}),
).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space");
});
});
@@ -1,42 +0,0 @@
import type { Href } from "expo-router";
import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined;
type NotificationRoute = Extract<Href, string>;
function readNonEmptyString(data: NotificationData, key: string): string | null {
const value = data?.[key];
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function resolveNotificationTarget(data: NotificationData): {
serverId: string | null;
agentId: string | null;
workspaceId: string | null;
terminalId: string | null;
} {
return {
serverId: readNonEmptyString(data, "serverId"),
agentId: readNonEmptyString(data, "agentId"),
workspaceId: readNonEmptyString(data, "workspaceId"),
terminalId: readNonEmptyString(data, "terminalId"),
};
}
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
if (serverId && workspaceId && agentId) {
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`);
}
if (serverId && workspaceId && terminalId) {
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
}
if (serverId) {
return buildHostRootRoute(serverId);
}
return "/" as const;
}
@@ -1,3 +1,4 @@
import { WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
interface MockNotificationOptions {
@@ -132,7 +133,7 @@ describe("sendOsNotification", () => {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
(globalThis as { location?: unknown }).location = { assign };
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
const { sendOsNotification } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
@@ -159,7 +160,7 @@ describe("sendOsNotification", () => {
expect(assign).not.toHaveBeenCalled();
});
it("falls back to route navigation when no listener handles the click", async () => {
it("leaves navigation to the app when no listener handles the click", async () => {
const created: MockNotificationInstance[] = [];
class MockNotification implements MockNotificationInstance {
@@ -207,9 +208,7 @@ describe("sendOsNotification", () => {
expect(clicked.clickListeners).toHaveLength(1);
clicked.clickListeners[0]?.({} as Event);
expect(assign).toHaveBeenCalledWith(
"/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1",
);
expect(assign).not.toHaveBeenCalled();
});
it("returns false when the Notification API is unavailable", async () => {
+3 -25
View File
@@ -1,6 +1,6 @@
import { Asset } from "expo-asset";
import { getDesktopHost } from "@/desktop/host";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
import { parseNotificationOpenTarget, WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
import { isNative } from "@/constants/platform";
interface OsNotificationPayload {
@@ -17,8 +17,6 @@ interface WebNotificationInstance {
addEventListener: (type: "click", listener: (event: Event) => void) => void;
}
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
let permissionRequest: Promise<boolean> | null = null;
let notificationIconUrl: string | null | undefined;
@@ -98,8 +96,7 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
}
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
const target = resolveNotificationTarget(data);
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
return parseNotificationOpenTarget(data) !== null;
}
function getWebNotificationIconUrl(): string | undefined {
@@ -135,31 +132,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
return !dispatch(event);
}
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
const route = buildNotificationRoute(data);
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
.location;
if (!location) {
return;
}
if (typeof location.assign === "function") {
location.assign(route);
return;
}
if (typeof location.href === "string") {
location.href = route;
}
}
function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined,
): void {
notification.addEventListener("click", () => {
const handledByApp = dispatchWebNotificationClick({ data });
if (!handledByApp) {
fallbackNavigateToNotificationTarget(data);
}
dispatchWebNotificationClick({ data });
});
}
@@ -1,53 +0,0 @@
import { test, expect } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { ClaudeAgentClient } from "./agent.js";
test("lists a session containing a large image record and retains the final prompt", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-import-image-"));
try {
const project = path.join(configDir, "projects", "image-project");
await fs.mkdir(project, { recursive: true });
const first = {
type: "user",
sessionId: "image-session",
cwd: "/image-project",
message: { content: "Describe the image" },
};
const image = {
type: "user",
sessionId: "image-session",
cwd: "/image-project",
message: {
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "a".repeat(5 * 1024 * 1024) },
},
],
},
};
const last = { type: "user", message: { content: "What changed?" } };
await fs.writeFile(
path.join(project, "image-session.jsonl"),
[first, image, last].map((record) => JSON.stringify(record)).join("\n"),
);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
});
const sessions = await client.listImportableSessions({ limit: 1 });
expect(sessions).toEqual([
expect.objectContaining({
providerHandleId: "image-session",
title: "Describe the image",
firstPromptPreview: "Describe the image",
lastPromptPreview: "What changed?",
}),
]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
});
@@ -1,9 +1,6 @@
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { PermissionResult, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
@@ -18,11 +15,9 @@ import {
resolveClaudeCodeVersion,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type {
ImportableProviderSession,
AgentPromptInput,
AgentSession,
AgentTimelineItem,
@@ -1745,404 +1740,6 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
});
describe("ClaudeAgentClient.listImportableSessions", () => {
async function descriptorFixture(count: number) {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-errors-"));
const projectDir = path.join(configDir, "projects", "errors");
await fs.mkdir(projectDir, { recursive: true });
const files: string[] = [];
const descriptors: ImportableProviderSession[] = [];
const logs: unknown[] = [];
for (let index = 0; index < count; index += 1) {
const file = path.join(projectDir, `${index}.jsonl`);
const timestamp = new Date(Date.UTC(2026, 0, 1) + (count - index) * 1000);
await fs.writeFile(
file,
JSON.stringify({
type: "user",
sessionId: `session-${index}`,
cwd: "/errors",
message: { content: `Prompt ${index}` },
}),
);
await fs.utimes(file, timestamp, timestamp);
files.push(file);
descriptors.push({
providerHandleId: `session-${index}`,
cwd: "/errors",
title: `Prompt ${index}`,
firstPromptPreview: `Prompt ${index}`,
lastPromptPreview: `Prompt ${index}`,
lastActivityAt: timestamp,
});
}
const options = {
logger: pino({ level: "warn" }, { write: (line: string) => logs.push(JSON.parse(line)) }),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
};
return {
files,
descriptors,
logs,
options,
cleanup: () => fs.rm(configDir, { recursive: true, force: true }),
};
}
const cannotEnforceFilePermissions = process.platform === "win32" || process.getuid?.() === 0;
const discoveryPermissionCases = [
{ label: "project enumeration", mode: 0, deniedSuffix: "", existingSuffix: "" },
{
label: "candidate stat",
mode: 0o400,
deniedSuffix: "hidden.jsonl",
existingSuffix: "0.jsonl",
},
];
test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)(
"logs denied $label while discovering a healthy project",
async ({ mode, deniedSuffix }) => {
const fixture = await descriptorFixture(1);
const projectsRoot = path.dirname(path.dirname(fixture.files[0]!));
const deniedProject = path.join(projectsRoot, "denied-project");
await fs.mkdir(deniedProject);
try {
await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}');
await fs.chmod(deniedProject, mode);
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual(
fixture.descriptors,
);
expect(fixture.logs).toEqual([
expect.objectContaining({
path: path.join(deniedProject, deniedSuffix),
err: expect.objectContaining({ code: "EACCES" }),
msg: "Skipping Claude session discovery path with denied access",
}),
]);
} finally {
await fs.chmod(deniedProject, 0o700);
await fixture.cleanup();
}
},
);
test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)(
"surfaces a provider error when $label denies discovery in every project",
async ({ mode, deniedSuffix, existingSuffix }) => {
const fixture = await descriptorFixture(1);
const existingProject = path.dirname(fixture.files[0]!);
const deniedProject = path.join(path.dirname(existingProject), "denied-project");
await fs.mkdir(deniedProject);
try {
await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}');
await fs.chmod(existingProject, mode);
await fs.chmod(deniedProject, mode);
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({
name: "AggregateError",
message: expect.stringContaining("no transcripts could be discovered"),
errors: expect.arrayContaining([
expect.objectContaining({
code: "EACCES",
path: path.join(existingProject, existingSuffix),
}),
expect.objectContaining({
code: "EACCES",
path: path.join(deniedProject, deniedSuffix),
}),
]),
});
expect(fixture.logs).toHaveLength(2);
expect(fixture.logs).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: path.join(existingProject, existingSuffix),
err: expect.objectContaining({ code: "EACCES" }),
}),
expect.objectContaining({
path: path.join(deniedProject, deniedSuffix),
err: expect.objectContaining({ code: "EACCES" }),
}),
]),
);
} finally {
await fs.chmod(existingProject, 0o700);
await fs.chmod(deniedProject, 0o700);
await fixture.cleanup();
}
},
);
test.skipIf(cannotEnforceFilePermissions)(
"isolates an actual access-denied transcript while returning a healthy session",
async () => {
const fixture = await descriptorFixture(2);
try {
await fs.chmod(fixture.files[0]!, 0);
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([
fixture.descriptors[1],
]);
expect(fixture.logs).toEqual([
expect.objectContaining({
path: fixture.files[0],
err: expect.objectContaining({ code: "EACCES" }),
msg: "Skipping Claude transcript with unreadable import metadata",
}),
]);
} finally {
await fixture.cleanup();
}
},
);
test.skipIf(cannotEnforceFilePermissions)(
"surfaces a provider error when every actual candidate is access denied",
async () => {
const fixture = await descriptorFixture(2);
try {
await Promise.all(fixture.files.map((file) => fs.chmod(file, 0)));
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({
name: "AggregateError",
errors: fixture.files.map((file) =>
expect.objectContaining({ code: "EACCES", path: file }),
),
message: expect.stringContaining(
"all 2 candidate transcripts have unreadable import metadata",
),
});
expect(fixture.logs).toHaveLength(2);
} finally {
await fixture.cleanup();
}
},
);
test("returns an empty listing for readable transcripts without import metadata", async () => {
const fixture = await descriptorFixture(2);
try {
await Promise.all(fixture.files.map((file) => fs.writeFile(file, '{"type":"assistant"}')));
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([]);
expect(fixture.logs).toEqual([]);
} finally {
await fixture.cleanup();
}
});
test("refreshes descriptors after append, rewrite, replacement, and deletion", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-cache-"));
try {
const projectDir = path.join(configDir, "projects", "changing-transcript");
await fs.mkdir(projectDir, { recursive: true });
const sessionFile = path.join(projectDir, "changing.jsonl");
const initial = `${JSON.stringify({
type: "user",
sessionId: "changing-session",
cwd: "/changing-project",
message: { content: "First prompt" },
})}\n`;
function title(customTitle: string): string {
return `${JSON.stringify({ type: "custom-title", customTitle })}\n`;
}
await fs.writeFile(sessionFile, initial + title("First title"));
const timestamp = new Date("2026-06-01T12:00:00.000Z");
await fs.utimes(sessionFile, timestamp, timestamp);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
});
const first = await client.listImportableSessions({ limit: 1 });
expect(first.map((session) => session.title)).toEqual(["First title"]);
// Callers must not be able to mutate a cached descriptor, including its Date.
first[0]!.title = "Changed by caller";
first[0]!.lastActivityAt.setTime(0);
const repeated = await Promise.all([
client.listImportableSessions({ limit: 1 }),
client.listImportableSessions({ limit: 1 }),
]);
expect(repeated.map((sessions) => [sessions[0]!.title, sessions[0]!.lastActivityAt])).toEqual(
[
["First title", timestamp],
["First title", timestamp],
],
);
repeated[0]![0]!.title = "Changed by overlapping caller";
repeated[0]![0]!.lastActivityAt.setTime(0);
expect([repeated[1]![0]!.title, repeated[1]![0]!.lastActivityAt]).toEqual([
"First title",
timestamp,
]);
await fs.appendFile(sessionFile, title("After append"));
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
).toEqual(["After append"]);
// Same-length rewrites with a restored mtime still invalidate the descriptor.
await fs.writeFile(sessionFile, initial + title("Other title"));
await fs.utimes(sessionFile, timestamp, timestamp);
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
).toEqual(["Other title"]);
const replacement = path.join(projectDir, "replacement.tmp");
await fs.writeFile(replacement, initial + title("Final title"));
await fs.utimes(replacement, timestamp, timestamp);
await fs.rename(replacement, sessionFile);
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
).toEqual(["Final title"]);
await fs.unlink(sessionFile);
await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
});
test.each([
{ providers: 1, requestsPerProvider: 4 },
{ providers: 5, requestsPerProvider: 3 },
])(
"discovers large histories with $providers providers and $requestsPerProvider requests each",
async ({ providers, requestsPerProvider }) => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-heap-"));
try {
const projectDir = path.join(configDir, "projects", "large-transcript");
await fs.mkdir(projectDir, { recursive: true });
const sessionFile = path.join(projectDir, "large.jsonl");
const transcript = await fs.open(sessionFile, "w");
try {
await transcript.writeFile(
`${JSON.stringify({
type: "user",
sessionId: "large-session",
cwd: "/large-project",
message: { content: "First prompt" },
})}\r\n`,
);
const block = `${JSON.stringify({ type: "assistant", text: "x".repeat(1024) })}\n`.repeat(
1024,
);
for (let index = 0; index < 128; index += 1) {
await transcript.writeFile(block);
}
// A malformed record must not hide the final unterminated record.
await transcript.writeFile(
`incomplete json\n${JSON.stringify({
type: "user",
message: { content: "Last prompt 🐢" },
})}`,
);
} finally {
await transcript.close();
}
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--max-old-space-size=96",
"--import",
import.meta.resolve("tsx"),
"--input-type=module",
"--eval",
`
import { deepStrictEqual } from "node:assert";
import { ClaudeAgentClient } from ${JSON.stringify(new URL("./agent.ts", import.meta.url).href)};
import pino from ${JSON.stringify(import.meta.resolve("pino"))};
import { mkdir, symlink } from "node:fs/promises";
import path from "node:path";
const clients = [];
for (let index = 0; index < ${providers}; index++) {
const providerConfigDir = path.join(${JSON.stringify(configDir)}, "alias-" + index);
await mkdir(providerConfigDir);
await symlink(path.join(${JSON.stringify(configDir)}, "projects"), path.join(providerConfigDir, "projects"), "junction");
clients.push(new ClaudeAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
}));
}
const listings = await Promise.all(clients.flatMap(client =>
Array.from({ length: ${requestsPerProvider} }, () => client.listImportableSessions({ limit: 1 }))
));
const sessions = listings[0];
for (const listing of listings) deepStrictEqual(listing, sessions);
console.log(JSON.stringify(sessions));
`,
],
{ timeout: 20_000 },
);
expect(JSON.parse(stdout)).toEqual([
{
providerHandleId: "large-session",
cwd: "/large-project",
title: "First prompt",
firstPromptPreview: "First prompt",
lastPromptPreview: "Last prompt 🐢",
lastActivityAt: (await fs.stat(sessionFile)).mtime.toISOString(),
},
]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
},
);
test("keeps metadata and UTF-8 across read chunk boundaries", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-limit-"));
try {
const projectDir = path.join(configDir, "projects", "boundary");
await fs.mkdir(projectDir, { recursive: true });
const prefix = '{"padding":"';
// Split the four-byte turtle across a 64 KiB input chunk boundary.
const padding = "x".repeat(64 * 1024 - Buffer.byteLength(prefix) - 1);
const suffix =
'","type":"user","sessionId":"boundary-session","cwd":"/boundary","message":{"content":"Hello 🐢"}}';
const remaining = 4 * 1024 * 1024 - Buffer.byteLength(prefix + padding + "🐢" + suffix);
await fs.writeFile(
path.join(projectDir, "boundary.jsonl"),
prefix + padding + "🐢" + "x".repeat(remaining) + suffix,
);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
});
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => ({
providerHandleId: session.providerHandleId,
cwd: session.cwd,
title: session.title,
firstPromptPreview: session.firstPromptPreview,
lastPromptPreview: session.lastPromptPreview,
})),
).toEqual([
{
providerHandleId: "boundary-session",
cwd: "/boundary",
title: "Hello 🐢",
firstPromptPreview: "Hello 🐢",
lastPromptPreview: "Hello 🐢",
},
]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
});
test("rejects a cancelled listing before doing filesystem work", async () => {
const controller = new AbortController();
controller.abort(new Error("Picker closed"));
const client = new ClaudeAgentClient({ logger: createTestLogger() });
await expect(client.listImportableSessions({ signal: controller.signal })).rejects.toThrow(
"Picker closed",
);
});
test("uses the latest native custom title and leaves fixture mtimes unchanged", async () => {
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
@@ -4098,184 +3695,3 @@ describe("Claude question permission notifications", () => {
expect(request.description).toBeUndefined();
});
});
describe("Claude import descriptor scheduling", () => {
function controlledReads() {
const started: string[] = [];
const reads = new Map<
string,
{ resolve: (value: string) => void; reject: (reason: unknown) => void }
>();
const signals = new Map<string, AbortSignal>();
const sharedScheduler = createImportDescriptorScheduler(
(candidate: { cacheKey: string }, signal) => {
started.push(candidate.cacheKey);
signals.set(candidate.cacheKey, signal);
return new Promise<string>((resolve, reject) => {
reads.set(candidate.cacheKey, { resolve, reject });
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
},
);
function scheduler(candidates: string[], signal: AbortSignal): Promise<string[]> {
return sharedScheduler(
candidates.map((cacheKey) => ({ cacheKey })),
signal,
);
}
return { scheduler, started, reads, signals };
}
test("shares a cold read across overlapping listings and leaves capacity for unrelated work", async () => {
const { scheduler, started, reads } = controlledReads();
const signal = new AbortController().signal;
const overlapping = Array.from({ length: 4 }, () => scheduler(["shared"], signal));
await vi.waitFor(() => expect(started).toEqual(["shared"]));
const unrelated = scheduler(["unrelated"], signal);
await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated"]));
reads.get("unrelated")!.resolve("unrelated descriptor");
await expect(unrelated).resolves.toEqual(["unrelated descriptor"]);
reads.get("shared")!.resolve("shared descriptor");
await expect(Promise.all(overlapping)).resolves.toEqual(
Array.from({ length: 4 }, () => ["shared descriptor"]),
);
// A settled entry must not become an accidental second cache.
const later = scheduler(["shared"], signal);
await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated", "shared"]));
reads.get("shared")!.resolve("updated descriptor");
await expect(later).resolves.toEqual(["updated descriptor"]);
});
test("cancelling the initiating listing keeps its other subscriber alive", async () => {
const { scheduler, started, reads } = controlledReads();
const initiator = new AbortController();
const first = scheduler(["shared"], initiator.signal);
const firstRejected = expect(first).rejects.toThrow("First picker closed");
const second = scheduler(["shared"], new AbortController().signal);
await vi.waitFor(() => expect(started).toEqual(["shared"]));
initiator.abort(new Error("First picker closed"));
await firstRejected;
reads.get("shared")!.resolve("shared descriptor");
await expect(second).resolves.toEqual(["shared descriptor"]);
expect(started).toEqual(["shared"]);
});
test("the final subscriber cancellation aborts the read and allows a fresh subscription", async () => {
const { scheduler, started, signals } = controlledReads();
const firstController = new AbortController();
const secondController = new AbortController();
const first = scheduler(["shared"], firstController.signal);
const second = scheduler(["shared"], secondController.signal);
const firstRejected = expect(first).rejects.toThrow("First picker closed");
const secondRejected = expect(second).rejects.toThrow("Final picker closed");
await vi.waitFor(() => expect(started).toEqual(["shared"]));
firstController.abort(new Error("First picker closed"));
await firstRejected;
expect(signals.get("shared")!.aborted).toEqual(false);
secondController.abort(new Error("Final picker closed"));
await secondRejected;
expect(signals.get("shared")!.aborted).toEqual(true);
const retryController = new AbortController();
const retry = scheduler(["shared"], retryController.signal);
const retryRejected = expect(retry).rejects.toThrow("Retry closed");
await vi.waitFor(() => expect(started).toEqual(["shared", "shared"]));
retryController.abort(new Error("Retry closed"));
await retryRejected;
expect(signals.get("shared")!.aborted).toEqual(true);
});
test("runs four reads per listing and gives a later small listing a bounded turn", async () => {
const { scheduler, started, reads, signals } = controlledReads();
const controller = new AbortController();
const large = scheduler(
Array.from({ length: 500 }, (_, index) => `large-${index}`),
controller.signal,
);
const largeRejected = expect(large).rejects.toThrow("Fairness check complete");
await vi.waitFor(() => expect(started).toEqual(["large-0", "large-1", "large-2", "large-3"]));
const small = scheduler(["small"], new AbortController().signal);
reads.get("large-0")!.resolve("first descriptor");
await vi.waitFor(() =>
expect(started).toEqual(["large-0", "large-1", "large-2", "large-3", "small"]),
);
reads.get("small")!.resolve("small descriptor");
await expect(small).resolves.toEqual(["small descriptor"]);
controller.abort(new Error("Fairness check complete"));
await largeRejected;
expect(signals.get("large-1")!.aborted).toEqual(true);
expect(signals.get("large-2")!.aborted).toEqual(true);
expect(signals.get("large-3")!.aborted).toEqual(true);
expect(started.length).toBeLessThanOrEqual(6);
});
test("refills four workers while retaining candidate order after out-of-order completions", async () => {
const { scheduler, started, reads } = controlledReads();
const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal);
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"]));
reads.get("3")!.resolve("descriptor-3");
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4"]));
reads.get("2")!.resolve("descriptor-2");
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4", "5"]));
reads.get("5")!.resolve("descriptor-5");
reads.get("4")!.resolve("descriptor-4");
reads.get("1")!.resolve("descriptor-1");
reads.get("0")!.resolve("descriptor-0");
await expect(listing).resolves.toEqual([
"descriptor-0",
"descriptor-1",
"descriptor-2",
"descriptor-3",
"descriptor-4",
"descriptor-5",
]);
});
test("an unexpected read failure cancels sibling workers without starting remaining candidates", async () => {
const { scheduler, started, reads, signals } = controlledReads();
const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal);
const rejected = expect(listing).rejects.toThrow("Unexpected read failure");
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"]));
reads.get("0")!.reject(new TypeError("Unexpected read failure"));
await rejected;
expect(signals.get("1")!.aborted).toEqual(true);
expect(signals.get("2")!.aborted).toEqual(true);
expect(signals.get("3")!.aborted).toEqual(true);
expect(started).toEqual(["0", "1", "2", "3"]);
});
test("bounds global reads and skips cancelled queued candidates", async () => {
const { scheduler, started, reads } = controlledReads();
const controllers = Array.from({ length: 5 }, () => new AbortController());
const listings = controllers.map((controller, index) =>
scheduler([`${index}-first`], controller.signal),
);
// Attach handlers before aborting any promises.
function errorMessage(error: Error): string {
return error.message;
}
const settled = listings.map((listing) => listing.catch(errorMessage));
await vi.waitFor(() => expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]));
controllers[4]!.abort(new Error("Queued picker closed"));
await expect(settled[4]).resolves.toEqual("Queued picker closed");
controllers[0]!.abort(new Error("Active picker closed"));
await expect(settled[0]).resolves.toEqual("Active picker closed");
reads.get("1-first")!.reject(new Error("Stop 1"));
reads.get("2-first")!.reject(new Error("Stop 2"));
reads.get("3-first")!.reject(new Error("Stop 3"));
await expect(Promise.all(settled)).resolves.toEqual([
"Active picker closed",
"Stop 1",
"Stop 2",
"Stop 3",
"Queued picker closed",
]);
expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]);
// A cancelled queue entry must not consume a later listing's read slot.
const later = scheduler(["later"], new AbortController().signal);
await vi.waitFor(() =>
expect(started).toEqual(["0-first", "1-first", "2-first", "3-first", "later"]),
);
reads.get("later")!.resolve("later descriptor");
await expect(later).resolves.toEqual(["later descriptor"]);
});
});
@@ -4,8 +4,6 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
import path from "node:path";
import { LRUCache } from "lru-cache";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import {
type AgentDefinition,
type CanUseTool,
@@ -469,10 +467,6 @@ interface ClaudeAgentClientOptions {
rewindSdk?: ClaudeRewindSdk;
}
type ClaudeImportDescriptorResult =
| { type: "parsed"; descriptor: ImportableProviderSession | null }
| { type: "unreadable"; error: Error };
interface ClaudeAgentSessionOptions {
defaults?: { agents?: Record<string, AgentDefinition> };
runtimeSettings?: ProviderRuntimeSettings;
@@ -1577,15 +1571,6 @@ export class ClaudeAgentClient implements AgentClient {
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly rewindSdk: ClaudeRewindSdk;
private readonly scheduleImportDescriptors = createImportDescriptorScheduler(
(candidate: ClaudeSessionCandidate, signal) => this.readImportDescriptor(candidate, signal),
);
private readonly importDescriptorCache = new LRUCache<string, ImportableProviderSession>({
max: 500,
maxSize: 8 * 1024 * 1024,
sizeCalculation: (descriptor, key) =>
128 + 2 * (JSON.stringify(descriptor).length + key.length),
});
constructor(options: ClaudeAgentClientOptions) {
this.discoverModels = options.discoverModels ?? discoverClaudeModels;
@@ -1729,73 +1714,26 @@ export class ClaudeAgentClient implements AgentClient {
}
async listImportableSessions(
options?: ListImportableSessionsOptions & { signal?: AbortSignal },
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
// Leave time to deliver the provider error before the client RPC expires at 60 seconds.
const deadline = AbortSignal.timeout(45_000);
const signal = options?.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
signal.throwIfAborted();
const configDir = claudeConfigDir(this.buildProviderEnv());
const sessionsRoot = options?.cwd
? claudeProjectDirSync(options.cwd, { configDir })
: path.join(configDir, "projects");
const rootExists = await pathExists(sessionsRoot);
signal.throwIfAborted();
if (!rootExists) return [];
if (!(await pathExists(sessionsRoot))) {
return [];
}
const limit = options?.limit ?? 20;
const scanLimit = Math.min(options?.scanLimit ?? limit * 3, 500);
const candidates = await collectRecentClaudeSessions(sessionsRoot, scanLimit, {
rootIsProjectDir: Boolean(options?.cwd),
logger: this.logger,
});
const results = await this.scheduleImportDescriptors(candidates, signal);
const failures = results.filter((result) => result.type === "unreadable");
if (failures.length > 0 && failures.length === results.length) {
const errors = failures.map((result) => result.error);
throw new AggregateError(
errors,
`Cannot list Claude sessions: all ${failures.length} candidate transcripts have unreadable import metadata. ${errors[0]!.message}`,
);
}
const parsed = results.filter((result) => result.type === "parsed");
const parsed = await Promise.all(
candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)),
);
return parsed
.map((result) => result.descriptor)
.filter((session): session is ImportableProviderSession => session !== null)
.slice(0, limit)
.map((session) => structuredClone(session));
}
private async readImportDescriptor(
candidate: ClaudeSessionCandidate,
signal: AbortSignal,
): Promise<ClaudeImportDescriptorResult> {
signal.throwIfAborted();
const cached = this.importDescriptorCache.get(candidate.cacheKey);
if (cached) return { type: "parsed", descriptor: structuredClone(cached) };
let descriptor: ImportableProviderSession | null;
try {
descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime, signal);
} catch (error) {
signal.throwIfAborted();
if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error;
this.logger.warn(
{ err: error, path: candidate.path },
"Skipping Claude transcript with unreadable import metadata",
);
return { type: "unreadable", error };
}
if (descriptor) {
const current = await fsPromises.stat(candidate.path).catch((error: unknown) => {
if (!isExpectedClaudeImportFileError(error)) throw error;
return null;
});
signal.throwIfAborted();
if (current && claudeSessionCacheKey(candidate.path, current) === candidate.cacheKey) {
this.importDescriptorCache.set(candidate.cacheKey, structuredClone(descriptor));
}
}
return { type: "parsed", descriptor: descriptor ? structuredClone(descriptor) : null };
.slice(0, limit);
}
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
@@ -6602,69 +6540,29 @@ function createAsyncMessageInput<T>(): AsyncMessageInput<T> {
interface ClaudeSessionCandidate {
path: string;
mtime: Date;
cacheKey: string;
}
function claudeSessionCacheKey(filePath: string, stats: fs.Stats): string {
return `${filePath}\0${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`;
}
function isMissingClaudeImportFile(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
);
}
function isExpectedClaudeImportFileError(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) return false;
return (
error.code === "ENOENT" ||
error.code === "ENOTDIR" ||
error.code === "EACCES" ||
error.code === "EPERM"
);
}
async function pathExists(target: string): Promise<boolean> {
try {
await fsPromises.access(target);
return true;
} catch (error) {
if (!isMissingClaudeImportFile(error)) throw error;
} catch {
return false;
}
}
interface ClaudeSessionDiscoveryOptions {
rootIsProjectDir: boolean;
logger: Logger;
}
async function collectRecentClaudeSessions(
root: string,
limit: number,
options: ClaudeSessionDiscoveryOptions,
options?: { rootIsProjectDir?: boolean },
): Promise<ClaudeSessionCandidate[]> {
const permissionErrors: Error[] = [];
function recordDiscoveryFailure(error: unknown, targetPath: string): void {
if (isMissingClaudeImportFile(error)) return;
if (!(error instanceof Error) || !isExpectedClaudeImportFileError(error)) throw error;
permissionErrors.push(error);
options.logger.warn(
{ err: error, path: targetPath },
"Skipping Claude session discovery path with denied access",
);
}
let rootEntries: string[];
try {
rootEntries = await fsPromises.readdir(root);
} catch (error) {
if (!isMissingClaudeImportFile(error)) throw error;
} catch {
return [];
}
const fileEntries = options.rootIsProjectDir
const fileEntries = options?.rootIsProjectDir
? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file))
: (
await Promise.all(
@@ -6677,8 +6575,7 @@ async function collectRecentClaudeSessions(
return files
.filter((file) => file.endsWith(".jsonl"))
.map((file) => path.join(projectPath, file));
} catch (error) {
recordDiscoveryFailure(error, projectPath);
} catch {
return [] as string[];
}
}),
@@ -6688,14 +6585,8 @@ async function collectRecentClaudeSessions(
fileEntries.map(async (fullPath) => {
try {
const fileStats = await fsPromises.stat(fullPath);
if (!fileStats.isFile()) return null;
return {
path: fullPath,
mtime: fileStats.mtime,
cacheKey: claudeSessionCacheKey(fullPath, fileStats),
};
} catch (error) {
recordDiscoveryFailure(error, fullPath);
return { path: fullPath, mtime: fileStats.mtime };
} catch {
return null;
}
}),
@@ -6703,12 +6594,6 @@ async function collectRecentClaudeSessions(
const candidates: ClaudeSessionCandidate[] = statResults.filter(
(entry): entry is ClaudeSessionCandidate => entry !== null,
);
if (candidates.length === 0 && permissionErrors.length > 0) {
throw new AggregateError(
permissionErrors,
`Cannot discover Claude sessions: access was denied to ${permissionErrors.length} paths and no transcripts could be discovered. ${permissionErrors[0]!.message}`,
);
}
return candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, limit);
}
@@ -6780,8 +6665,14 @@ function applyClaudeSessionEntryToAccumulator(
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date,
signal: AbortSignal,
): Promise<ImportableProviderSession | null> {
let content: string;
try {
content = await fsPromises.readFile(filePath, "utf8");
} catch {
return null;
}
const acc: ClaudeSessionDescriptorAccumulator = {
sessionId: null,
cwd: null,
@@ -6792,38 +6683,16 @@ async function parseClaudeSessionDescriptor(
lastPromptPreview: null,
};
// Keep only the current record, including image records larger than a read chunk.
const record: Buffer[] = [];
const stream = fs.createReadStream(filePath, { highWaterMark: 64 * 1024, signal });
function applyRecord(): void {
const line = Buffer.concat(record).toString("utf8");
if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) return;
for (const line of content.split(/\r?\n/)) {
if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
return;
continue;
}
applyClaudeSessionEntryToAccumulator(entry, acc);
}
try {
for await (const chunk of stream) {
signal.throwIfAborted();
let start = 0;
while (start < chunk.length) {
const newline = chunk.indexOf(10, start);
const end = newline === -1 ? chunk.length : newline;
record.push(chunk.subarray(start, end));
if (newline === -1) break;
applyRecord();
record.length = 0;
start = newline + 1;
}
}
if (record.length > 0) applyRecord();
} finally {
stream.destroy();
}
const { sessionId, cwd } = acc;
@@ -1,92 +0,0 @@
import pLimit from "p-limit";
interface ImportDescriptorCandidate {
cacheKey: string;
}
interface PendingDescriptor<Descriptor> {
controller: AbortController;
subscribers: number;
settled: boolean;
promise: Promise<Descriptor>;
}
export function createImportDescriptorScheduler<
Candidate extends ImportDescriptorCandidate,
Descriptor,
>(
read: (candidate: Candidate, signal: AbortSignal) => Promise<Descriptor>,
): (candidates: Candidate[], signal: AbortSignal) => Promise<Descriptor[]> {
const limit = pLimit(4);
const reads = new Map<string, PendingDescriptor<Descriptor>>();
function acquire(candidate: Candidate): PendingDescriptor<Descriptor> {
let pending = reads.get(candidate.cacheKey);
if (!pending) {
const controller = new AbortController();
const entry: PendingDescriptor<Descriptor> = {
controller,
subscribers: 0,
settled: false,
promise: limit(() => {
controller.signal.throwIfAborted();
return read(candidate, controller.signal);
}).finally(() => {
entry.settled = true;
if (reads.get(candidate.cacheKey) === entry) reads.delete(candidate.cacheKey);
}),
};
reads.set(candidate.cacheKey, entry);
pending = entry;
}
pending.subscribers += 1;
return pending;
}
function release(candidate: Candidate, pending: PendingDescriptor<Descriptor>): void {
pending.subscribers -= 1;
// The initiating listing has no special ownership of a shared read.
if (pending.subscribers === 0 && !pending.settled) {
if (reads.get(candidate.cacheKey) === pending) reads.delete(candidate.cacheKey);
pending.controller.abort();
}
}
return async (candidates, signal) => {
signal.throwIfAborted();
const controller = new AbortController();
signal = AbortSignal.any([signal, controller.signal]);
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
const results: Descriptor[] = [];
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < candidates.length) {
signal.throwIfAborted();
const index = nextIndex++;
const candidate = candidates[index]!;
// Each worker queues only its next read. Later listings enter the
// shared FIFO after at most four outstanding reads from this listing.
const pending = acquire(candidate);
try {
results[index] = await Promise.race([pending.promise, aborted]);
} finally {
release(candidate, pending);
}
}
}
try {
const workers = Array.from({ length: Math.min(4, candidates.length) }, () => worker());
await Promise.all(workers);
return results;
} catch (error) {
controller.abort(error);
throw error;
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
};
}
+2 -2
View File
@@ -188,6 +188,7 @@ test("PR routing declares stable behavior ownership", () => {
desktop: [
"packages/desktop/**",
"packages/app/src/desktop/**",
"packages/app/src/**/*.electron.{ts,tsx}",
"packages/server/src/server/browser-tools/**",
"packages/app/e2e/support/**",
"packages/app/*config.{cjs,js,ts}",
@@ -251,13 +252,11 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
const filters = loadFilters(filtersPath);
const browserSpecs = filesUnder("packages/app/e2e", (path) => path.endsWith(".spec.ts"));
const desktopSpecs = filesUnder("packages/desktop/e2e", (path) => path.endsWith(".spec.ts"));
const electronModules = filesUnder("packages/app/src", (path) => /\.electron\.tsx?$/.test(path));
assert.ok(browserSpecs.length > 0);
assert.ok(desktopSpecs.length > 0);
assert.ok(browserSpecs.every((path) => path.startsWith("packages/app/e2e/browser/")));
assert.ok(desktopSpecs.every((path) => path.startsWith("packages/desktop/e2e/")));
assert.ok(electronModules.every((path) => path.startsWith("packages/app/src/desktop/")));
const desktopPackage = JSON.parse(readFileSync(desktopPackagePath, "utf8"));
assert.match(desktopPackage.scripts.test, /--exclude ["']e2e\/\*\*["']/);
@@ -277,6 +276,7 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
assert.deepEqual(filters.desktop, [
"packages/desktop/**",
"packages/app/src/desktop/**",
"packages/app/src/**/*.electron.{ts,tsx}",
"packages/server/src/server/browser-tools/**",
"packages/app/e2e/support/**",
"packages/app/*config.{cjs,js,ts}",