mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 09:12:44 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff6d23dbe | ||
|
|
7439729e50 | ||
|
|
3c05dcbf9a | ||
|
|
21f207d81d | ||
|
|
afd338d65e | ||
|
|
568aaeaf93 | ||
|
|
c4a8efddb1 | ||
|
|
9b61a6779d |
@@ -41,7 +41,6 @@ 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}"
|
||||
|
||||
@@ -36,7 +36,6 @@ 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 |
|
||||
|
||||
+9
-16
@@ -104,24 +104,17 @@ Do not collapse this topology with a catch-all, `getId`, or
|
||||
|
||||
## Agent 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 and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Params
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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,8 +21,10 @@ 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,7 +11,6 @@ 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";
|
||||
@@ -94,20 +93,10 @@ 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("keeps an offline notification on the agent route until the host connects", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||
const target = {
|
||||
serverId: "notification-offline-host",
|
||||
workspaceId: "notification-workspace",
|
||||
@@ -134,33 +123,14 @@ test.describe("Workspace navigation regression", () => {
|
||||
);
|
||||
}, target);
|
||||
|
||||
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();
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
test("shows the New launcher after returning from settings and closing the last agent draft tab", async ({
|
||||
@@ -351,7 +321,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a stale notification workspace preserves retained workspace selection and tabs", async ({
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -465,7 +435,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: firstWorkspace.workspaceId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
@@ -493,22 +463,6 @@ 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,6 +18,9 @@ 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 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.
|
||||
// The workspace layout consumes `?open=...`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
||||
// so we allow a generous timeout here.
|
||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
|
||||
@@ -86,8 +86,7 @@ export async function navigateToTerminal(
|
||||
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await terminalTab.click();
|
||||
|
||||
// Inactive terminal tabs retain their surfaces; interact with the selected tab.
|
||||
const terminalSurface = page.getByTestId("terminal-surface").filter({ visible: true });
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// Wait for loading overlay to disappear (terminal attached)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
|
||||
import {
|
||||
@@ -61,26 +61,3 @@ 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 });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
@@ -49,7 +51,11 @@ 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 { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
@@ -91,6 +97,7 @@ 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 {
|
||||
@@ -125,9 +132,14 @@ 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 { NotificationOpenRouter } from "@/notification-open/react";
|
||||
import { ensureOsNotificationPermission } from "@/utils/os-notifications";
|
||||
import {
|
||||
ensureOsNotificationPermission,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -147,6 +159,114 @@ 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);
|
||||
|
||||
@@ -825,9 +945,6 @@ function WorkspaceRouteNavigationBridge() {
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
useEffect(() => {
|
||||
if (getDesktopHost()) void ensureOsNotificationPermission();
|
||||
}, []);
|
||||
return (
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
@@ -842,15 +959,10 @@ function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationOpenBridge() {
|
||||
const { startupBlocker } = useHostRuntimeBootstrapState();
|
||||
return <NotificationOpenRouter startupBlocked={startupBlocker.kind !== "none"} />;
|
||||
}
|
||||
|
||||
function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<NotificationOpenBridge />
|
||||
<PushNotificationRouter />
|
||||
<SidebarCalloutProvider>
|
||||
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||
</SidebarCalloutProvider>
|
||||
|
||||
@@ -4,8 +4,6 @@ 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";
|
||||
@@ -85,16 +83,7 @@ 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,
|
||||
@@ -117,7 +106,7 @@ function HostAgentReadyRouteContent() {
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId, pin: true });
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
return;
|
||||
}
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "expo-router/react-navigation";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } 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 { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
areRetainedWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
|
||||
@@ -64,8 +64,6 @@ 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;
|
||||
@@ -83,9 +81,7 @@ function clearConsumedOpenIntent(input: {
|
||||
}) {
|
||||
input.navigation.setParams({ open: undefined });
|
||||
if (isWeb) {
|
||||
// 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);
|
||||
stripOpenSearchParamFromBrowserUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +95,15 @@ 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);
|
||||
@@ -109,7 +111,14 @@ function HostWorkspaceRouteContent() {
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(params.open);
|
||||
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),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
@@ -117,31 +126,74 @@ function HostWorkspaceRouteContent() {
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
||||
}, [serverId, workspaceId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!openValue || !serverId || !workspaceId) return;
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab(
|
||||
{
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
},
|
||||
{ openTab: useWorkspaceLayoutStore.getState().deferOpenTab },
|
||||
);
|
||||
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;
|
||||
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
});
|
||||
}
|
||||
|
||||
// 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]);
|
||||
|
||||
return <WorkspaceDeck />;
|
||||
setIntentConsumed(true);
|
||||
}, [
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
if (
|
||||
openValue &&
|
||||
!isOpenIntentWaitingForWorkspace &&
|
||||
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck() {
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
|
||||
@@ -212,6 +264,7 @@ function WorkspaceDeck() {
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
active={active}
|
||||
recoveryRequested={recoveryRequested}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -224,10 +277,12 @@ function WorkspaceDeck() {
|
||||
function WorkspaceDeckEntry({
|
||||
selection,
|
||||
active,
|
||||
recoveryRequested,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
active: boolean;
|
||||
recoveryRequested: boolean;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
|
||||
@@ -257,6 +312,7 @@ function WorkspaceDeckEntry({
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={active}
|
||||
recoveryRequested={active && recoveryRequested}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -38,5 +38,3 @@ export function addNotificationResponseReceivedListener() {
|
||||
export async function getLastNotificationResponseAsync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearLastNotificationResponse(): void {}
|
||||
|
||||
@@ -5,8 +5,6 @@ const VALID_ROUTE = {
|
||||
serverId: "server-1",
|
||||
agentId: "agent-1",
|
||||
cachedWorkspaceId: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
hasHydratedLayout: true,
|
||||
} as const;
|
||||
|
||||
describe("resolveAgentRoute", () => {
|
||||
@@ -34,20 +32,6 @@ 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,18 +24,13 @@ 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 && !hydrationPending) {
|
||||
if (cachedWorkspaceId) {
|
||||
return { kind: "resolved", workspaceId: cachedWorkspaceId };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
createNotificationOpenRouter,
|
||||
parseNotificationOpenTarget,
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
} from "./model";
|
||||
export type { NotificationOpenSource, NotificationTap } from "./source";
|
||||
@@ -1,150 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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?.();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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";
|
||||
@@ -1,23 +0,0 @@
|
||||
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,7 +93,6 @@ import {
|
||||
type SheetTab,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
usePendingWorkspaceOpen,
|
||||
useWorkspaceLayoutStoreHydrated,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -272,10 +271,12 @@ 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 {
|
||||
@@ -418,6 +419,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
useEffect(() => {
|
||||
@@ -431,6 +433,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -745,9 +748,9 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
function shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces: boolean,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
pendingOpenTarget: WorkspaceTabTarget | null,
|
||||
recoveryRequested: boolean,
|
||||
): boolean {
|
||||
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
|
||||
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
|
||||
}
|
||||
|
||||
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
@@ -1012,6 +1015,7 @@ function WorkspaceScreenContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1145,18 +1149,13 @@ 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,
|
||||
pendingOpenTarget,
|
||||
recoveryRequested,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ 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,12 +21,7 @@ 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,
|
||||
@@ -38,7 +33,7 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
|
||||
navigateToRoute: (route) => navigations.push(route),
|
||||
...overrides,
|
||||
};
|
||||
return { deps, navigations, remembered, openedTabs, deferredTabs };
|
||||
return { deps, navigations, remembered, openedTabs };
|
||||
}
|
||||
|
||||
function createLastSelectionDeps(
|
||||
@@ -144,7 +139,7 @@ describe("workspace navigation", () => {
|
||||
});
|
||||
|
||||
it("defers an agent tab until a missing workspace is recovered", () => {
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map(),
|
||||
});
|
||||
|
||||
@@ -158,8 +153,7 @@ describe("workspace navigation", () => {
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
|
||||
expect(deferredTabs).toHaveLength(1);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
});
|
||||
|
||||
it("defers an agent tab until persisted workspace layout has hydrated", () => {
|
||||
@@ -167,7 +161,7 @@ describe("workspace navigation", () => {
|
||||
id: "workspace-a",
|
||||
workspaceDirectory: "/repo/workspace-a",
|
||||
} as WorkspaceDescriptor;
|
||||
const { deps, navigations, openedTabs, deferredTabs } = createFakeDeps({
|
||||
const { deps, navigations, openedTabs } = createFakeDeps({
|
||||
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
|
||||
isWorkspaceLayoutHydrated: () => false,
|
||||
});
|
||||
@@ -183,8 +177,7 @@ describe("workspace navigation", () => {
|
||||
);
|
||||
|
||||
expect(openedTabs).toEqual([]);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
|
||||
expect(deferredTabs).toHaveLength(1);
|
||||
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
import {
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
@@ -12,7 +13,6 @@ 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,7 +35,6 @@ 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;
|
||||
}
|
||||
@@ -92,10 +91,13 @@ export function navigateToWorkspace(
|
||||
workspaces,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
const shouldDeferOpen = !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated();
|
||||
const shouldDeferAgentOpen = Boolean(
|
||||
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
|
||||
);
|
||||
if (input.target) {
|
||||
const openTab = shouldDeferOpen ? deps.deferOpenTab : deps.openTab;
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
|
||||
if (!shouldDeferAgentOpen) {
|
||||
prepareWorkspaceTab({ ...input, target: input.target }, deps);
|
||||
}
|
||||
} else {
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(input.serverId)).filter(
|
||||
@@ -112,7 +114,14 @@ export function navigateToWorkspace(
|
||||
}
|
||||
}
|
||||
|
||||
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
const route =
|
||||
input.target?.kind === "agent" && shouldDeferAgentOpen
|
||||
? buildHostWorkspaceOpenRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
`agent:${input.target.agentId}`,
|
||||
)
|
||||
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
|
||||
deps.navigateToRoute(route);
|
||||
return route;
|
||||
|
||||
@@ -5131,43 +5131,3 @@ 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, useLayoutEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import type { z } from "zod";
|
||||
@@ -130,9 +130,6 @@ 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
|
||||
@@ -1023,26 +1020,6 @@ 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: {},
|
||||
@@ -2183,7 +2160,6 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
set((state) => {
|
||||
const hasAny =
|
||||
normalizedWorkspaceKey in state.pendingOpenByWorkspace ||
|
||||
normalizedWorkspaceKey in state.layoutByWorkspace ||
|
||||
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
|
||||
@@ -2197,8 +2173,6 @@ export function createWorkspaceLayoutStore(
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
const { [normalizedWorkspaceKey]: _pendingOpen, ...pendingOpenByWorkspace } =
|
||||
state.pendingOpenByWorkspace;
|
||||
const {
|
||||
[normalizedWorkspaceKey]: _autoOpened,
|
||||
...pullRequestTabAutoOpenedByWorkspace
|
||||
@@ -2225,7 +2199,6 @@ export function createWorkspaceLayoutStore(
|
||||
state.sidePaneIdByWorkspace;
|
||||
return {
|
||||
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
|
||||
pendingOpenByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace,
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -2375,27 +2348,3 @@ 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 only when the store has no record", () => {
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
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([{ serverId: SERVER_ID, agentId: AGENT_ID }]);
|
||||
expect(readTargets).toEqual([]);
|
||||
expect(tabNavigations).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
@@ -86,12 +86,6 @@ 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 =
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId })
|
||||
.agentWorkspaceId ?? input.workspaceId;
|
||||
input.workspaceId ??
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (!workspaceId) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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,4 +1,3 @@
|
||||
import { WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface MockNotificationOptions {
|
||||
@@ -133,7 +132,7 @@ describe("sendOsNotification", () => {
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
@@ -160,7 +159,7 @@ describe("sendOsNotification", () => {
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves navigation to the app when no listener handles the click", async () => {
|
||||
it("falls back to route navigation when no listener handles the click", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
@@ -208,7 +207,9 @@ describe("sendOsNotification", () => {
|
||||
expect(clicked.clickListeners).toHaveLength(1);
|
||||
clicked.clickListeners[0]?.({} as Event);
|
||||
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
expect(assign).toHaveBeenCalledWith(
|
||||
"/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the Notification API is unavailable", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Asset } from "expo-asset";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { parseNotificationOpenTarget, WEB_NOTIFICATION_CLICK_EVENT } from "@/notification-open";
|
||||
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface OsNotificationPayload {
|
||||
@@ -17,6 +17,8 @@ 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;
|
||||
|
||||
@@ -96,7 +98,8 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
|
||||
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
|
||||
return parseNotificationOpenTarget(data) !== null;
|
||||
const target = resolveNotificationTarget(data);
|
||||
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
|
||||
}
|
||||
|
||||
function getWebNotificationIconUrl(): string | undefined {
|
||||
@@ -132,12 +135,31 @@ 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", () => {
|
||||
dispatchWebNotificationClick({ data });
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,6 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -15,9 +18,11 @@ 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,
|
||||
@@ -1740,6 +1745,404 @@ 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;
|
||||
@@ -3695,3 +4098,184 @@ 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,6 +4,8 @@ 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,
|
||||
@@ -467,6 +469,10 @@ interface ClaudeAgentClientOptions {
|
||||
rewindSdk?: ClaudeRewindSdk;
|
||||
}
|
||||
|
||||
type ClaudeImportDescriptorResult =
|
||||
| { type: "parsed"; descriptor: ImportableProviderSession | null }
|
||||
| { type: "unreadable"; error: Error };
|
||||
|
||||
interface ClaudeAgentSessionOptions {
|
||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -1571,6 +1577,15 @@ 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;
|
||||
@@ -1714,26 +1729,73 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
options?: ListImportableSessionsOptions,
|
||||
options?: ListImportableSessionsOptions & { signal?: AbortSignal },
|
||||
): 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");
|
||||
if (!(await pathExists(sessionsRoot))) {
|
||||
return [];
|
||||
}
|
||||
const rootExists = await pathExists(sessionsRoot);
|
||||
signal.throwIfAborted();
|
||||
if (!rootExists) 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 parsed = await Promise.all(
|
||||
candidates.map((candidate) => parseClaudeSessionDescriptor(candidate.path, candidate.mtime)),
|
||||
);
|
||||
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");
|
||||
return parsed
|
||||
.map((result) => result.descriptor)
|
||||
.filter((session): session is ImportableProviderSession => session !== null)
|
||||
.slice(0, limit);
|
||||
.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 };
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
|
||||
@@ -6540,29 +6602,69 @@ 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 {
|
||||
} catch (error) {
|
||||
if (!isMissingClaudeImportFile(error)) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ClaudeSessionDiscoveryOptions {
|
||||
rootIsProjectDir: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
async function collectRecentClaudeSessions(
|
||||
root: string,
|
||||
limit: number,
|
||||
options?: { rootIsProjectDir?: boolean },
|
||||
options: ClaudeSessionDiscoveryOptions,
|
||||
): 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 {
|
||||
} catch (error) {
|
||||
if (!isMissingClaudeImportFile(error)) throw error;
|
||||
return [];
|
||||
}
|
||||
const fileEntries = options?.rootIsProjectDir
|
||||
const fileEntries = options.rootIsProjectDir
|
||||
? rootEntries.filter((file) => file.endsWith(".jsonl")).map((file) => path.join(root, file))
|
||||
: (
|
||||
await Promise.all(
|
||||
@@ -6575,7 +6677,8 @@ async function collectRecentClaudeSessions(
|
||||
return files
|
||||
.filter((file) => file.endsWith(".jsonl"))
|
||||
.map((file) => path.join(projectPath, file));
|
||||
} catch {
|
||||
} catch (error) {
|
||||
recordDiscoveryFailure(error, projectPath);
|
||||
return [] as string[];
|
||||
}
|
||||
}),
|
||||
@@ -6585,8 +6688,14 @@ async function collectRecentClaudeSessions(
|
||||
fileEntries.map(async (fullPath) => {
|
||||
try {
|
||||
const fileStats = await fsPromises.stat(fullPath);
|
||||
return { path: fullPath, mtime: fileStats.mtime };
|
||||
} catch {
|
||||
if (!fileStats.isFile()) return null;
|
||||
return {
|
||||
path: fullPath,
|
||||
mtime: fileStats.mtime,
|
||||
cacheKey: claudeSessionCacheKey(fullPath, fileStats),
|
||||
};
|
||||
} catch (error) {
|
||||
recordDiscoveryFailure(error, fullPath);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
@@ -6594,6 +6703,12 @@ 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);
|
||||
}
|
||||
|
||||
@@ -6665,14 +6780,8 @@ 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,
|
||||
@@ -6683,16 +6792,38 @@ async function parseClaudeSessionDescriptor(
|
||||
lastPromptPreview: null,
|
||||
};
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) continue;
|
||||
// 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;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -188,7 +188,6 @@ 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}",
|
||||
@@ -252,11 +251,13 @@ 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\/\*\*["']/);
|
||||
@@ -276,7 +277,6 @@ 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}",
|
||||
|
||||
Reference in New Issue
Block a user