Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e1c786e70b feat(app): store assistant image previews as assets 2026-04-22 22:23:10 +07:00
76 changed files with 1045 additions and 4127 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
release create "$RELEASE_TAG"
--repo "${{ github.repository }}"
--title "Paseo $RELEASE_TAG"
--notes ""
--generate-notes
)
if [[ "$IS_PRERELEASE" == "true" ]]; then
+1 -2
View File
@@ -4,14 +4,13 @@ on:
push:
branches: [main]
paths:
- 'CHANGELOG.md'
- 'packages/website/**'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published]
types: [published, edited]
workflow_dispatch:
jobs:
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--notes "" \
--generate-notes \
$prerelease_flag || {
echo "Release creation raced with another workflow; continuing."
}
+1 -3
View File
@@ -54,9 +54,7 @@ jobs:
fi
create_if_missing="false"
if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then
create_if_missing="true"
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
create_if_missing="true"
fi
-24
View File
@@ -1,29 +1,5 @@
# Changelog
## 0.1.62 - 2026-04-23
### Added
- Sidebar warning when your app and daemon versions drift apart, with a shortcut to settings.
### Improved
- Workspaces appear in the sidebar immediately on startup instead of waiting for git registration.
### Fixed
- Pull request status resolves correctly for PRs opened from forks.
- Installing the paseo CLI from the macOS desktop app now works in packaged builds.
- Agents launched from the desktop app no longer inherit Electron-only environment variables.
## 0.1.61 - 2026-04-23
### Added
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
### Improved
- Pi loads your installed extensions on startup so their models show up in the model picker.
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
## 0.1.60 - 2026-04-22
### Added
-43
View File
@@ -424,48 +424,6 @@ Models and modes are discovered dynamically at runtime from the agent process. I
Profile models (defined in config.json) completely replace runtime-discovered models when present.
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
Example: add an experimental model while keeping every model the provider discovers at runtime:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
]
}
}
}
}
```
Example: relabel a discovered model without replacing the full list:
```json
{
"agents": {
"providers": {
"my-agent": {
"extends": "acp",
"label": "My Agent",
"command": ["my-agent", "--acp"],
"additionalModels": [
{ "id": "provider/model-id", "label": "My Preferred Label" }
]
}
}
}
}
```
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
---
## Provider override reference
@@ -480,7 +438,6 @@ Every entry under `agents.providers` accepts these fields:
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
| `order` | `number` | No | Sort order in the provider list |
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-OF3YosIWd00SAKiDGXo3yXsWASTmRSJVF3a9g7uTpEk=";
npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.62",
"version": "0.1.60",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.62",
"version": "0.1.60",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -37385,16 +37385,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.62",
"version": "0.1.60",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.62",
"@getpaseo/highlight": "0.1.62",
"@getpaseo/server": "0.1.62",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -37539,11 +37539,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.62",
"version": "0.1.60",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.62",
"@getpaseo/server": "0.1.62",
"@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37584,11 +37584,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.62",
"version": "0.1.60",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.62",
"@getpaseo/server": "0.1.62",
"@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -37622,7 +37622,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.62",
"version": "0.1.60",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -37823,7 +37823,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.62",
"version": "0.1.60",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37849,7 +37849,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.62",
"version": "0.1.60",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37865,14 +37865,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.62",
"version": "0.1.60",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.62",
"@getpaseo/relay": "0.1.62",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -38627,7 +38627,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.62",
"version": "0.1.60",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.62",
"version": "0.1.60",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.62",
"version": "0.1.60",
"private": true,
"scripts": {
"start": "APP_VARIANT=development expo start",
@@ -32,9 +32,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.62",
"@getpaseo/highlight": "0.1.62",
"@getpaseo/server": "0.1.62",
"@getpaseo/expo-two-way-audio": "0.1.60",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/server": "0.1.60",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
+85 -88
View File
@@ -1,8 +1,5 @@
import "@/styles/unistyles";
import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { polyfillCrypto } from "@/polyfills/crypto";
import {
Stack,
useGlobalSearchParams,
@@ -10,92 +7,95 @@ import {
usePathname,
useRouter,
} from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { QueryClientProvider } from "@tanstack/react-query";
import {
getHostRuntimeStore,
useHosts,
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useOpenProject } from "@/hooks/use-open-project";
import { useStableEvent } from "@/hooks/use-stable-event";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useState,
useEffect,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
import { Platform, Text, View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { DaemonVersionMismatchCalloutSource } from "@/components/daemon-version-mismatch-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { DownloadToast } from "@/components/download-toast";
import { UpdateBanner } from "@/desktop/updates/update-banner";
import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
import {
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useOpenProject } from "@/hooks/use-open-project";
import { loadSettingsFromStorage, useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto";
import { queryClient } from "@/query/query-client";
import {
getHostRuntimeStore,
useHostMutations,
useHostRuntimeClient,
useHosts,
} from "@/runtime/host-runtime";
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { resolveActiveHost } from "@/utils/active-host";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { queryClient } from "@/query/query-client";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import {
buildHostRootRoute,
mapPathnameToServer,
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
addBrowserActiveWorkspaceLocationListener,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { resolveActiveHost } from "@/utils/active-host";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
buildHostRootRoute,
decodeWorkspaceIdFromPathSegment,
mapPathnameToServer,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseWorkspaceOpenIntent,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { isWeb, isNative } from "@/constants/platform";
polyfillCrypto();
@@ -499,8 +499,7 @@ function AppContainer({
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateCalloutSource />
<DaemonVersionMismatchCalloutSource />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceShortcutTargetsSubscriber
@@ -907,20 +906,18 @@ export default function RootLayout() {
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<SidebarCalloutProvider>
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</SidebarCalloutProvider>
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</KeyboardProvider>
+4 -10
View File
@@ -55,10 +55,9 @@ export function parseImageDataUrl(
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
};
} catch {
return null;
@@ -81,18 +80,13 @@ export function getFileNameFromPath(path: string | null | undefined): string | n
}
export function createPreviewAttachmentId(input: {
base64: string;
mimeType: string;
path?: string | null;
size?: number | null;
modifiedAt?: string | null;
contentLength?: number | null;
}): string {
const path = input.path?.trim() ?? "";
const size = Number.isFinite(input.size) ? String(input.size) : "";
const modifiedAt = input.modifiedAt?.trim() ?? "";
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
return `preview_${size || contentLength || "unknown"}_${hash}`;
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
return `preview_${input.base64.length}_${hash}`;
}
export async function blobToBase64(blob: Blob): Promise<string> {
@@ -1,171 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18 },
borderWidth: { 1: 1 },
borderRadius: { sm: 4, md: 6, lg: 8, xl: 12, "2xl": 16, full: 999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
opacity: { 50: 0.5 },
shadow: { md: {} },
colors: {
surfaceSidebar: "#111",
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#666",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff4444",
primary: "#0a84ff",
palette: { white: "#fff" },
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
X: createIcon("X"),
CheckCircle2: createIcon("CheckCircle2"),
AlertTriangle: createIcon("AlertTriangle"),
};
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { CalloutCard } from "./callout-card";
describe("CalloutCard", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("renders title and description", () => {
act(() => {
root?.render(
<CalloutCard title="Update available" description="v1.2.3 is ready to install." />,
);
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).toContain("v1.2.3 is ready to install.");
});
it("renders an icon next to the title", () => {
act(() => {
root?.render(
<CalloutCard title="Update available" icon={<span data-testid="callout-title-icon" />} />,
);
});
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
});
it("renders one action when one is provided", () => {
const onPress = vi.fn();
act(() => {
root?.render(
<CalloutCard
description="Saved."
actions={[{ label: "Undo", onPress }]}
testID="callout"
/>,
);
});
const button = container?.querySelector(
'[data-testid="callout-action-0"]',
) as HTMLElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("Undo");
});
it("renders up to two actions", () => {
act(() => {
root?.render(
<CalloutCard
title="Update available"
description="v1 ready."
actions={[
{ label: "What's new", onPress: vi.fn() },
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
]}
testID="callout"
/>,
);
});
expect(container?.querySelector('[data-testid="callout-action-0"]')?.textContent).toContain(
"What's new",
);
expect(container?.querySelector('[data-testid="callout-action-1"]')?.textContent).toContain(
"Install & restart",
);
});
it("renders no action row when no actions are provided", () => {
act(() => {
root?.render(<CalloutCard description="Copied" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-actions"]')).toBeNull();
});
it("renders the dismiss X in the top-left when onDismiss is provided", () => {
const onDismiss = vi.fn();
act(() => {
root?.render(<CalloutCard description="Saved" onDismiss={onDismiss} testID="callout" />);
});
const dismissButton = container?.querySelector(
'[data-testid="callout-dismiss"]',
) as HTMLElement | null;
expect(dismissButton).not.toBeNull();
});
it("omits the dismiss button when onDismiss is not provided", () => {
act(() => {
root?.render(<CalloutCard description="Saved" testID="callout" />);
});
expect(container?.querySelector('[data-testid="callout-dismiss"]')).toBeNull();
});
});
@@ -1,224 +0,0 @@
import { X } from "lucide-react-native";
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type CalloutActionVariant = "primary" | "secondary";
export interface CalloutAction {
label: string;
onPress: () => void;
variant?: CalloutActionVariant;
disabled?: boolean;
testID?: string;
}
export type CalloutVariant = "default" | "success" | "error";
export interface CalloutCardProps {
title?: string;
description?: ReactNode;
icon?: ReactNode;
variant?: CalloutVariant;
actions?: readonly CalloutAction[];
onDismiss?: () => void;
testID?: string;
}
export function CalloutDescriptionText({ children }: { children: ReactNode }) {
return <Text style={styles.description}>{children}</Text>;
}
export function CalloutCard({
title,
description,
icon,
variant = "default",
actions,
onDismiss,
testID,
}: CalloutCardProps) {
const { theme } = useUnistyles();
const visibleActions = (actions ?? []).slice(0, 2);
const hasHeader = title != null || icon != null;
const hasDescription = description != null && description !== "";
const containerStyle = [styles.container, variant === "error" ? styles.containerError : null];
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
<View style={styles.body}>
{hasHeader || onDismiss ? (
<View style={styles.topRow}>
{hasHeader ? (
<View style={styles.header}>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{title ? (
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
) : null}
</View>
) : null}
{onDismiss ? (
<Pressable
onPress={onDismiss}
hitSlop={8}
style={styles.dismissButton}
testID={testID ? `${testID}-dismiss` : undefined}
accessibilityLabel="Dismiss"
accessibilityRole="button"
>
{({ hovered }) => (
<X
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
</View>
) : null}
{hasDescription ? (
typeof description === "string" ? (
<CalloutDescriptionText>{description}</CalloutDescriptionText>
) : (
<View style={styles.descriptionSlot}>{description}</View>
)
) : null}
{visibleActions.length > 0 ? (
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
{visibleActions.map((action, index) => (
<CalloutActionButton
key={`${action.label}-${index}`}
action={action}
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
/>
))}
</View>
) : null}
</View>
</View>
);
}
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
const isPrimary = action.variant === "primary";
return (
<Pressable
onPress={action.onPress}
disabled={action.disabled}
testID={testID}
accessibilityRole="button"
style={({ pressed }) => [
styles.actionButton,
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
pressed ? styles.actionButtonPressed : null,
action.disabled ? styles.actionButtonDisabled : null,
]}
>
<Text
style={[
styles.actionLabel,
isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
]}
numberOfLines={1}
>
{action.label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
width: "100%",
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
gap: theme.spacing[2],
},
containerError: {
borderTopColor: theme.colors.destructive,
},
dismissButton: {
width: 14,
height: 14,
alignItems: "center",
justifyContent: "center",
},
body: {
gap: theme.spacing[2],
},
topRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
header: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
iconSlot: {
alignItems: "center",
justifyContent: "center",
},
title: {
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
actionButton: {
flex: 1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
alignItems: "center",
justifyContent: "center",
},
actionButtonPrimary: {
backgroundColor: theme.colors.foreground,
borderColor: theme.colors.foreground,
},
actionButtonSecondary: {
backgroundColor: "transparent",
borderColor: theme.colors.border,
},
actionButtonPressed: {
opacity: 0.8,
},
actionButtonDisabled: {
opacity: 0.5,
},
actionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
actionLabelPrimary: {
color: theme.colors.surface0,
},
actionLabelSecondary: {
color: theme.colors.foreground,
},
}));
@@ -1,209 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
const hostsState = vi.hoisted(() => ({
value: [{ serverId: "server-1", label: "Local daemon" }],
}));
const sessionState = vi.hoisted(() => ({
value: {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
},
}));
const router = vi.hoisted(() => ({
navigate: vi.fn(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("expo-router", () => ({
useRouter: () => router,
}));
vi.mock("@/runtime/host-runtime", () => ({
useHosts: () => hostsState.value,
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: typeof sessionState.value) => unknown) =>
selector(sessionState.value),
}));
vi.mock("@/utils/app-version", () => ({
resolveAppVersion: () => "1.2.3",
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { DaemonVersionMismatchCalloutSource } from "./daemon-version-mismatch-callout-source";
function Harness() {
return (
<SidebarCalloutProvider>
<DaemonVersionMismatchCalloutSource />
<SidebarCalloutSlot />
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root): Promise<void> {
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
}
describe("DaemonVersionMismatchCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
hostsState.value = [{ serverId: "server-1", label: "Local daemon" }];
sessionState.value = {
sessions: {
"server-1": {
serverInfo: {
serverId: "server-1",
hostname: "MacBook",
version: "1.2.2",
},
},
},
};
router.navigate.mockClear();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("registers a daemon mismatch sidebar callout", async () => {
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).not.toBeNull();
expect(container?.textContent).toContain("Daemon version mismatch");
expect(container?.textContent).toContain("Local daemon is running v1.2.2");
expect(container?.textContent).toContain("This app is v1.2.3");
expect(container?.textContent).toContain(
"For the best experience, keep the daemon and client on the same version.",
);
});
it("does not register a callout when versions match", async () => {
sessionState.value.sessions["server-1"]!.serverInfo!.version = "1.2.3";
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
it("opens host settings from the callout action", async () => {
await renderHarness(root!);
const action = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-action-0"]',
) as HTMLElement | null;
expect(action).not.toBeNull();
act(() => {
action?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(router.navigate).toHaveBeenCalledWith("/settings/hosts/server-1");
});
it("persists dismissal for the current app-daemon version pair", async () => {
await renderHarness(root!);
const dismiss = container?.querySelector(
'[data-testid="daemon-version-mismatch-callout-server-1-dismiss"]',
) as HTMLElement | null;
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["daemon-version-mismatch:server-1:1.2.3:1.2.2"]),
);
expect(
container?.querySelector('[data-testid="daemon-version-mismatch-callout-server-1"]'),
).toBeNull();
});
});
@@ -1,79 +0,0 @@
import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { formatVersionWithPrefix, isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { resolveAppVersion } from "@/utils/app-version";
import { buildSettingsHostRoute } from "@/utils/host-routes";
type DaemonVersionMismatch = {
serverId: string;
label: string;
appVersion: string;
daemonVersion: string;
};
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
const hosts = useHosts();
const sessions = useSessionStore((state) => state.sessions);
const appVersion = resolveAppVersion();
return useMemo(() => {
if (!appVersion) {
return [];
}
return hosts
.map((entry) => ({
serverId: entry.serverId,
label: entry.label?.trim() || sessions[entry.serverId]?.serverInfo?.hostname || "Daemon",
appVersion,
daemonVersion: sessions[entry.serverId]?.serverInfo?.version ?? null,
}))
.filter((entry): entry is DaemonVersionMismatch =>
isVersionMismatch(entry.appVersion, entry.daemonVersion),
);
}, [appVersion, hosts, sessions]);
}
function DaemonVersionMismatchRegistration({ mismatch }: { mismatch: DaemonVersionMismatch }) {
const callouts = useSidebarCallouts();
const router = useRouter();
const { appVersion, daemonVersion, label, serverId } = mismatch;
const openSettings = useStableEvent(() => {
router.navigate(buildSettingsHostRoute(serverId));
});
useEffect(() => {
return callouts.show({
id: `daemon-version-mismatch:${serverId}`,
dismissalKey: `daemon-version-mismatch:${serverId}:${appVersion}:${daemonVersion}`,
priority: 300,
title: "Daemon version mismatch",
description: `${label} is running ${formatVersionWithPrefix(
daemonVersion,
)}. This app is ${formatVersionWithPrefix(
appVersion,
)}. For the best experience, keep the daemon and client on the same version.`,
variant: "error",
actions: [{ label: "Open settings", onPress: openSettings, variant: "primary" }],
testID: `daemon-version-mismatch-callout-${serverId}`,
});
}, [appVersion, callouts, daemonVersion, label, openSettings, serverId]);
return null;
}
export function DaemonVersionMismatchCalloutSource() {
const mismatches = useDaemonVersionMismatches();
return (
<>
{mismatches.map((mismatch) => (
<DaemonVersionMismatchRegistration key={mismatch.serverId} mismatch={mismatch} />
))}
</>
);
}
+1 -3
View File
@@ -76,11 +76,9 @@ async function createFilePanePreview(file: ExplorerFile | null): Promise<{
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.content.length,
}),
base64: file.content,
mimeType: file.mimeType,
+35 -38
View File
@@ -1,67 +1,66 @@
import { router, usePathname } from "expo-router";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import {
type Dispatch,
memo,
useCallback,
useMemo,
useState,
useEffect,
useRef,
type Dispatch,
type ReactElement,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
View,
Pressable,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
View,
StyleSheet as RNStyleSheet,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
interpolate,
Extrapolation,
runOnJS,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router";
import {
usePanelStore,
selectIsAgentListOpen,
MIN_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
} from "@/stores/panel-store";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import {
type SidebarProjectEntry,
useSidebarWorkspacesList,
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
selectIsAgentListOpen,
usePanelStore,
} from "@/stores/panel-store";
import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
} from "@/utils/host-routes";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { isWeb } from "@/constants/platform";
import { resolveActiveHost } from "@/utils/active-host";
const MIN_CHAT_WIDTH = 400;
@@ -715,8 +714,6 @@ function DesktopSidebar({
/>
)}
<SidebarCalloutSlot />
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
+2 -7
View File
@@ -635,11 +635,9 @@ function AssistantMarkdownImage({
return await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.content.length,
}),
base64: file.content,
mimeType: file.mimeType,
@@ -657,10 +655,7 @@ function AssistantMarkdownImage({
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
}),
id: createPreviewAttachmentId(dataImage),
dataUrl: source,
mimeType: dataImage.mimeType,
});
-1
View File
@@ -1 +0,0 @@
export * from "./sidebar-callout-slot.native";
@@ -1,3 +0,0 @@
export function SidebarCalloutSlot() {
return null;
}
@@ -1,116 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => {}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { SidebarCalloutProvider, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
function RegisterCallout() {
const callouts = useSidebarCallouts();
useEffect(
() =>
callouts.show({
id: "slot-test",
title: "Update available",
description: "v1 is ready.",
testID: "slot-test-callout",
}),
[callouts],
);
return null;
}
describe("SidebarCalloutSlot", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("renders the active sidebar callout", async () => {
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<RegisterCallout />
<SidebarCalloutSlot />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).toContain("v1 is ready.");
});
it("renders nothing when there is no active callout", async () => {
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<SidebarCalloutSlot />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
expect(container?.querySelector('[data-testid="slot-test-callout"]')).toBeNull();
});
});
@@ -1,17 +0,0 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarCalloutViewport } from "@/contexts/sidebar-callout-context";
export function SidebarCalloutSlot() {
return (
<View style={styles.slot} collapsable={false}>
<SidebarCalloutViewport />
</View>
);
}
const styles = StyleSheet.create(() => ({
slot: {
width: "100%",
},
}));
@@ -1,215 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#f44",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
return { X };
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import {
SidebarCalloutProvider,
type SidebarCalloutsApi,
SidebarCalloutViewport,
useSidebarCallouts,
} from "./sidebar-callout-context";
function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) {
const api = useSidebarCallouts();
onApi(api);
return null;
}
describe("SidebarCalloutProvider", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
let api: SidebarCalloutsApi | null = null;
beforeEach(async () => {
api = null;
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SidebarCalloutProvider>
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
await Promise.resolve();
});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
api = null;
});
it("shows the highest-priority callout first, then reveals the next when dismissed", () => {
act(() => {
api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" });
api?.show({ id: "update", priority: 200, title: "Update available" });
});
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).not.toContain("Set up scripts");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).toContain("Set up scripts");
expect(container?.textContent).not.toContain("Update available");
});
it("replaces a callout by id without duplicating the queue item", () => {
act(() => {
api?.show({ id: "daemon", title: "Old daemon", description: "v1" });
api?.show({ id: "daemon", title: "New daemon", description: "v2" });
});
expect(container?.textContent).toContain("New daemon");
expect(container?.textContent).toContain("v2");
expect(container?.textContent).not.toContain("Old daemon");
});
it("keeps API consumers from rerendering when callout state changes", () => {
const renders = vi.fn();
function Producer() {
const callouts = useSidebarCallouts();
renders(callouts);
useEffect(() => {
callouts.show({ id: "initial", title: "Initial" });
}, [callouts]);
return null;
}
act(() => {
root?.render(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={(nextApi) => (api = nextApi)} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
const firstApi = renders.mock.calls[0]?.[0];
act(() => {
api?.show({ id: "later", priority: 10, title: "Later" });
});
expect(renders).toHaveBeenCalledTimes(1);
expect(renders.mock.calls[0]?.[0]).toBe(firstApi);
});
it("unregisters only the registration returned by show", () => {
let unregisterOld: (() => void) | null = null;
act(() => {
unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null;
api?.show({ id: "update", title: "New" });
});
act(() => {
unregisterOld?.();
});
expect(container?.textContent).toContain("New");
});
it("persists dismissals by dismissal key", () => {
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Update available",
});
});
expect(container?.textContent).toContain("Update available");
act(() => {
api?.dismiss("update");
});
expect(container?.textContent).not.toContain("Update available");
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.3",
title: "Dismissed update",
});
});
expect(container?.textContent).not.toContain("Dismissed update");
act(() => {
api?.show({
id: "update",
dismissalKey: "desktop-update:available:1.2.4",
title: "New update",
});
});
expect(container?.textContent).toContain("New update");
});
});
@@ -1,232 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type CalloutAction,
CalloutCard,
type CalloutCardProps,
type CalloutVariant,
} from "@/components/callout-card";
import { useStableEvent } from "@/hooks/use-stable-event";
export type SidebarCalloutAction = CalloutAction;
export interface SidebarCalloutOptions {
id: string;
dismissalKey?: string;
title: string;
description?: ReactNode;
icon?: ReactNode;
variant?: CalloutVariant;
actions?: readonly SidebarCalloutAction[];
dismissible?: boolean;
priority?: number;
onDismiss?: () => void;
testID?: string;
}
export interface SidebarCalloutsApi {
show: (callout: SidebarCalloutOptions) => () => void;
dismiss: (id: string) => void;
clear: () => void;
}
type SidebarCalloutEntry = SidebarCalloutOptions & {
order: number;
priority: number;
token: number;
};
const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals";
const SidebarCalloutApiContext = createContext<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
function persistDismissedCalloutKeys(keys: ReadonlySet<string>): void {
void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch(
(error) => {
console.error("[SidebarCallouts] Failed to persist dismissed callouts", error);
},
);
}
function selectActiveCallout(input: {
callouts: readonly SidebarCalloutEntry[];
dismissedKeys: ReadonlySet<string>;
dismissalStorageLoaded: boolean;
}): SidebarCalloutEntry | null {
const visibleCallouts = input.callouts.filter((entry) => {
const dismissalKey = normalizeDismissalKey(entry.dismissalKey);
if (!dismissalKey) {
return true;
}
return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey);
});
if (visibleCallouts.length === 0) {
return null;
}
return (
[...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null
);
}
export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
const [callouts, setCallouts] = useState<SidebarCalloutEntry[]>([]);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
const dismissedKeysRef = useRef<Set<string>>(new Set());
const orderRef = useRef(0);
const tokenRef = useRef(0);
useEffect(() => {
let mounted = true;
void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY)
.then((value) => {
if (!mounted) {
return;
}
const nextKeys = parseDismissedCalloutKeys(value);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
})
.catch((error) => {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
})
.finally(() => {
if (mounted) {
setDismissalStorageLoaded(true);
}
});
return () => {
mounted = false;
};
}, []);
const show = useStableEvent((callout: SidebarCalloutOptions) => {
tokenRef.current += 1;
const token = tokenRef.current;
const current = calloutsRef.current;
const existing = current.find((entry) => entry.id === callout.id);
const nextEntry: SidebarCalloutEntry = {
...callout,
priority: callout.priority ?? 0,
order: existing?.order ?? ++orderRef.current,
token,
};
const next = existing
? current.map((entry) => (entry.id === callout.id ? nextEntry : entry))
: [...current, nextEntry];
calloutsRef.current = next;
setCallouts(next);
return () => {
const updated = calloutsRef.current.filter(
(entry) => entry.id !== callout.id || entry.token !== token,
);
calloutsRef.current = updated;
setCallouts(updated);
};
});
const dismiss = useStableEvent((id: string) => {
const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null;
const next = calloutsRef.current.filter((entry) => entry.id !== id);
calloutsRef.current = next;
setCallouts(next);
const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey);
if (dismissalKey) {
const nextKeys = new Set(dismissedKeysRef.current);
nextKeys.add(dismissalKey);
dismissedKeysRef.current = nextKeys;
setDismissedKeys(nextKeys);
persistDismissedCalloutKeys(nextKeys);
}
dismissed?.onDismiss?.();
});
const clear = useStableEvent(() => {
calloutsRef.current = [];
setCallouts([]);
});
const api = useMemo<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
return (
<SidebarCalloutApiContext.Provider value={api}>
<SidebarCalloutStateContext.Provider value={activeCallout}>
{children}
</SidebarCalloutStateContext.Provider>
</SidebarCalloutApiContext.Provider>
);
}
export function useSidebarCallouts(): SidebarCalloutsApi {
const api = useContext(SidebarCalloutApiContext);
if (!api) {
throw new Error("useSidebarCallouts must be used within SidebarCalloutProvider");
}
return api;
}
export function useActiveSidebarCallout(): SidebarCalloutEntry | null {
return useContext(SidebarCalloutStateContext);
}
export function SidebarCalloutViewport() {
const activeCallout = useActiveSidebarCallout();
const api = useSidebarCallouts();
if (!activeCallout) {
return null;
}
const cardProps: CalloutCardProps = {
title: activeCallout.title,
description: activeCallout.description,
icon: activeCallout.icon,
variant: activeCallout.variant,
actions: activeCallout.actions,
onDismiss:
activeCallout.dismissible === false ? undefined : () => api.dismiss(activeCallout.id),
testID: activeCallout.testID,
};
return <CalloutCard {...cardProps} />;
}
@@ -0,0 +1,200 @@
import { useEffect, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktopApp,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktopApp) return;
void checkForUpdates({ silent: true });
intervalRef.current = setInterval(() => {
void checkForUpdates({ silent: true });
}, CHECK_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isDesktopApp, checkForUpdates]);
if (!isDesktopApp) return null;
if (dismissed) return null;
if (
status !== "available" &&
status !== "installed" &&
status !== "installing" &&
status !== "error"
)
return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Installing and restarting...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<View style={styles.textSection}>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
</View>
<View style={styles.actions}>
<Pressable
onPress={() => void openExternalUrl(CHANGELOG_URL)}
style={({ pressed }) => [styles.outlineButton, pressed && styles.buttonPressed]}
>
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && !isError && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
isInstalling && styles.buttonDisabled,
]}
>
<Text style={styles.primaryButtonText}>
{isInstalling ? "Installing..." : "Install & restart"}
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
bottom: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
banner: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[4],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.xl,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingLeft: theme.spacing[4],
paddingRight: theme.spacing[3],
...theme.shadow.md,
maxWidth: 480,
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
alignItems: "center",
},
outlineButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
outlineButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
primaryButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.foreground,
},
primaryButtonText: {
color: theme.colors.surface0,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
buttonPressed: {
opacity: 0.8,
},
buttonDisabled: {
opacity: 0.5,
},
}));
@@ -1,274 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18 },
borderWidth: { 1: 1 },
borderRadius: { md: 6 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500", semibold: "600" },
shadow: { md: {} },
colors: {
surface0: "#000",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
destructive: "#ff4444",
},
},
}));
const asyncStorage = vi.hoisted(() => ({
values: new Map<string, string>(),
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorage.values.set(key, value);
}),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorage,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
const X = (props: Record<string, unknown>) => React.createElement("span", props);
const Gift = (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": "Gift" });
return { Gift, X };
});
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(async () => {}),
}));
const updaterState = vi.hoisted(() => ({
value: {
isDesktopApp: true,
status: "available" as
| "idle"
| "checking"
| "pending"
| "up-to-date"
| "available"
| "installing"
| "installed"
| "error",
availableUpdate: {
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.2.2",
latestVersion: "1.2.3",
body: null,
date: null,
} as unknown,
errorMessage: null as string | null,
lastCheckedAt: null as number | null,
isChecking: false,
isInstalling: false,
statusText: "",
checkForUpdates: vi.fn(async () => null),
installUpdate: vi.fn(async () => null),
},
}));
vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({
useDesktopAppUpdater: () => updaterState.value,
}));
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { UpdateCalloutSource } from "./update-callout-source";
function resetUpdaterState(): void {
updaterState.value = {
isDesktopApp: true,
status: "available",
availableUpdate: {
hasUpdate: true,
readyToInstall: true,
currentVersion: "1.2.2",
latestVersion: "1.2.3",
body: null,
date: null,
},
errorMessage: null,
lastCheckedAt: null,
isChecking: false,
isInstalling: false,
statusText: "",
checkForUpdates: vi.fn(async () => null),
installUpdate: vi.fn(async () => null),
};
}
function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) {
return (
<SidebarCalloutProvider>
<UpdateCalloutSource />
{withSlot ? <SidebarCalloutSlot /> : null}
</SidebarCalloutProvider>
);
}
async function renderHarness(root: Root, withSlot = true): Promise<void> {
await act(async () => {
root.render(<UpdateCalloutHarness withSlot={withSlot} />);
await Promise.resolve();
await Promise.resolve();
});
}
describe("UpdateCalloutSource", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
resetUpdaterState();
asyncStorage.values.clear();
asyncStorage.getItem.mockClear();
asyncStorage.setItem.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
});
it("registers an update-available sidebar callout with changelog + install actions", async () => {
await renderHarness(root!);
expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull();
expect(container?.textContent).toContain("Update available");
expect(container?.textContent).toContain("1.2.3");
expect(container?.textContent).toContain(
"Upgrading the app will stop running agents and close terminal sessions.",
);
expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull();
expect(
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
).toContain("What's new");
expect(
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
).toContain("Install & restart");
});
it("renders only through the sidebar slot", async () => {
await renderHarness(root!, false);
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
});
it("shows only the changelog action once the update is installed", async () => {
updaterState.value = {
...updaterState.value,
status: "installed",
availableUpdate: null,
};
await renderHarness(root!);
expect(container?.textContent).toContain("Update installed");
expect(
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
).toContain("What's new");
expect(container?.querySelector('[data-testid="update-callout-action-1"]')).toBeNull();
});
it("disables the install action and shows Installing... while installing", async () => {
updaterState.value = {
...updaterState.value,
status: "installing",
isInstalling: true,
};
await renderHarness(root!);
expect(
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
).toContain("Installing");
});
it("shows a retry action on error and surfaces the error message", async () => {
updaterState.value = {
...updaterState.value,
status: "error",
errorMessage: "Download failed",
availableUpdate: null,
};
await renderHarness(root!);
expect(container?.textContent).toContain("Update failed");
expect(container?.textContent).toContain("Download failed");
expect(
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
).toContain("Retry");
});
it("renders nothing when not running as a desktop app", async () => {
updaterState.value = { ...updaterState.value, isDesktopApp: false };
await renderHarness(root!);
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
});
it("renders nothing when status is idle / checking / up-to-date / pending", async () => {
for (const status of ["idle", "checking", "up-to-date", "pending"] as const) {
resetUpdaterState();
updaterState.value = { ...updaterState.value, status };
const innerContainer = document.createElement("div");
document.body.appendChild(innerContainer);
const innerRoot = createRoot(innerContainer);
await act(async () => {
innerRoot.render(<UpdateCalloutHarness />);
await Promise.resolve();
await Promise.resolve();
});
expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull();
act(() => {
innerRoot.unmount();
});
innerContainer.remove();
}
});
it("dismisses the registered update callout", async () => {
await renderHarness(root!);
const dismiss = container?.querySelector(
'[data-testid="update-callout-dismiss"]',
) as HTMLElement | null;
expect(dismiss).not.toBeNull();
act(() => {
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
expect(asyncStorage.setItem).toHaveBeenCalledWith(
"@paseo:sidebar-callout-dismissals",
JSON.stringify(["desktop-update:available:1.2.3"]),
);
});
});
@@ -1,149 +0,0 @@
import { Gift } from "lucide-react-native";
import { type ReactNode, useEffect, useRef } from "react";
import { useUnistyles } from "react-native-unistyles";
import { CalloutDescriptionText } from "@/components/callout-card";
import { type SidebarCalloutAction, useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { useStableEvent } from "@/hooks/use-stable-event";
import { openExternalUrl } from "@/utils/open-external-url";
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateCalloutSource() {
const callouts = useSidebarCallouts();
const { theme } = useUnistyles();
const {
isDesktopApp,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const openChangelog = useStableEvent(() => {
void openExternalUrl(CHANGELOG_URL);
});
const install = useStableEvent(() => {
void installUpdate();
});
const retry = useStableEvent(() => {
void checkForUpdates();
});
useEffect(() => {
if (!isDesktopApp) return;
void checkForUpdates({ silent: true });
intervalRef.current = setInterval(() => {
void checkForUpdates({ silent: true });
}, CHECK_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isDesktopApp, checkForUpdates]);
useEffect(() => {
if (!isDesktopApp) {
return;
}
if (
status !== "available" &&
status !== "installed" &&
status !== "installing" &&
status !== "error"
) {
return;
}
const isInstalled = status === "installed";
const isError = status === "error";
const isAvailable = !isInstalled && !isInstalling && !isError;
let title = "Update available";
if (isInstalled) {
title = "Update installed";
} else if (isInstalling) {
title = "Installing update";
} else if (isError) {
title = "Update failed";
}
let description: ReactNode = <UpdateAvailableDescription />;
if (isInstalled) {
description = "Restart to use the new version.";
} else if (isInstalling) {
description = "Installing and restarting...";
} else if (isError) {
description = errorMessage ?? "Something went wrong.";
} else if (availableUpdate?.latestVersion) {
description = (
<UpdateAvailableDescription
versionLabel={`v${availableUpdate.latestVersion.replace(/^v/i, "")}`}
/>
);
}
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: openChangelog }];
if (isError) {
actions.push({ label: "Retry", onPress: retry, variant: "primary" });
} else if (!isInstalled) {
actions.push({
label: isInstalling ? "Installing..." : "Install & restart",
onPress: install,
variant: "primary",
disabled: isInstalling,
});
}
return callouts.show({
id: "desktop-update",
dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`,
priority: 200,
title,
description,
icon: isAvailable ? (
<Gift size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : undefined,
variant: isError ? "error" : "default",
actions,
testID: "update-callout",
});
}, [
availableUpdate?.latestVersion,
callouts,
errorMessage,
install,
isDesktopApp,
isInstalling,
openChangelog,
retry,
status,
theme.colors.foregroundMuted,
theme.iconSize.sm,
]);
return null;
}
function UpdateAvailableDescription({ versionLabel }: { versionLabel?: string }) {
return (
<>
<CalloutDescriptionText>
{versionLabel
? `${versionLabel} is ready to install.`
: "A new version is ready to install."}
</CalloutDescriptionText>
<CalloutDescriptionText>
Upgrading the app will stop running agents and close terminal sessions.
</CalloutDescriptionText>
</>
);
}
@@ -1,56 +0,0 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import type { LayoutChangeEvent } from "react-native";
import { describe, expect, it } from "vitest";
import { useContainerWidthBelow } from "./use-container-width";
function layoutEvent(width: number): LayoutChangeEvent {
return {
nativeEvent: {
layout: {
width,
height: 48,
x: 0,
y: 0,
},
},
} as LayoutChangeEvent;
}
describe("useContainerWidthBelow", () => {
it("does not re-render for width changes that stay in the same threshold bucket", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useContainerWidthBelow(700);
});
expect(result.current.isBelow).toBe(true);
expect(renderCount).toBe(1);
act(() => {
result.current.onLayout(layoutEvent(650));
result.current.onLayout(layoutEvent(620));
result.current.onLayout(layoutEvent(699));
});
expect(result.current.isBelow).toBe(true);
expect(renderCount).toBe(1);
});
it("re-renders when the width crosses the threshold", () => {
let renderCount = 0;
const { result } = renderHook(() => {
renderCount += 1;
return useContainerWidthBelow(700);
});
act(() => {
result.current.onLayout(layoutEvent(760));
});
expect(result.current.isBelow).toBe(false);
expect(renderCount).toBe(2);
});
});
@@ -16,25 +16,3 @@ export function useContainerWidth(): {
width,
};
}
/**
* Tracks only whether a container is narrower than a threshold.
*/
export function useContainerWidthBelow(threshold: number): {
onLayout: (e: LayoutChangeEvent) => void;
isBelow: boolean;
} {
const [isBelow, setIsBelow] = useState(true);
return {
onLayout: useCallback(
(e: LayoutChangeEvent) => {
const nextIsBelow = e.nativeEvent.layout.width < threshold;
setIsBelow((currentIsBelow) =>
currentIsBelow === nextIsBelow ? currentIsBelow : nextIsBelow,
);
},
[threshold],
),
isBelow,
};
}
@@ -85,17 +85,6 @@ function DisabledRenderCountProbe({
return null;
}
function HookResultProbe({
onResult,
serverId,
}: {
onResult: (result: ReturnType<typeof useSidebarWorkspacesList>) => void;
serverId: string;
}): null {
onResult(useSidebarWorkspacesList({ serverId }));
return null;
}
describe("applyStoredOrdering", () => {
it("keeps unknown items on the baseline while applying stored order", () => {
const result = applyStoredOrdering({
@@ -217,7 +206,6 @@ describe("useSidebarWorkspacesList", () => {
act(() => {
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession("srv-disabled");
useSessionStore.getState().clearSession("srv-loading");
useSidebarOrderStore.setState({
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
@@ -246,30 +234,6 @@ describe("useSidebarWorkspacesList", () => {
expect(useSidebarOrderStore.getState().workspaceOrderByServerAndProject).toEqual({});
});
it("keeps the sidebar in initial load until workspace hydration succeeds", async () => {
const onResult = vi.fn();
act(() => {
useSessionStore.getState().initializeSession("srv-loading", null as unknown as DaemonClient);
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookResultProbe, { serverId: "srv-loading", onResult }));
});
expect(onResult).toHaveBeenLastCalledWith(
expect.objectContaining({
projects: [],
isLoading: true,
isInitialLoad: true,
}),
);
});
it("does not subscribe to order updates while disabled", async () => {
const onRender = vi.fn();
@@ -303,7 +303,8 @@ export function useSidebarWorkspacesList(options?: {
})();
}, [connectionStatus, isActive, runtime, serverId]);
const isLoading = isActive && Boolean(serverId) && !hasHydratedWorkspaces;
const isLoading =
isActive && Boolean(serverId) && connectionStatus === "online" && !hasHydratedWorkspaces;
const isInitialLoad = isLoading && projects.length === 0;
const isRevalidating = false;
@@ -122,7 +122,7 @@ import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { isWeb, isNative } from "@/constants/platform";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { useContainerWidth } from "@/hooks/use-container-width";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
@@ -1051,8 +1051,8 @@ function WorkspaceScreenContent({
);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const { closingTabIds, closeTab } = useCloseTabs();
const { onLayout: onHeaderLayout, isBelow: showCompactButtonLabels } =
useContainerWidthBelow(700);
const { onLayout: onHeaderLayout, width: headerWidth } = useContainerWidth();
const showCompactButtonLabels = headerWidth < 700;
const closeWorkspaceTabWithCleanup = useCallback(
function closeWorkspaceTabWithCleanup(input: {
tabId: string;
@@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import { groupActivities, type AgentActivity } from "./agent-activity";
function timestamp(seconds: number): Date {
return new Date(`2026-01-01T00:00:${seconds.toString().padStart(2, "0")}.000Z`);
}
describe("groupActivities", () => {
it("groups text chunks and merges tool call updates at the first tool position", () => {
const activities: AgentActivity[] = [
{
timestamp: timestamp(1),
update: {
kind: "agent_message_chunk",
content: { type: "text", text: "Hel" },
},
},
{
timestamp: timestamp(2),
update: {
kind: "agent_message_chunk",
content: { type: "text", text: "lo" },
},
},
{
timestamp: timestamp(3),
update: {
kind: "tool_call",
toolCallId: "tool_1",
title: "Read file",
status: "in_progress",
toolKind: "read",
input: { path: "README.md" },
},
},
{
timestamp: timestamp(4),
update: {
kind: "agent_thought_chunk",
content: { type: "text", text: "Checking context" },
},
},
{
timestamp: timestamp(5),
update: {
kind: "tool_call_update",
toolCallId: "tool_1",
status: "completed",
output: { ok: true },
},
},
];
expect(groupActivities(activities)).toEqual([
{
kind: "grouped_text",
messageType: "agent",
text: "Hello",
startTimestamp: timestamp(1),
endTimestamp: timestamp(2),
},
{
kind: "merged_tool_call",
toolCallId: "tool_1",
title: "Read file",
status: "completed",
toolKind: "read",
input: { path: "README.md" },
output: { ok: true },
content: undefined,
locations: undefined,
startTimestamp: timestamp(3),
endTimestamp: timestamp(5),
},
{
kind: "grouped_text",
messageType: "thought",
text: "Checking context",
startTimestamp: timestamp(4),
endTimestamp: timestamp(4),
},
]);
});
it("creates a merged tool call when an update arrives before the initial call", () => {
const activities: AgentActivity[] = [
{
timestamp: timestamp(1),
update: {
kind: "tool_call_update",
toolCallId: "tool_2",
title: "Shell",
status: "completed",
toolKind: "execute",
output: { exitCode: 0 },
},
},
];
expect(groupActivities(activities)).toEqual([
{
kind: "merged_tool_call",
toolCallId: "tool_2",
title: "Shell",
status: "completed",
toolKind: "execute",
input: undefined,
output: { exitCode: 0 },
content: undefined,
locations: undefined,
startTimestamp: timestamp(1),
endTimestamp: timestamp(1),
},
]);
});
});
+145 -100
View File
@@ -1,3 +1,6 @@
/**
* Discriminated union types for session updates
*/
export type SessionUpdate =
| UserMessageChunk
| AgentMessageChunk
@@ -8,22 +11,6 @@ export type SessionUpdate =
| AvailableCommandsUpdate
| CurrentModeUpdate;
export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
export type ToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other";
type TextMessageType = "user" | "agent" | "thought";
export interface UserMessageChunk {
kind: "user_message_chunk";
content: {
@@ -52,8 +39,18 @@ export interface ToolCall {
kind: "tool_call";
toolCallId: string;
title: string;
status?: ToolCallStatus;
toolKind?: ToolKind;
status?: "pending" | "in_progress" | "completed" | "failed";
toolKind?:
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other";
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[];
@@ -64,8 +61,19 @@ export interface ToolCallUpdate {
kind: "tool_call_update";
toolCallId: string;
title?: string | null;
status?: ToolCallStatus | null;
toolKind?: ToolKind | null;
status?: "pending" | "in_progress" | "completed" | "failed" | null;
toolKind?:
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other"
| null;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[] | null;
@@ -94,25 +102,44 @@ export interface CurrentModeUpdate {
currentModeId: string;
}
/**
* Activity item with timestamp
*/
export interface AgentActivity {
timestamp: Date;
update: SessionUpdate;
}
/**
* Grouped text message (consecutive chunks combined)
*/
export interface GroupedTextMessage {
kind: "grouped_text";
messageType: TextMessageType;
messageType: "user" | "agent" | "thought";
text: string;
startTimestamp: Date;
endTimestamp: Date;
}
/**
* Merged tool call (initial call + all updates combined)
*/
export interface MergedToolCall {
kind: "merged_tool_call";
toolCallId: string;
title: string;
status: ToolCallStatus;
toolKind?: ToolKind;
status: "pending" | "in_progress" | "completed" | "failed";
toolKind?:
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other";
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[];
@@ -121,95 +148,89 @@ export interface MergedToolCall {
endTimestamp: Date;
}
export type GroupedActivity = GroupedTextMessage | MergedToolCall | AgentActivity;
/**
* Group consecutive text chunks into messages and merge tool calls by ID
*/
export function groupActivities(
activities: AgentActivity[],
): Array<GroupedTextMessage | MergedToolCall | AgentActivity> {
const result: Array<GroupedTextMessage | MergedToolCall | AgentActivity> = [];
type TextGroup = {
messageType: TextMessageType;
chunks: string[];
startTimestamp: Date;
endTimestamp: Date;
};
// Track current text group
let currentTextGroup: {
messageType: "user" | "agent" | "thought";
chunks: string[];
startTimestamp: Date;
endTimestamp: Date;
} | null = null;
type ToolCallAccumulator = Omit<MergedToolCall, "kind"> & {
insertIndex: number;
};
function getMessageType(
update: UserMessageChunk | AgentMessageChunk | AgentThoughtChunk,
): TextMessageType {
switch (update.kind) {
case "user_message_chunk":
return "user";
case "agent_message_chunk":
return "agent";
case "agent_thought_chunk":
return "thought";
}
}
function isTextChunk(
update: SessionUpdate,
): update is UserMessageChunk | AgentMessageChunk | AgentThoughtChunk {
return (
update.kind === "user_message_chunk" ||
update.kind === "agent_message_chunk" ||
update.kind === "agent_thought_chunk"
);
}
function isGroupedActivity(item: GroupedActivity | null): item is GroupedActivity {
return item !== null;
}
function toMergedToolCall(toolCall: ToolCallAccumulator): MergedToolCall {
return {
kind: "merged_tool_call",
toolCallId: toolCall.toolCallId,
title: toolCall.title,
status: toolCall.status,
toolKind: toolCall.toolKind,
input: toolCall.input,
output: toolCall.output,
content: toolCall.content,
locations: toolCall.locations,
startTimestamp: toolCall.startTimestamp,
endTimestamp: toolCall.endTimestamp,
};
}
export function groupActivities(activities: AgentActivity[]): GroupedActivity[] {
const result: Array<GroupedActivity | null> = [];
const toolCallsById = new Map<string, ToolCallAccumulator>();
let currentTextGroup: TextGroup | null = null;
// Track tool calls by ID
const toolCallsById = new Map<
string,
{
toolCallId: string;
title: string;
status: "pending" | "in_progress" | "completed" | "failed";
toolKind?:
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other";
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[];
locations?: unknown[];
startTimestamp: Date;
endTimestamp: Date;
insertIndex: number; // Track where to insert in result
}
>();
function flushTextGroup() {
if (!currentTextGroup) {
return;
if (currentTextGroup) {
result.push({
kind: "grouped_text",
messageType: currentTextGroup.messageType,
text: currentTextGroup.chunks.join(""),
startTimestamp: currentTextGroup.startTimestamp,
endTimestamp: currentTextGroup.endTimestamp,
});
currentTextGroup = null;
}
result.push({
kind: "grouped_text",
messageType: currentTextGroup.messageType,
text: currentTextGroup.chunks.join(""),
startTimestamp: currentTextGroup.startTimestamp,
endTimestamp: currentTextGroup.endTimestamp,
});
currentTextGroup = null;
}
for (const activity of activities) {
const update = activity.update;
if (isTextChunk(update)) {
const messageType = getMessageType(update);
// Handle text chunks
if (
update.kind === "user_message_chunk" ||
update.kind === "agent_message_chunk" ||
update.kind === "agent_thought_chunk"
) {
const messageType =
update.kind === "user_message_chunk"
? "user"
: update.kind === "agent_message_chunk"
? "agent"
: "thought";
const text = update.content.text;
// If we have a current group of the same type, add to it
if (currentTextGroup && currentTextGroup.messageType === messageType) {
currentTextGroup.chunks.push(text);
currentTextGroup.endTimestamp = activity.timestamp;
} else {
flushTextGroup();
// Start new group
currentTextGroup = {
messageType,
chunks: [text],
@@ -217,16 +238,20 @@ export function groupActivities(activities: AgentActivity[]): GroupedActivity[]
endTimestamp: activity.timestamp,
};
}
} else if (update.kind === "tool_call" || update.kind === "tool_call_update") {
}
// Handle tool calls and updates
else if (update.kind === "tool_call" || update.kind === "tool_call_update") {
flushTextGroup();
const toolCallId = update.toolCallId;
const existing = toolCallsById.get(toolCallId);
if (update.kind === "tool_call") {
// Initial tool call
if (!existing) {
// Create new entry and placeholder in result
const insertIndex = result.length;
result.push(null);
result.push(null as any); // Placeholder
toolCallsById.set(toolCallId, {
toolCallId,
@@ -242,6 +267,7 @@ export function groupActivities(activities: AgentActivity[]): GroupedActivity[]
insertIndex,
});
} else {
// Update existing
existing.title = update.title;
if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind;
@@ -252,7 +278,9 @@ export function groupActivities(activities: AgentActivity[]): GroupedActivity[]
existing.endTimestamp = activity.timestamp;
}
} else {
// Tool call update
if (existing) {
// Merge update into existing
if (update.title) existing.title = update.title;
if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind;
@@ -262,8 +290,9 @@ export function groupActivities(activities: AgentActivity[]): GroupedActivity[]
if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp;
} else {
// Update without initial call - create entry
const insertIndex = result.length;
result.push(null);
result.push(null as any); // Placeholder
toolCallsById.set(toolCallId, {
toolCallId,
@@ -280,17 +309,33 @@ export function groupActivities(activities: AgentActivity[]): GroupedActivity[]
});
}
}
} else {
}
// Handle other activities
else {
flushTextGroup();
result.push(activity);
}
}
// Flush final text group
flushTextGroup();
// Replace placeholders with merged tool calls
for (const toolCall of toolCallsById.values()) {
result[toolCall.insertIndex] = toMergedToolCall(toolCall);
result[toolCall.insertIndex] = {
kind: "merged_tool_call",
toolCallId: toolCall.toolCallId,
title: toolCall.title,
status: toolCall.status,
toolKind: toolCall.toolKind,
input: toolCall.input,
output: toolCall.output,
content: toolCall.content,
locations: toolCall.locations,
startTimestamp: toolCall.startTimestamp,
endTimestamp: toolCall.endTimestamp,
};
}
return result.filter(isGroupedActivity);
return result;
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.62",
"version": "0.1.60",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.62",
"@getpaseo/server": "0.1.62",
"@getpaseo/relay": "0.1.60",
"@getpaseo/server": "0.1.60",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.62",
"version": "0.1.60",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -13,8 +13,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.62",
"@getpaseo/server": "0.1.62",
"@getpaseo/cli": "0.1.60",
"@getpaseo/server": "0.1.60",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeNodeEntrypointEnv } from "./node-entrypoint-runner";
describe("node-entrypoint-runner", () => {
it("removes Electron node-mode env before loading the target entrypoint", () => {
const env = {
ELECTRON_RUN_AS_NODE: "1",
ELECTRON_NO_ATTACH_CONSOLE: "1",
PATH: "/usr/bin",
};
sanitizeNodeEntrypointEnv(env);
expect(env).toEqual({
PATH: "/usr/bin",
});
});
});
@@ -1,11 +1,6 @@
import { pathToFileURL } from "node:url";
export function sanitizeNodeEntrypointEnv(env: NodeJS.ProcessEnv = process.env): void {
delete env.ELECTRON_RUN_AS_NODE;
delete env.ELECTRON_NO_ATTACH_CONSOLE;
}
export async function main(): Promise<void> {
async function main(): Promise<void> {
const [argvMode, entryPath, ...args] = process.argv.slice(2);
if (argvMode !== "bare" && argvMode !== "node-script") {
throw new Error(`Unsupported node entrypoint argv mode: ${argvMode ?? "<missing>"}`);
@@ -18,14 +13,11 @@ export async function main(): Promise<void> {
argvMode === "bare"
? [process.argv[0] ?? "node", ...args]
: [process.argv[0] ?? "node", entryPath, ...args];
sanitizeNodeEntrypointEnv();
await import(pathToFileURL(entryPath).href);
}
if (require.main === module) {
void main().catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
}
void main().catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./cli-install-path";
describe("cli-install-path", () => {
it("uses the bundled shim for packaged macOS installs", () => {
it("uses the packaged executable on supported unix platforms", () => {
expect(
resolveCliInstallSourcePath({
platform: "darwin",
@@ -10,7 +10,7 @@ describe("cli-install-path", () => {
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo",
}),
).toBe("/Applications/Paseo.app/Contents/Resources/bin/paseo");
).toBe("/Applications/Paseo.app/Contents/MacOS/Paseo");
});
it("prefers the original AppImage path on linux", () => {
@@ -13,10 +13,6 @@ export function resolveCliInstallSourcePath(input: {
return input.shimPath;
}
if (input.platform === "darwin") {
return input.shimPath;
}
if (input.platform === "linux") {
const appImagePath = input.appImagePath?.trim();
if (appImagePath) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.62",
"version": "0.1.60",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.62",
"version": "0.1.60",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.62",
"version": "0.1.60",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.62",
"version": "0.1.60",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -60,8 +60,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.62",
"@getpaseo/relay": "0.1.62",
"@getpaseo/highlight": "0.1.60",
"@getpaseo/relay": "0.1.60",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-coding-agent": "^0.67.68",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -1274,9 +1274,11 @@ describe("agent snapshot MCP serialization", () => {
const tool = (server as any)._registeredTools["list_agents"];
const response = await tool.callback({});
const agentIds = response.structuredContent.agents.map((agent: { id: string }) => agent.id);
expect(agentIds).toHaveLength(3);
expect(new Set(agentIds)).toEqual(new Set(["in-cwd", "in-child-cwd", "stored-in-cwd"]));
expect(response.structuredContent.agents.map((agent: { id: string }) => agent.id)).toEqual([
"in-cwd",
"in-child-cwd",
"stored-in-cwd",
]);
});
it("allows explicit cwd, status, archive, time, and limit filters for list_agents", async () => {
@@ -67,7 +67,6 @@ export const ProviderOverrideSchema = z
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
additionalModels: z.array(ProviderProfileModelSchema).optional(),
disallowedTools: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
@@ -666,193 +666,6 @@ describe("buildProviderRegistry", () => {
]);
});
test("additional models append to runtime models", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
additionalModels: [
{
id: "profile-fast",
label: "Profile Fast",
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
{
provider: "claude",
id: "profile-fast",
label: "Profile Fast",
},
]);
});
test("additional models merge onto profile replacement models", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
models: [
{
id: "profile-curated",
label: "Profile Curated",
},
],
additionalModels: [
{
id: "profile-extra",
label: "Profile Extra",
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models.map((model) => model.id)).toEqual(["profile-curated", "profile-extra"]);
});
test("additional models override matching runtime models in place", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "shared-model",
label: "Runtime Label",
description: "Runtime description",
metadata: {
source: "runtime",
},
},
{
provider: "claude",
id: "runtime-only",
label: "Runtime Only",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
additionalModels: [
{
id: "shared-model",
label: "Profile Label",
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([
{
provider: "claude",
id: "shared-model",
label: "Profile Label",
description: "Runtime description",
metadata: {
source: "runtime",
},
},
{
provider: "claude",
id: "runtime-only",
label: "Runtime Only",
},
]);
});
test("additional model default overrides runtime default", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: true,
},
{
provider: "claude",
id: "runtime-other",
label: "Runtime Other",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
additionalModels: [
{
id: "profile-default",
label: "Profile Default",
isDefault: true,
},
],
},
},
});
const models = await registry.claude.fetchModels({
cwd: "/tmp/registry-models",
force: false,
});
expect(models).toEqual([
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: false,
},
{
provider: "claude",
id: "runtime-other",
label: "Runtime Other",
isDefault: false,
},
{
provider: "claude",
id: "profile-default",
label: "Profile Default",
isDefault: true,
},
]);
});
test("no profile models — runtime models returned as-is", async () => {
mockState.runtimeModels.set("claude", [
{
@@ -63,7 +63,6 @@ type ResolvedProvider = {
definition: AgentProviderDefinition;
runtimeSettings?: ProviderRuntimeSettings;
profileModels: ProviderProfileModel[];
additionalModels: ProviderProfileModel[];
enabled: boolean;
createBaseClient: (logger: Logger) => AgentClient;
};
@@ -225,59 +224,16 @@ function mapModel(provider: AgentProvider, model: AgentModelDefinition): AgentMo
function mergeModels(
provider: AgentProvider,
profileModels: ProviderProfileModel[],
additionalModels: ProviderProfileModel[],
runtimeModels: AgentModelDefinition[],
): AgentModelDefinition[] {
const baseModels =
profileModels.length === 0
? runtimeModels.map((model) => mapModel(provider, model))
: profileModels.map((model) => ({
...model,
provider,
}));
if (additionalModels.length === 0) {
return baseModels;
if (profileModels.length === 0) {
return runtimeModels.map((model) => mapModel(provider, model));
}
const mergedModels = [...baseModels];
let hasAdditionalDefault = false;
for (const model of additionalModels) {
const additionalModel = {
...model,
provider,
};
hasAdditionalDefault ||= additionalModel.isDefault === true;
const existingIndex = mergedModels.findIndex((candidate) => candidate.id === model.id);
if (existingIndex === -1) {
mergedModels.push(additionalModel);
continue;
}
mergedModels[existingIndex] = {
...mergedModels[existingIndex],
...additionalModel,
};
}
if (!hasAdditionalDefault) {
return mergedModels;
}
const additionalDefaultIds = new Set(
additionalModels.filter((model) => model.isDefault === true).map((model) => model.id),
);
return mergedModels.map((model) =>
additionalDefaultIds.has(model.id)
? model
: {
...model,
isDefault: false,
},
);
return profileModels.map((model) => ({
...model,
provider,
}));
}
function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
@@ -374,12 +330,7 @@ function createRegistryEntry(
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
},
fetchModels: async (options: ListModelsOptions) =>
mergeModels(
provider,
resolved.profileModels,
resolved.additionalModels,
await modelClient.listModels(options),
),
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
fetchModes: async (options: ListModesOptions) => {
const modes = modelClient.listModes
? await modelClient.listModes(options)
@@ -422,7 +373,6 @@ function buildResolvedBuiltinProviders(
definition: applyOverrideToDefinition(definition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override?.models ?? [],
additionalModels: override?.additionalModels ?? [],
enabled: override?.enabled !== false,
createBaseClient: (logger) =>
factory(logger, mergedRuntimeSettings, {
@@ -466,7 +416,6 @@ function addDerivedProviders(
),
runtimeSettings: toRuntimeSettings(override),
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
createBaseClient: (logger) =>
new GenericACPAgentClient({
@@ -496,7 +445,6 @@ function addDerivedProviders(
definition: createDerivedDefinition(providerId, baseDefinition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override.models ?? [],
additionalModels: override.additionalModels ?? [],
enabled: override.enabled !== false,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
});
@@ -1,6 +1,3 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
import type { Api, Model } from "@mariozechner/pi-ai";
import pino from "pino";
@@ -96,126 +93,4 @@ describe("PiDirectAgentClient", () => {
expect(registry.getAll).not.toHaveBeenCalled();
expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]);
});
test("loads project extensions before listing available models", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-provider.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const models = await client.listModels({ cwd, force: false });
expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model");
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
test("creates sessions with project extension models and exposes extension commands", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-command.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
pi.registerCommand("dummy-command", {
description: "Dummy extension command",
handler: async () => {}
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "pi",
cwd,
model: "paseo-dummy/extension-model",
});
try {
await expect(session.listCommands()).resolves.toContainEqual({
name: "dummy-command",
description: "Dummy extension command",
argumentHint: "",
});
} finally {
await session.close();
}
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
});
@@ -4,13 +4,12 @@ import { join } from "node:path";
import { homedir } from "node:os";
import type { Logger } from "pino";
import {
AuthStorage,
ModelRegistry,
SessionManager,
createAgentSessionFromServices,
createAgentSessionServices,
createAgentSession,
type AgentSession as PiAgentSession,
type AgentSessionEvent,
type AgentSessionServices,
type BashToolInput,
type EditToolInput,
type FindToolInput,
@@ -102,11 +101,6 @@ interface StartTurnResult {
turnId: string;
}
interface PiDirectSessionResult {
session: PiAgentSession;
modelRegistry: ModelRegistry;
}
export type PiDirectSessionAdapter = Pick<
PiAgentSession,
| "abort"
@@ -1365,48 +1359,48 @@ export class PiDirectAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
}
private async getSessionServices(cwd: string): Promise<AgentSessionServices> {
return createAgentSessionServices({
cwd,
...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}),
});
private getModelRegistry(): ModelRegistry {
if (!this.modelRegistry) {
this.modelRegistry = ModelRegistry.create(AuthStorage.create());
}
return this.modelRegistry;
}
private resolveConfiguredModel(
registry: PiDirectModelRegistry,
modelId: string | null | undefined,
): Model<Api> | undefined {
private resolveConfiguredModel(modelId: string | null | undefined): Model<Api> | undefined {
const parsedReference = parseModelReference(modelId ?? null);
if (!parsedReference) {
return undefined;
}
const registry = this.getModelRegistry();
return findModelInRegistry(registry, parsedReference);
}
private async createSdkSession(config: AgentSessionConfig): Promise<PiDirectSessionResult> {
private async createSdkSession(config: AgentSessionConfig): Promise<PiAgentSession> {
const thinkingLevel =
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL;
const services = await this.getSessionServices(config.cwd);
const model = this.resolveConfiguredModel(services.modelRegistry, config.model);
const modelRegistry = this.getModelRegistry();
const model = this.resolveConfiguredModel(config.model);
const { session } = await createAgentSessionFromServices({
services,
const sessionOptions = {
cwd: config.cwd,
modelRegistry,
sessionManager: SessionManager.create(config.cwd),
thinkingLevel,
...(model ? { model } : {}),
});
};
const { session } = await createAgentSession(sessionOptions);
await session.bindExtensions({});
applySystemPrompt(session, config.systemPrompt);
return { session, modelRegistry: services.modelRegistry };
return session;
}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const { session, modelRegistry } = await this.createSdkSession(config);
return new PiDirectAgentSession(session, modelRegistry, config);
const session = await this.createSdkSession(config);
return new PiDirectAgentSession(session, this.getModelRegistry(), config);
}
async resumeSession(
@@ -1440,34 +1434,37 @@ export class PiDirectAgentClient implements AgentClient {
modeId: overrides?.modeId,
};
const services = await this.getSessionServices(mergedConfig.cwd);
const model = this.resolveConfiguredModel(services.modelRegistry, mergedConfig.model);
const model = this.resolveConfiguredModel(mergedConfig.model);
const thinkingLevel = normalizePiThinkingOption(mergedConfig.thinkingOptionId);
const { session } = await createAgentSessionFromServices({
services,
const sessionOptions = {
cwd: mergedConfig.cwd,
modelRegistry: this.getModelRegistry(),
sessionManager: resumedManager,
...(model ? { model } : {}),
...(thinkingLevel ? { thinkingLevel } : {}),
});
};
const { session } = await createAgentSession(sessionOptions);
await session.bindExtensions({});
applySystemPrompt(session, mergedConfig.systemPrompt);
return new PiDirectAgentSession(session, services.modelRegistry, mergedConfig);
return new PiDirectAgentSession(session, this.getModelRegistry(), mergedConfig);
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const services = await this.getSessionServices(options.cwd);
const models = services.modelRegistry.getAvailable().map((model) => ({
provider: PI_PROVIDER,
id: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.name}`,
description: `${model.provider}/${model.id}`,
metadata: {
provider: model.provider,
modelId: model.id,
} satisfies AgentMetadata,
thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined,
defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined,
}));
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
// Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant.
const models = this.getModelRegistry()
.getAvailable()
.map((model) => ({
provider: PI_PROVIDER,
id: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.name}`,
description: `${model.provider}/${model.id}`,
metadata: {
provider: model.provider,
modelId: model.id,
} satisfies AgentMetadata,
thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined,
defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined,
}));
return transformPiModels(models);
}
@@ -236,7 +236,8 @@ function createGitHubServiceStub(): GitHubService {
function createWorkspaceGitServiceStub(): WorkspaceGitService {
return {
registerWorkspace: () => ({
subscribe: async (params) => ({
initial: createWorkspaceGitSnapshot(params.cwd),
unsubscribe: () => {},
}),
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
@@ -165,34 +165,6 @@ describe("provider overrides (new format)", () => {
]);
});
test("additionalModels array accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
zai: {
extends: "claude",
label: "ZAI",
additionalModels: [
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
},
],
},
},
},
});
expect(parsed.agents?.providers?.zai?.additionalModels).toEqual([
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
},
]);
});
test("order field accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
+30 -19
View File
@@ -217,7 +217,7 @@ import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./pase
import { toWorktreeWireError } from "./worktree-errors.js";
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
type GitMutationRefreshReason =
| "commit-changes"
| "pull"
@@ -326,7 +326,7 @@ type WorkspaceGitWatchTarget = {
debounceTimer: ReturnType<typeof setTimeout> | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestDescriptorStateKey: string | null;
latestFingerprint: string | null;
lastBranchName: string | null;
};
@@ -897,9 +897,11 @@ export class Session {
}
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
async primeWorkspaceGitWatchFingerprintForWorkspace(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.syncWorkspaceGitObservers([descriptor]);
await this.primeWorkspaceGitWatchFingerprints([descriptor]);
}
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
@@ -915,7 +917,7 @@ export class Session {
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
}
@@ -4358,9 +4360,9 @@ export class Session {
this.workspaceGitSubscriptions.delete(normalizedCwd);
}
private workspaceGitDescriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
private workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string {
if (!workspace) {
return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY;
return WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT;
}
return JSON.stringify([
workspace.name,
@@ -4376,15 +4378,15 @@ export class Session {
if (!target) {
return false;
}
const nextStateKey = this.workspaceGitDescriptorStateKey(workspace);
if (target.latestDescriptorStateKey === nextStateKey) {
const nextFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
if (target.latestFingerprint === nextFingerprint) {
return true;
}
target.latestDescriptorStateKey = nextStateKey;
target.latestFingerprint = nextFingerprint;
return false;
}
private rememberWorkspaceGitDescriptorState(
private rememberWorkspaceGitWatchFingerprint(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): void {
@@ -4392,20 +4394,29 @@ export class Session {
if (!target) {
return;
}
target.latestDescriptorStateKey = this.workspaceGitDescriptorStateKey(workspace);
target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
target.lastBranchName = workspace?.name ?? null;
}
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
private async primeWorkspaceGitWatchFingerprints(
workspaces: Iterable<WorkspaceDescriptorPayload>,
): Promise<void> {
for (const workspace of workspaces) {
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
const persistedWorkspace = await this.workspaceRegistry.get(workspace.id);
if (!persistedWorkspace) {
continue;
}
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, {
isGit: workspace.projectKind === "git",
});
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace);
}
}
private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void {
private async syncWorkspaceGitWatchTarget(
cwd: string,
options: { isGit: boolean },
): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd);
@@ -4416,7 +4427,7 @@ export class Session {
return;
}
const subscription = this.workspaceGitService.registerWorkspace(
const subscription = await this.workspaceGitService.subscribe(
{ cwd: normalizedCwd },
(snapshot) => {
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
@@ -6512,7 +6523,7 @@ export class Session {
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
}
}
this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
this.rememberWorkspaceGitWatchFingerprint(workspaceId, nextWorkspace);
if (!nextWorkspace) {
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
@@ -6683,7 +6694,7 @@ export class Session {
}
const payload = await this.listFetchWorkspacesEntries(request);
this.syncWorkspaceGitObservers(payload.entries);
await this.primeWorkspaceGitWatchFingerprints(payload.entries);
this.sessionLogger.debug(
{
requestId: request.requestId,
@@ -9,7 +9,6 @@ import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
function createWorkspaceRuntimeSnapshot(
cwd: string,
@@ -69,7 +68,7 @@ function createSessionForWorkspaceGitWatchTests(): {
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
workspaces: Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>;
workspaceGitService: WorkspaceGitService & {
registerWorkspace: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
peekSnapshot: ReturnType<typeof vi.fn>;
getSnapshot: ReturnType<typeof vi.fn>;
refresh: ReturnType<typeof vi.fn>;
@@ -100,8 +99,7 @@ function createSessionForWorkspaceGitWatchTests(): {
error: vi.fn(),
};
const workspaceGitService = {
...createNoopWorkspaceGitService(),
registerWorkspace: vi.fn((params: { cwd: string }, listener: WorkspaceGitListener) => {
subscribe: vi.fn(async (params: { cwd: string }, listener: WorkspaceGitListener) => {
const unsubscribe = vi.fn();
subscriptions.push({
params,
@@ -109,6 +107,7 @@ function createSessionForWorkspaceGitWatchTests(): {
unsubscribe,
});
return {
initial: createWorkspaceRuntimeSnapshot(params.cwd),
unsubscribe,
};
}),
@@ -187,7 +186,7 @@ function createSessionForWorkspaceGitWatchTests(): {
}),
dispose: () => {},
} as any,
workspaceGitService,
workspaceGitService: workspaceGitService as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -201,7 +200,7 @@ function createSessionForWorkspaceGitWatchTests(): {
emitted,
projects,
workspaces,
workspaceGitService,
workspaceGitService: workspaceGitService as any,
subscriptions,
};
}
@@ -276,9 +275,9 @@ describe("workspace git watch targets", () => {
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
expect(workspaceGitService.subscribe).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" },
expect.any(Function),
);
@@ -335,7 +334,7 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
emitted.length = 0;
subscriptions[0]?.listener(
@@ -370,7 +369,7 @@ describe("workspace git watch targets", () => {
error: null,
requestId: "subscription:/tmp/repo",
});
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
expect(workspaceGitService.subscribe).toHaveBeenCalledWith(
{ cwd: "/tmp/repo" },
expect.any(Function),
);
@@ -398,7 +397,7 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true });
await sessionAny.syncWorkspaceGitWatchTarget("/tmp/repo", { isGit: true });
emitted.length = 0;
subscriptions[0]?.listener(
@@ -10,7 +10,6 @@ import type {
SessionOutboundMessage,
} from "../shared/messages.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -113,6 +112,67 @@ function agentIdsFromEntries(entries: Array<{ agent: Pick<AgentSnapshotPayload,
return entries.map((entry) => entry.agent.id);
}
function createNoopWorkspaceGitService() {
return {
subscribe: async (params: { cwd: string }) => ({
initial: {
cwd: params.cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: (_cwd: string) => null,
getSnapshot: async (cwd: string) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
resolveRepoRemoteUrl: async () => null,
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
refresh: async () => {},
requestWorkingTreeWatch: async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
function createWorkspaceRuntimeSnapshot(
cwd: string,
overrides?: {
@@ -241,7 +301,7 @@ function createSessionForWorkspaceTests(
}),
dispose: () => {},
} as any,
workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(),
workspaceGitService: (options.workspaceGitService ?? createNoopWorkspaceGitService()) as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -533,7 +593,7 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
workspaceGitService: createNoopWorkspaceGitService(),
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -685,7 +745,7 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
workspaceGitService: createNoopWorkspaceGitService(),
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -864,7 +924,7 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
workspaceGitService: createNoopWorkspaceGitService(),
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -1006,7 +1066,7 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
workspaceGitService: createNoopWorkspaceGitService(),
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -1623,7 +1683,7 @@ describe("workspace aggregation", () => {
}),
dispose: () => {},
} as any,
workspaceGitService: createNoopWorkspaceGitService(),
workspaceGitService: createNoopWorkspaceGitService() as any,
mcpBaseUrl: null,
stt: null,
tts: null,
@@ -2586,7 +2646,8 @@ describe("workspace aggregation", () => {
});
const workspaceGitService = createNoopWorkspaceGitService();
workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot);
workspaceGitService.registerWorkspace = vi.fn(() => ({
workspaceGitService.subscribe = vi.fn(async () => ({
initial: runtimeSnapshot,
unsubscribe: () => {},
}));
@@ -2667,64 +2728,6 @@ describe("workspace aggregation", () => {
]);
});
test("fetch_workspaces_response emits before cold registration-triggered git work starts", async () => {
const events: string[] = [];
const emitted: Array<{ type: string; payload: any }> = [];
const workspaceGitService = createNoopWorkspaceGitService();
const getSnapshot = vi.fn(async (cwd: string) => {
events.push(`git:${cwd}`);
return createWorkspaceRuntimeSnapshot(cwd);
});
workspaceGitService.getSnapshot = getSnapshot;
workspaceGitService.registerWorkspace = vi.fn((params: { cwd: string }) => {
queueMicrotask(() => {
void getSnapshot(params.cwd);
});
return {
unsubscribe: () => {},
};
});
const session = createSessionForWorkspaceTests({
workspaceGitService,
}) as any;
const project = createPersistedProjectRecord({
projectId: "proj-fetch-boundary",
rootPath: "/tmp/repo",
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-fetch-boundary",
projectId: project.projectId,
cwd: "/tmp/repo",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.emit = (message: any) => {
if (message.type === "fetch_workspaces_response") {
events.push("response");
}
emitted.push(message);
};
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
await session.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-fetch-workspaces-boundary",
subscribe: {},
});
expect(emitted.find((message) => message.type === "fetch_workspaces_response")).toBeDefined();
expect(events[0]).toBe("response");
});
test("workspace_update includes updated runtime fields", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", {
@@ -2862,7 +2865,6 @@ describe("workspace aggregation", () => {
projectId: gitProject.projectId,
projectDisplayName: gitProject.displayName,
projectRootPath: gitProject.rootPath,
workspaceDirectory: gitWorkspace.cwd,
projectKind: gitProject.kind,
workspaceKind: gitWorkspace.kind,
name: "main",
@@ -2879,7 +2881,6 @@ describe("workspace aggregation", () => {
projectId: directoryProject.projectId,
projectDisplayName: directoryProject.displayName,
projectRootPath: directoryProject.rootPath,
workspaceDirectory: directoryWorkspace.cwd,
projectKind: directoryProject.kind,
workspaceKind: directoryWorkspace.kind,
name: "docs",
@@ -1,76 +0,0 @@
import { basename } from "node:path";
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
import {
buildWorkspaceGitMetadataFromSnapshot,
type WorkspaceGitMetadata,
} from "../workspace-git-metadata.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
return {
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
};
}
export function createNoopWorkspaceGitService(
overrides: Partial<WorkspaceGitService> = {},
): WorkspaceGitService {
const service: WorkspaceGitService = {
registerWorkspace: () => ({
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd: string) => createNoGitWorkspaceRuntimeSnapshot(cwd),
getCheckoutDiff: async (): Promise<CheckoutDiffResult> => ({ diff: "" }),
validateBranchRef: async () => ({ kind: "not-found" }),
hasLocalBranch: async () => false,
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
return buildWorkspaceGitMetadataFromSnapshot({
cwd,
directoryName: options?.directoryName ?? basename(cwd),
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
});
},
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
resolveRepoRemoteUrl: async () => null,
refresh: async () => {},
requestWorkingTreeWatch: async () => ({
repoRoot: null,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
...overrides,
};
return service;
}
+51 -58
View File
@@ -1,6 +1,6 @@
import { WebSocketServer } from "ws";
import type { Server as HTTPServer } from "http";
import { basename, join } from "path";
import { join } from "path";
import { hostname as getHostname } from "node:os";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
@@ -35,8 +35,7 @@ import type {
} from "./agent/provider-launch-config.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { buildProviderRegistry } from "./agent/provider-registry.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
@@ -68,68 +67,62 @@ type WebSocketServerConfig = {
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
function createFallbackWorkspaceGitService(): WorkspaceGitServiceImpl {
return {
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
};
}
function createFallbackWorkspaceGitService(): WorkspaceGitService {
return {
registerWorkspace: () => ({
subscribe: async ({ cwd }: { cwd: string }) => ({
initial: {
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd: string) => createFallbackWorkspaceGitSnapshot(cwd),
getCheckoutDiff: async () => ({ diff: "" }),
validateBranchRef: async () => ({ kind: "not-found" }),
hasLocalBranch: async () => false,
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
getWorkspaceGitMetadata: async (cwd: string, options) => {
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
return buildWorkspaceGitMetadataFromSnapshot({
cwd,
directoryName: options?.directoryName ?? basename(cwd),
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
});
},
resolveRepoRoot: async (cwd: string) => cwd,
resolveDefaultBranch: async () => "main",
resolveRepoRemoteUrl: async () => null,
getSnapshot: async (cwd: string) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
refresh: async () => {},
requestWorkingTreeWatch: async () => ({
repoRoot: null,
requestWorkingTreeWatch: async (cwd: string) => ({
repoRoot: cwd,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
} as unknown as WorkspaceGitServiceImpl;
}
function createNoopProjectRegistry(): ProjectRegistry {
@@ -304,7 +297,7 @@ export class VoiceAssistantWebSocketServer {
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitService;
private readonly workspaceGitService: WorkspaceGitServiceImpl;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly daemonConfigStore: DaemonConfigStore;
@@ -401,7 +394,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpPort?: () => number | null,
getDaemonTcpHost?: () => string | null,
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null,
workspaceGitService?: WorkspaceGitService,
workspaceGitService?: WorkspaceGitServiceImpl,
github?: GitHubService,
) {
this.logger = logger.child({ module: "websocket-server" });
@@ -295,33 +295,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("registerWorkspace returns a subscription without waiting for a cold snapshot", async () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const service = createService({ getCheckoutStatus });
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
expect(getCheckoutStatus).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
subscription.unsubscribe();
service.dispose();
});
test("forced getSnapshot bypasses the internal min-gap and re-shells", async () => {
let nowMs = 0;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
@@ -342,10 +315,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
test("forced getSnapshot emits even when the fingerprint matches", async () => {
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({ getCheckoutStatus });
await service.getSnapshot("/tmp/repo");
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
await service.getSnapshot("/tmp/repo", { force: true, reason: "test" });
@@ -365,10 +336,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus,
now: () => new Date(nowMs),
});
await service.getSnapshot("/tmp/repo");
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
nowMs = 3_000;
await service.refresh("/tmp/repo");
@@ -597,8 +566,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getPullRequestStatus,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
@@ -614,103 +582,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("self-heal retries workspace observation setup while a listener remains active", async () => {
let nowMs = 0;
const resolveAbsoluteGitDir = vi
.fn<() => Promise<string | null>>()
.mockRejectedValueOnce(new Error("git dir temporarily unavailable"))
.mockResolvedValue("/tmp/repo/.git");
const watch = vi.fn(() => createWatcher() as never);
const service = createService({
resolveAbsoluteGitDir,
watch,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect(watch).not.toHaveBeenCalled();
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
expect(resolveAbsoluteGitDir).toHaveBeenLastCalledWith("/tmp/repo");
subscription.unsubscribe();
service.dispose();
});
test("stale workspace watcher callbacks do not refresh after unsubscribe", async () => {
const watchCallbacks: Array<() => void> = [];
const watch = vi.fn(
(_watchPath: string, _options: { recursive: boolean }, callback: () => void) => {
watchCallbacks.push(callback);
return createWatcher() as never;
},
);
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
getCheckoutStatus,
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
watch,
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(watchCallbacks.length).toBeGreaterThan(0);
});
const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length;
subscription.unsubscribe();
watchCallbacks[0]?.();
await vi.advanceTimersByTimeAsync(500);
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
service.dispose();
});
test("stale GitHub poll callbacks do not refresh after unsubscribe", async () => {
let pollStatus: (() => void) | null = null;
const pollUnsubscribe = vi.fn();
const github = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: vi.fn((options: { onStatus: () => void }) => {
pollStatus = options.onStatus;
return { unsubscribe: pollUnsubscribe };
}),
};
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
getCheckoutStatus,
github,
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
await vi.waitFor(() => {
expect(github.retainCurrentPullRequestStatusPoll).toHaveBeenCalledTimes(1);
});
const callsBeforeStaleCallback = getCheckoutStatus.mock.calls.length;
subscription.unsubscribe();
pollStatus?.();
await flushPromises();
expect(pollUnsubscribe).toHaveBeenCalledTimes(1);
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
service.dispose();
});
test("subscription starts GitHub self-heal reads within the fast poll window", async () => {
let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
@@ -740,8 +611,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
nowMs = 20_000;
await vi.advanceTimersByTimeAsync(20_000);
@@ -773,8 +643,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus,
github,
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
expect(retainCurrentPullRequestStatusPoll).not.toHaveBeenCalled();
@@ -789,9 +658,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus,
now: () => new Date(nowMs),
});
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
const second = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
await flushPromises();
const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
const second = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
@@ -811,14 +679,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
subscription.unsubscribe();
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
service.dispose();
});
@@ -830,14 +698,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus,
now: () => new Date(nowMs),
});
service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
service.dispose();
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
});
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
@@ -851,8 +719,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
getCheckoutStatus,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
nowMs = 60_000;
await vi.advanceTimersByTimeAsync(60_000);
@@ -223,16 +223,14 @@ describe("WorkspaceGitServiceImpl", () => {
vi.useRealTimers();
});
test("registerWorkspace returns a subscription without an initial snapshot contract", async () => {
test("subscribe returns an initial workspace runtime snapshot", async () => {
const service = createService();
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
expect(subscription).toEqual({ unsubscribe: expect.any(Function) });
expect("initial" in subscription).toBe(false);
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
expect(listener).not.toHaveBeenCalled();
expect(service.peekSnapshot("/tmp/repo")).toBeNull();
subscription.unsubscribe();
service.dispose();
@@ -284,8 +282,7 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs),
});
const listener = vi.fn();
await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
nowMs += 3_000;
await service.refresh("/tmp/repo");
@@ -297,7 +294,7 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("cold getSnapshot calls share one workspace target and cache the snapshot", async () => {
test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => {
const checkoutStatusDeferred = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise);
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
@@ -316,6 +313,8 @@ describe("WorkspaceGitServiceImpl", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
expect((service as any).workspaceTargets.size).toBe(1);
expect((service as any).workspaceTargetSetups.size).toBe(1);
checkoutStatusDeferred.resolve(createCheckoutStatus("/tmp/repo"));
@@ -326,7 +325,8 @@ describe("WorkspaceGitServiceImpl", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect((service as any).workspaceTargets.size).toBe(1);
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
await expect(service.getSnapshot("/tmp/repo")).resolves.toEqual(createSnapshot("/tmp/repo"));
@@ -347,12 +347,14 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs),
});
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
const second = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
const [first, second] = await Promise.all([
service.subscribe({ cwd: "/tmp/repo" }, vi.fn()),
service.subscribe({ cwd: "/tmp/repo" }, vi.fn()),
]);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect((service as any).workspaceTargets.size).toBe(1);
first.unsubscribe();
second.unsubscribe();
@@ -370,9 +372,9 @@ describe("WorkspaceGitServiceImpl", () => {
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: "/tmp/repo/." }, vi.fn());
const subscription = await service.subscribe({ cwd: "/tmp/repo/." }, vi.fn());
await expect(service.getSnapshot("/tmp/repo/.")).resolves.toEqual(createSnapshot("/tmp/repo"));
expect(subscription.initial).toEqual(createSnapshot("/tmp/repo"));
expect(service.peekSnapshot("/tmp/repo")).toEqual(createSnapshot("/tmp/repo"));
nowMs += 3_000;
@@ -381,6 +383,7 @@ describe("WorkspaceGitServiceImpl", () => {
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
expect((service as any).workspaceTargets.size).toBe(1);
subscription.unsubscribe();
service.dispose();
@@ -389,23 +392,20 @@ describe("WorkspaceGitServiceImpl", () => {
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
const runGitFetch = vi.fn(async () => {});
const hasOriginRemote = vi.fn(async () => true);
const resolveAbsoluteGitDir = vi.fn(async () => "/tmp/repo/.git");
const service = createService({
resolveAbsoluteGitDir,
resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"),
hasOriginRemote,
runGitFetch,
});
const first = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn());
const second = service.registerWorkspace({ cwd: "/tmp/repo/packages/server" }, vi.fn());
await vi.waitFor(() => {
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(2);
expect(runGitFetch).toHaveBeenCalledTimes(1);
});
const first = await service.subscribe({ cwd: "/tmp/repo" }, vi.fn());
const second = await service.subscribe({ cwd: "/tmp/repo/packages/server" }, vi.fn());
await flushPromises();
expect(hasOriginRemote).toHaveBeenCalledTimes(1);
expect(runGitFetch).toHaveBeenCalledTimes(1);
expect((service as any).repoTargets.size).toBe(1);
await vi.advanceTimersByTimeAsync(180_000);
await flushPromises();
@@ -452,10 +452,9 @@ describe("WorkspaceGitServiceImpl", () => {
});
const listener = vi.fn();
const initialSnapshot = await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
expect(subscription.initial.github.pullRequest?.title).toBe("Before refresh");
await service.refresh("/tmp/repo");
await flushPromises();
@@ -522,10 +521,9 @@ describe("WorkspaceGitServiceImpl", () => {
});
const listener = vi.fn();
const initialSnapshot = await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
expect(initialSnapshot.git.currentBranch).toBe("main");
expect(subscription.initial.git.currentBranch).toBe("main");
nowMs += 3_000;
await service.refresh("/tmp/repo");
@@ -566,8 +564,7 @@ describe("WorkspaceGitServiceImpl", () => {
});
const listener = vi.fn();
await service.getSnapshot("/tmp/repo");
const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, listener);
const subscription = await service.subscribe({ cwd: "/tmp/repo" }, listener);
await service.getSnapshot("/tmp/repo", {
force: true,
@@ -768,14 +765,10 @@ describe("WorkspaceGitServiceImpl", () => {
const service = createService({ getCheckoutShortstat, watch });
const workspaceListener = vi.fn();
const initialSnapshot = await service.getSnapshot("/tmp/repo");
const workspaceSubscription = service.registerWorkspace(
{ cwd: "/tmp/repo" },
workspaceListener,
);
const workspaceSubscription = await service.subscribe({ cwd: "/tmp/repo" }, workspaceListener);
const diffSubscription = await service.requestWorkingTreeWatch("/tmp/repo", vi.fn());
expect(initialSnapshot.git.diffStat).toEqual({ additions: 1, deletions: 0 });
expect(workspaceSubscription.initial.git.diffStat).toEqual({ additions: 1, deletions: 0 });
const repoRootWatch = watchCallbacks.find((entry) => entry.path === "/tmp/repo");
expect(repoRootWatch).toBeDefined();
@@ -83,10 +83,13 @@ export type WorkspaceGitRuntimeSnapshot = {
};
export interface WorkspaceGitService {
registerWorkspace(
subscribe(
params: { cwd: string },
listener: WorkspaceGitListener,
): WorkspaceGitSubscription;
): Promise<{
initial: WorkspaceGitRuntimeSnapshot;
unsubscribe: () => void;
}>;
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null;
getSnapshot(
@@ -136,10 +139,6 @@ export interface WorkspaceGitService {
export type WorkspaceGitListener = (snapshot: WorkspaceGitRuntimeSnapshot) => void;
export type WorkspaceGitSubscription = {
unsubscribe: () => void;
};
export type WorkspaceGitReadOptions =
| {
force?: false;
@@ -247,9 +246,6 @@ interface WorkspaceGitTarget {
latestFingerprint: string | null;
lastShellOutAtMs: number | null;
repoGitRoot: string | null;
observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean;
closed: boolean;
}
interface RepoGitTarget {
@@ -285,6 +281,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private readonly deps: WorkspaceGitServiceDependencies;
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
private readonly repoTargets = new Map<string, RepoGitTarget>();
private readonly workspaceTargetSetups = new Map<string, Promise<WorkspaceGitTarget>>();
private readonly workingTreeWatchTargets = new Map<string, WorkingTreeWatchTarget>();
private readonly workingTreeWatchSetups = new Map<string, Promise<WorkingTreeWatchTarget>>();
private readonly branchValidationCache = new Map<
@@ -340,22 +337,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
};
}
registerWorkspace(
async subscribe(
params: { cwd: string },
listener: WorkspaceGitListener,
): WorkspaceGitSubscription {
): Promise<{
initial: WorkspaceGitRuntimeSnapshot;
unsubscribe: () => void;
}> {
const cwd = normalizeWorkspaceId(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
const target = await this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
if (target.listeners.size === 1) {
this.startWorkspaceSubscriptionTimers(target);
}
if (!target.latestSnapshot) {
this.scheduleInitialWorkspaceRefresh(target);
}
this.scheduleWorkspaceObservationSetup(target);
return {
initial: target.latestSnapshot ?? (await this.getSnapshot(cwd)),
unsubscribe: () => {
this.removeWorkspaceListener(cwd, listener);
},
@@ -368,7 +365,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): Promise<WorkspaceGitRuntimeSnapshot> {
cwd = normalizeWorkspaceId(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd);
const target = await this.ensureWorkspaceTarget(cwd);
if (!request.force && this.isSnapshotWarm(target)) {
return target.latestSnapshot;
}
@@ -547,14 +544,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
cwd = normalizeWorkspaceId(cwd);
const target = this.ensureWorkspaceTarget(cwd);
await this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: false,
reason: "refresh",
notify: true,
});
this.scheduleWorkspaceObservationSetup(target);
const target = this.workspaceTargets.get(cwd);
if (target) {
await this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: false,
reason: "refresh",
notify: true,
});
return;
}
await this.ensureWorkspaceTarget(cwd);
}
async requestWorkingTreeWatch(
@@ -586,6 +587,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.closeWorkspaceTarget(target);
}
this.workspaceTargets.clear();
this.workspaceTargetSetups.clear();
for (const target of this.repoTargets.values()) {
this.closeRepoTarget(target);
@@ -599,13 +601,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.workingTreeWatchSetups.clear();
}
private ensureWorkspaceTarget(cwd: string): WorkspaceGitTarget {
private async ensureWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
const existingTarget = this.workspaceTargets.get(cwd);
if (existingTarget) {
return existingTarget;
}
return this.createWorkspaceTarget(cwd);
const existingSetup = this.workspaceTargetSetups.get(cwd);
if (existingSetup) {
return existingSetup;
}
const setup = this.createWorkspaceTarget(cwd).finally(() => {
this.workspaceTargetSetups.delete(cwd);
});
this.workspaceTargetSetups.set(cwd, setup);
return setup;
}
private readAuxiliaryCache<T>(
@@ -687,7 +698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return setup;
}
private createWorkspaceTarget(cwd: string): WorkspaceGitTarget {
private async createWorkspaceTarget(cwd: string): Promise<WorkspaceGitTarget> {
const target: WorkspaceGitTarget = {
cwd,
listeners: new Set(),
@@ -702,85 +713,35 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
latestFingerprint: null,
lastShellOutAtMs: null,
repoGitRoot: null,
observationSetupPromise: null,
observationSetupComplete: false,
closed: false,
};
this.workspaceTargets.set(cwd, target);
return target;
}
private scheduleInitialWorkspaceRefresh(target: WorkspaceGitTarget): void {
queueMicrotask(() => {
if (!this.isActiveObservedWorkspaceTarget(target) || target.latestSnapshot) {
return;
}
void this.refreshWorkspaceTarget(target, {
try {
await this.requestWorkspaceSnapshot(target, {
force: false,
includeGitHub: true,
reason: "initial",
notify: true,
notify: false,
});
});
}
private scheduleWorkspaceObservationSetup(target: WorkspaceGitTarget): void {
if (
target.observationSetupComplete ||
target.observationSetupPromise ||
!this.isActiveObservedWorkspaceTarget(target)
) {
return;
}
target.observationSetupPromise = Promise.resolve()
.then(() => this.setupWorkspaceObservation(target))
.catch((error) => {
this.logger.warn(
{ err: error, cwd: target.cwd },
"Failed to set up workspace git observation",
);
})
.finally(() => {
target.observationSetupPromise = null;
});
}
private async setupWorkspaceObservation(target: WorkspaceGitTarget): Promise<void> {
try {
const gitDir = await this.deps.resolveAbsoluteGitDir(target.cwd);
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
const gitDir = await this.deps.resolveAbsoluteGitDir(cwd);
if (!gitDir) {
target.observationSetupComplete = true;
return;
return target;
}
const repoGitRoot = await this.resolveWorkspaceGitRefsRoot(gitDir);
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
target.repoGitRoot = repoGitRoot;
this.startWorkspaceWatchers(target, gitDir, repoGitRoot);
await this.ensureRepoTarget(target);
if (this.isActiveObservedWorkspaceTarget(target)) {
target.observationSetupComplete = true;
}
return target;
} catch (error) {
this.closeWorkspaceTarget(target);
this.workspaceTargets.delete(cwd);
throw error;
}
}
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
!target.closed &&
target.listeners.size > 0 &&
this.workspaceTargets.get(target.cwd) === target
);
}
private async createWorkingTreeWatchTarget(cwd: string): Promise<WorkingTreeWatchTarget> {
const repoRoot = await this.resolveCheckoutWatchRoot(cwd);
const target: WorkingTreeWatchTarget = {
@@ -901,7 +862,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private async ensureRepoTarget(workspaceTarget: WorkspaceGitTarget): Promise<void> {
const repoGitRoot = workspaceTarget.repoGitRoot;
if (!repoGitRoot || !this.isActiveObservedWorkspaceTarget(workspaceTarget)) {
if (!repoGitRoot) {
return;
}
@@ -912,9 +873,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
const hasOrigin = await this.deps.hasOriginRemote(workspaceTarget.cwd);
if (!this.isActiveObservedWorkspaceTarget(workspaceTarget)) {
return;
}
if (!hasOrigin) {
return;
}
@@ -946,7 +904,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
typeof targetOrCwd === "string"
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
: targetOrCwd;
if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
if (!target) {
return;
}
@@ -955,9 +913,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
target.debounceTimer = setTimeout(() => {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.debounceTimer = null;
void this.refreshWorkspaceTarget(target, {
force: options?.force === true,
@@ -971,7 +926,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
if (!target.selfHealTimer) {
target.selfHealTimer = setInterval(() => {
this.scheduleWorkspaceObservationSetup(target);
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
this.logger.warn(
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
@@ -1011,9 +965,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: target.cwd,
headRef,
onStatus: () => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
void this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: true,
@@ -1186,9 +1137,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
try {
await this.requestWorkspaceSnapshot(target, request);
} catch (error) {
@@ -1449,7 +1397,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
target.closed = true;
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
@@ -6,11 +6,69 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
function createNoopWorkspaceGitService(): WorkspaceGitService {
return {
subscribe: async (params) => ({
initial: {
cwd: params.cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
},
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getSnapshot: async (cwd) => ({
cwd,
git: {
isGit: false,
repoRoot: null,
mainRepoRoot: null,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
isDirty: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
resolveRepoRemoteUrl: async () => null,
refresh: async () => {},
requestWorkingTreeWatch: async () => ({
repoRoot: null,
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
dispose: () => {},
};
}
describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string;
let paseoHome: string;
@@ -1220,78 +1220,6 @@ describe("GitHubService", () => {
]);
});
it("selects the requested fork owner when resolving a scoped PR worktree branch", async () => {
const runner = createScriptedRunner([
{ error: noPullRequestError() },
JSON.stringify([
{
number: 77,
url: "https://github.com/repoOwner/repo/pull/77",
title: "Unrelated fork main branch",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "main",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "otherForkOwner" },
},
{
number: 345,
url: "https://github.com/repoOwner/repo/pull/345",
title: "Requested fork main branch",
state: "OPEN",
isDraft: false,
baseRefName: "main",
headRefName: "main",
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "chethanuk" },
},
]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "main",
headRepositoryOwner: "chethanuk",
});
expect(status).toMatchObject({
number: 345,
repoOwner: "repoOwner",
repoName: "repo",
headRefName: "main",
});
expect(runner.calls.map((call) => call.args)).toEqual([
[
"pr",
"view",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
],
[
"pr",
"list",
"--state",
"all",
"--head",
"main",
"--json",
"number,url,title,state,isDraft,baseRefName,headRefName,mergedAt,statusCheckRollup,reviewDecision,headRepositoryOwner",
"--limit",
"10",
],
]);
});
it("finds a fork PR in the parent repo when the direct current branch view is unavailable", async () => {
const runner = createScriptedRunner([
{ error: noPullRequestError() },
+16 -39
View File
@@ -481,7 +481,6 @@ export interface GitHubService {
options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
} & GitHubReadOptions,
): Promise<GitHubCurrentPullRequestStatus | null>;
getPullRequestTimeline(
@@ -855,16 +854,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return cached({
cwd: options.cwd,
method: "getCurrentPullRequestStatus",
args: {
headRef: options.headRef,
headRepositoryOwner: options.headRepositoryOwner,
},
args: { headRef: options.headRef },
readOptions: options,
load: async () => {
return resolveCurrentPullRequestView({
cwd: options.cwd,
headRef: options.headRef,
headRepositoryOwner: options.headRepositoryOwner,
run,
});
},
@@ -1286,51 +1281,33 @@ function isNoPullRequestFoundError(error: unknown): boolean {
async function resolveCurrentPullRequestView(options: {
cwd: string;
headRef: string;
headRepositoryOwner?: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<GitHubCurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
const viewMatch = viewCandidate
? pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
return viewMatch.status;
if (viewCandidate && isCandidateForHeadRef(viewCandidate, options.headRef)) {
return viewCandidate.status;
}
let listHeadRef = options.headRef;
let listRepo: string | undefined;
let headRepositoryOwner = options.headRepositoryOwner;
if (!headRepositoryOwner) {
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !parentOwner || !parentName) {
return null;
}
listHeadRef = `${forkOwner}:${options.headRef}`;
listRepo = `${parentOwner}/${parentName}`;
headRepositoryOwner = forkOwner;
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !parentOwner || !parentName) {
return null;
}
const candidates = await listCurrentPullRequestCandidates({
const parentCandidates = await listCurrentPullRequestCandidates({
cwd: options.cwd,
headRef: listHeadRef,
headRef: `${forkOwner}:${options.headRef}`,
run: options.run,
repo: listRepo,
repo: `${parentOwner}/${parentName}`,
});
const match = pickPullRequestCandidate({
candidates,
const parentMatch = pickPullRequestCandidate({
candidates: parentCandidates,
headRef: options.headRef,
headRepositoryOwner,
headRepositoryOwner: forkOwner,
});
return match?.status ?? null;
return parentMatch?.status ?? null;
}
async function tryCurrentPullRequestView(options: {
@@ -31,7 +31,6 @@ import {
warmCheckoutShortstatInBackground,
} from "./checkout-git.js";
import {
GitHubCommandError,
GitHubCliMissingError,
type GitHubCurrentPullRequestStatus,
type GitHubService,
@@ -1429,45 +1428,6 @@ const x = 1;
});
});
it("uses the tracked fork branch for PR worktree status lookup", async () => {
execSync("git checkout -b chethanuk/main", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
execSync("git remote add paseo-pr-345 git@github.com:chethanuk/paseo.git", { cwd: repoDir });
execSync("git config branch.chethanuk/main.remote paseo-pr-345", { cwd: repoDir });
execSync("git config branch.chethanuk/main.merge refs/heads/main", { cwd: repoDir });
const requestedTargets: Array<{ headRef: string; headRepositoryOwner?: string }> = [];
const github = createGitHubServiceForStatus(
createPullRequestStatus({
number: 345,
url: "https://github.com/getpaseo/paseo/pull/345",
headRefName: "main",
}),
{
onStatus: () => {},
},
);
github.getCurrentPullRequestStatus = async (options) => {
requestedTargets.push({
headRef: options.headRef,
...(options.headRepositoryOwner
? { headRepositoryOwner: options.headRepositoryOwner }
: {}),
});
return createPullRequestStatus({
number: 345,
url: "https://github.com/getpaseo/paseo/pull/345",
headRefName: options.headRef,
});
};
const status = await getPullRequestStatus(repoDir, github);
expect(requestedTargets).toEqual([{ headRef: "main", headRepositoryOwner: "chethanuk" }]);
expect(status.status?.number).toBe(345);
expect(status.status?.headRefName).toBe("main");
});
it("returns closed-unmerged PR status without marking it as merged", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
@@ -1537,75 +1497,6 @@ const x = 1;
}
});
it("keeps stale PR status when a refresh hits a transient GitHub error", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
__setPullRequestStatusCacheTtlForTests(50);
try {
let callCount = 0;
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async () => {
callCount += 1;
if (callCount === 1) {
return createPullRequestStatus({
url: "https://github.com/getpaseo/paseo/pull/123",
});
}
throw new GitHubCommandError({
args: ["pr", "view"],
cwd: repoDir,
exitCode: 1,
stderr: "could not resolve host: github.com",
});
};
const fresh = await getPullRequestStatus(repoDir, github);
await sleep(80);
const stale = await getPullRequestStatus(repoDir, github);
expect(stale).toEqual(fresh);
expect(stale.githubFeaturesEnabled).toBe(true);
expect(stale.status?.url).toContain("/pull/123");
expect(callCount).toBe(2);
} finally {
__resetPullRequestStatusCacheForTests();
}
});
it("clears stale PR status after a successful no-PR refresh", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
__setPullRequestStatusCacheTtlForTests(50);
try {
let callCount = 0;
const github = createGitHubServiceForStatus(null);
github.getCurrentPullRequestStatus = async () => {
callCount += 1;
if (callCount === 1) {
return createPullRequestStatus({
url: "https://github.com/getpaseo/paseo/pull/123",
});
}
return null;
};
const fresh = await getPullRequestStatus(repoDir, github);
await sleep(80);
const cleared = await getPullRequestStatus(repoDir, github);
expect(fresh.status?.url).toContain("/pull/123");
expect(cleared).toEqual({
githubFeaturesEnabled: true,
status: null,
});
expect(callCount).toBe(2);
} finally {
__resetPullRequestStatusCacheForTests();
}
});
it("dedupes concurrent PR status lookups for the same cwd", async () => {
execSync("git checkout -b feature", { cwd: repoDir });
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
+1 -79
View File
@@ -4,11 +4,9 @@ import { open as openFile, readFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js";
import {
GitHubAuthenticationError,
GitHubCliMissingError,
GitHubCommandError,
createGitHubService,
resolveGitHubRepo,
type GitHubRepoRemoteUrlResolver,
@@ -31,7 +29,6 @@ const SHORTSTAT_CACHE_MAX = 1_000;
let pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS;
let pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
const pullRequestStatusInFlight = new Map<string, Promise<PullRequestStatusResult>>();
const lastSuccessfulPullRequestStatus = new Map<string, PullRequestStatusResult>();
let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS;
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
@@ -41,11 +38,6 @@ interface CheckoutReadCacheOptions {
reason?: string;
}
interface PullRequestStatusLookupTarget {
headRef: string;
headRepositoryOwner?: string;
}
function createPullRequestStatusCache(ttlMs: number) {
return new TTLCache<string, PullRequestStatusResult>({
ttl: ttlMs,
@@ -66,17 +58,6 @@ function getPullRequestStatusCacheKey(cwd: string): string {
return resolve(cwd);
}
function rememberPullRequestStatus(cacheKey: string, status: PullRequestStatusResult): void {
lastSuccessfulPullRequestStatus.set(cacheKey, status);
if (lastSuccessfulPullRequestStatus.size <= PULL_REQUEST_STATUS_CACHE_MAX) {
return;
}
const oldest = lastSuccessfulPullRequestStatus.keys().next();
if (!oldest.done) {
lastSuccessfulPullRequestStatus.delete(oldest.value);
}
}
function getShortstatCacheKey(cwd: string): string {
return resolve(cwd);
}
@@ -87,7 +68,6 @@ export function __resetPullRequestStatusCacheForTests(): void {
pullRequestStatusCacheTtlMs = DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS;
pullRequestStatusCache = createPullRequestStatusCache(pullRequestStatusCacheTtlMs);
pullRequestStatusInFlight.clear();
lastSuccessfulPullRequestStatus.clear();
}
export function __setPullRequestStatusCacheTtlForTests(ttlMs: number): void {
@@ -96,7 +76,6 @@ export function __setPullRequestStatusCacheTtlForTests(ttlMs: number): void {
pullRequestStatusCacheTtlMs = ttlMs;
pullRequestStatusCache = createPullRequestStatusCache(ttlMs);
pullRequestStatusInFlight.clear();
lastSuccessfulPullRequestStatus.clear();
}
export function __resetCheckoutShortstatCacheForTests(): void {
@@ -926,52 +905,6 @@ export async function hasOriginRemote(cwd: string): Promise<boolean> {
return url !== null;
}
async function getGitConfigValue(cwd: string, key: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["config", "--get", key], {
cwd,
env: READ_ONLY_GIT_ENV,
});
const value = stdout.trim();
return value.length > 0 ? value : null;
} catch {
return null;
}
}
function parseBranchMergeHeadRef(mergeRef: string | null): string | null {
const prefix = "refs/heads/";
if (!mergeRef?.startsWith(prefix)) {
return null;
}
const headRef = mergeRef.slice(prefix.length).trim();
return headRef.length > 0 ? headRef : null;
}
async function resolvePullRequestStatusLookupTarget(
cwd: string,
currentBranch: string,
): Promise<PullRequestStatusLookupTarget> {
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`);
if (!remoteName?.startsWith("paseo-pr-")) {
return { headRef: currentBranch };
}
const mergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`);
const trackedHeadRef = parseBranchMergeHeadRef(mergeRef);
if (!trackedHeadRef) {
return { headRef: currentBranch };
}
const remoteUrl = await getGitConfigValue(cwd, `remote.${remoteName}.url`);
const remoteRepo = remoteUrl ? parseGitHubRepoFromRemote(remoteUrl) : null;
const headRepositoryOwner = remoteRepo?.split("/")[0];
return {
headRef: trackedHeadRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
}
export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
@@ -2173,18 +2106,8 @@ export async function getPullRequestStatus(
const lookup = getPullRequestStatusUncached(cwd, github, options)
.then((status) => {
pullRequestStatusCache.set(cacheKey, status);
rememberPullRequestStatus(cacheKey, status);
return status;
})
.catch((error) => {
if (error instanceof GitHubCommandError) {
const stale = lastSuccessfulPullRequestStatus.get(cacheKey);
if (stale) {
return stale;
}
}
throw error;
})
.finally(() => {
pullRequestStatusInFlight.delete(cacheKey);
});
@@ -2207,10 +2130,9 @@ async function getPullRequestStatusUncached(
};
}
try {
const lookupTarget = await resolvePullRequestStatusLookupTarget(cwd, head);
const status = await github.getCurrentPullRequestStatus({
cwd,
...lookupTarget,
headRef: head,
reason: options?.reason,
});
return {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.62",
"version": "0.1.60",
"private": true,
"type": "module",
"scripts": {
@@ -29,6 +29,7 @@
]
},
"allowedHosts": {
"description": "Deprecated: use hostnames instead",
"anyOf": [
{
"type": "boolean",
@@ -47,12 +48,9 @@
"properties": {
"enabled": {
"type": "boolean"
},
"injectIntoAgents": {
"type": "boolean"
}
},
"additionalProperties": true
"additionalProperties": false
},
"cors": {
"type": "object",
@@ -127,95 +125,68 @@
"additionalProperties": {
"type": "object",
"properties": {
"extends": {
"type": "string"
},
"label": {
"type": "string"
},
"description": {
"type": "string"
},
"command": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1
"anyOf": [
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "default"
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "append"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["mode"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"mode": {
"type": "string",
"const": "replace"
},
"argv": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"minItems": 1
}
},
"required": ["mode", "argv"],
"additionalProperties": false
}
]
},
"env": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"models": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"label": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"thinkingOptions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"description": {
"type": "string"
},
"isDefault": {
"type": "boolean"
}
},
"required": ["id", "label"],
"additionalProperties": false
}
}
},
"required": ["id", "label"],
"additionalProperties": false
}
},
"additionalModels": {
"type": "array",
"items": {
"$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items"
}
},
"disallowedTools": {
"type": "array",
"items": {
"type": "string"
}
},
"enabled": {
"type": "boolean"
},
"order": {
"type": "number"
}
},
"additionalProperties": false
},
"propertyNames": {
"type": "string"
}
}
},
@@ -289,15 +260,6 @@
},
"additionalProperties": false
},
"turnDetection": {
"type": "object",
"properties": {
"provider": {
"$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider"
}
},
"additionalProperties": false
},
"tts": {
"type": "object",
"properties": {
-21
View File
@@ -25,7 +25,6 @@ import { Route as DocsVoiceRouteImport } from "./routes/docs/voice";
import { Route as DocsUpdatesRouteImport } from "./routes/docs/updates";
import { Route as DocsSkillsRouteImport } from "./routes/docs/skills";
import { Route as DocsSecurityRouteImport } from "./routes/docs/security";
import { Route as DocsProvidersRouteImport } from "./routes/docs/providers";
import { Route as DocsConfigurationRouteImport } from "./routes/docs/configuration";
import { Route as DocsCliRouteImport } from "./routes/docs/cli";
import { Route as DocsBestPracticesRouteImport } from "./routes/docs/best-practices";
@@ -111,11 +110,6 @@ const DocsSecurityRoute = DocsSecurityRouteImport.update({
path: "/security",
getParentRoute: () => DocsRoute,
} as any);
const DocsProvidersRoute = DocsProvidersRouteImport.update({
id: "/providers",
path: "/providers",
getParentRoute: () => DocsRoute,
} as any);
const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
id: "/configuration",
path: "/configuration",
@@ -151,7 +145,6 @@ export interface FileRoutesByFullPath {
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/providers": typeof DocsProvidersRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/skills": typeof DocsSkillsRoute;
"/docs/updates": typeof DocsUpdatesRoute;
@@ -172,7 +165,6 @@ export interface FileRoutesByTo {
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/providers": typeof DocsProvidersRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/skills": typeof DocsSkillsRoute;
"/docs/updates": typeof DocsUpdatesRoute;
@@ -196,7 +188,6 @@ export interface FileRoutesById {
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/providers": typeof DocsProvidersRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/skills": typeof DocsSkillsRoute;
"/docs/updates": typeof DocsUpdatesRoute;
@@ -221,7 +212,6 @@ export interface FileRouteTypes {
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/providers"
| "/docs/security"
| "/docs/skills"
| "/docs/updates"
@@ -242,7 +232,6 @@ export interface FileRouteTypes {
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/providers"
| "/docs/security"
| "/docs/skills"
| "/docs/updates"
@@ -265,7 +254,6 @@ export interface FileRouteTypes {
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/providers"
| "/docs/security"
| "/docs/skills"
| "/docs/updates"
@@ -401,13 +389,6 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DocsSecurityRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/providers": {
id: "/docs/providers";
path: "/providers";
fullPath: "/docs/providers";
preLoaderRoute: typeof DocsProvidersRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/configuration": {
id: "/docs/configuration";
path: "/configuration";
@@ -455,7 +436,6 @@ interface DocsRouteChildren {
DocsBestPracticesRoute: typeof DocsBestPracticesRoute;
DocsCliRoute: typeof DocsCliRoute;
DocsConfigurationRoute: typeof DocsConfigurationRoute;
DocsProvidersRoute: typeof DocsProvidersRoute;
DocsSecurityRoute: typeof DocsSecurityRoute;
DocsSkillsRoute: typeof DocsSkillsRoute;
DocsUpdatesRoute: typeof DocsUpdatesRoute;
@@ -468,7 +448,6 @@ const DocsRouteChildren: DocsRouteChildren = {
DocsBestPracticesRoute: DocsBestPracticesRoute,
DocsCliRoute: DocsCliRoute,
DocsConfigurationRoute: DocsConfigurationRoute,
DocsProvidersRoute: DocsProvidersRoute,
DocsSecurityRoute: DocsSecurityRoute,
DocsSkillsRoute: DocsSkillsRoute,
DocsUpdatesRoute: DocsUpdatesRoute,
-1
View File
@@ -12,7 +12,6 @@ const navigation = [
{ name: "Git worktrees", href: "/docs/worktrees" },
{ name: "CLI", href: "/docs/cli" },
{ name: "Skills", href: "/docs/skills" },
{ name: "Providers", href: "/docs/providers" },
{ name: "Configuration", href: "/docs/configuration" },
{ name: "Security", href: "/docs/security" },
{ name: "Best practices", href: "/docs/best-practices" },
@@ -11,8 +11,6 @@ export const Route = createFileRoute("/docs/configuration")({
component: Configuration,
});
const CUSTOM_PROVIDERS_URL = "https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md";
function Configuration() {
return (
<div className="space-y-8">
@@ -61,12 +59,15 @@ function Configuration() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Example</h2>
<p className="text-white/60 leading-relaxed">
Minimal example that configures listening address, hostnames, and MCP:
Minimal example that configures listening address, hostnames, provider keys, and MCP:
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"$schema": "https://paseo.sh/schemas/paseo.config.v1.json",
"version": 1,
"providers": {
"openai": { "apiKey": "..." }
},
"daemon": {
"listen": "127.0.0.1:6767",
"hostnames": ["localhost", ".localhost"],
@@ -82,31 +83,84 @@ function Configuration() {
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Agent providers</h2>
<h2 className="text-xl font-medium">Agent provider runtime settings</h2>
<p className="text-white/60 leading-relaxed">
Agent providers — both the first-class ones Paseo ships with and custom entries you add
under <code className="font-mono">agents.providers</code> — are documented on their own
page.
Use <code className="font-mono">agents.providers</code> to customize how Paseo launches
agent provider CLIs. This works for <code className="font-mono">claude</code>,{" "}
<code className="font-mono">codex</code>, and
<code className="font-mono"> opencode</code>.
</p>
<p className="text-white/60 leading-relaxed">
See{" "}
<a href="/docs/providers" className="underline hover:text-white/80">
Providers
</a>{" "}
for first-class providers, how to point Claude at Anthropic-compatible endpoints (Z.AI,
Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the{" "}
<code className="font-mono">additionalModels</code> merge behavior. The full field
reference lives on GitHub at{" "}
<a
href={CUSTOM_PROVIDERS_URL}
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
docs/CUSTOM-PROVIDERS.md
</a>
.
<code className="font-mono">command.mode</code> can be{" "}
<code className="font-mono">default</code>, <code className="font-mono">append</code>, or{" "}
<code className="font-mono">replace</code>. Use <code className="font-mono">env</code> to
inject provider-specific environment variables.
</p>
<h3 className="text-lg font-medium">Enable Claude Code Chrome MCP</h3>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": {
"mode": "append",
"args": ["--chrome"]
}
}
}
}
}`}
</pre>
<h3 className="text-lg font-medium">
Point Claude to Anthropic-compatible endpoints (z.ai example)
</h3>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"env": {
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"ANTHROPIC_AUTH_TOKEN": "auth token",
"ANTHROPIC_API_KEY": ""
}
}
}
}
}`}
</pre>
<h3 className="text-lg font-medium">Run Claude through Docker</h3>
<p className="text-white/60 leading-relaxed">
Create a wrapper script that runs Claude in Docker, then tell Paseo to replace the Claude
launch command with that script.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": {
"mode": "replace",
"argv": ["/Users/you/bin/claude-docker"]
}
}
}
}
}`}
</pre>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`#!/usr/bin/env bash
set -euo pipefail
docker run --rm -i \\
-v "$PWD":"$PWD" \\
-w "$PWD" \\
-v "$HOME/.claude":"$HOME/.claude" \\
ghcr.io/anthropics/claude-code:latest \\
claude "$@"`}
</pre>
</section>
<section className="space-y-4">
@@ -148,11 +148,6 @@ function GettingStarted() {
Voice
</a>
</li>
<li>
<a href="/docs/providers" className="underline hover:text-white/80">
Providers
</a>
</li>
<li>
<a href="/docs/configuration" className="underline hover:text-white/80">
Configuration
@@ -1,345 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/providers")({
head: () => ({
meta: pageMeta(
"Providers - Paseo Docs",
"First-class agent providers in Paseo, and how to configure custom providers, ACP agents, and profiles.",
),
}),
component: Providers,
});
const CUSTOM_PROVIDERS_URL = "https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md";
function Providers() {
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-medium font-title mb-4">Providers</h1>
<p className="text-white/60 leading-relaxed">
A provider is an agent CLI that Paseo knows how to launch, stream, and control. Paseo
ships with first-class providers for the major coding agents, and lets you add your own
through <code className="font-mono">config.json</code> — either by pointing an existing
provider at a different API, adding extra profiles, or plugging in any{" "}
<a
href="https://agentclientprotocol.com"
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
ACP
</a>
-compatible agent.
</p>
</div>
<section className="space-y-4">
<h2 className="text-xl font-medium">First-class providers</h2>
<p className="text-white/60 leading-relaxed">
These work out of the box once the underlying CLI is installed and authenticated. Paseo
discovers them automatically, wires up modes, and exposes them in the app and CLI.
</p>
<ul className="text-white/60 space-y-3 list-disc list-inside">
<li>
<code className="font-mono">claude</code> — Anthropic's Claude Code. Multi-tool
assistant with MCP support, streaming, and deep reasoning.
</li>
<li>
<code className="font-mono">codex</code> — OpenAI's Codex workspace agent with sandbox
controls and optional network access.
</li>
<li>
<code className="font-mono">opencode</code> — Open-source coding assistant with
multi-provider model support.
</li>
<li>
<code className="font-mono">copilot</code> — GitHub Copilot via ACP, with dynamic modes
and session support.
</li>
<li>
<code className="font-mono">pi</code> — Minimal terminal-based coding agent with
multi-provider LLM support.
</li>
</ul>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Custom providers</h2>
<p className="text-white/60 leading-relaxed">
Everything beyond the defaults lives under{" "}
<code className="font-mono">agents.providers</code> in{" "}
<code className="font-mono">~/.paseo/config.json</code>. You can:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
<strong>Extend</strong> a first-class provider to point at a different API (Z.AI,
Alibaba/Qwen, a proxy, a self-hosted endpoint).
</li>
<li>
<strong>Add profiles</strong> — multiple entries against the same underlying provider
with different credentials or curated model lists.
</li>
<li>
<strong>Override the binary</strong> — run a nightly build, a wrapper script, or a
Docker image instead of the installed CLI.
</li>
<li>
<strong>Add ACP agents</strong> — Gemini CLI, Hermes, or any agent speaking the Agent
Client Protocol over stdio.
</li>
<li>
<strong>Disable</strong> a provider you don't use.
</li>
</ul>
<p className="text-white/60 leading-relaxed">
Provider IDs must be lowercase alphanumeric with hyphens (
<code className="font-mono">/^[a-z][a-z0-9-]*$/</code>). Every custom entry needs{" "}
<code className="font-mono">extends</code> (a first-class provider ID or{" "}
<code className="font-mono">"acp"</code>) and a <code className="font-mono">label</code>.
</p>
<p className="text-white/60 leading-relaxed">
The examples below are a quick tour. The full, up-to-date reference is on GitHub:{" "}
<a
href={CUSTOM_PROVIDERS_URL}
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
docs/CUSTOM-PROVIDERS.md
</a>
.
</p>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Extending a first-class provider</h2>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"my-claude": {
"extends": "claude",
"label": "My Claude",
"env": {
"ANTHROPIC_API_KEY": "sk-ant-...",
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
}
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Z.AI (GLM) coding plan</h2>
<p className="text-white/60 leading-relaxed">
Z.AI exposes GLM models through an Anthropic-compatible endpoint. Point{" "}
<code className="font-mono">ANTHROPIC_BASE_URL</code> at their API and use{" "}
<code className="font-mono">ANTHROPIC_AUTH_TOKEN</code> for the key. Third-party endpoints
don't support Anthropic's server-side tools, so disable{" "}
<code className="font-mono">WebSearch</code>.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"zai": {
"extends": "claude",
"label": "ZAI",
"env": {
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
"API_TIMEOUT_MS": "3000000"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
{ "id": "glm-5.1", "label": "GLM 5.1" }
]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Alibaba Cloud (Qwen) coding plan</h2>
<p className="text-white/60 leading-relaxed">
Alibaba's coding plan routes Claude Code to Qwen models via an Anthropic-compatible API.
Subscription keys look like <code className="font-mono">sk-sp-...</code> and must be
created in the Singapore region.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"qwen": {
"extends": "claude",
"label": "Qwen (Alibaba)",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
},
"disallowedTools": ["WebSearch"],
"models": [
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Multiple profiles</h2>
<p className="text-white/60 leading-relaxed">
Create as many entries as you want against the same first-class provider. Each one shows
up as a separate option in the app with its own credentials and models.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude-work": {
"extends": "claude",
"label": "Claude (Work)",
"env": { "ANTHROPIC_API_KEY": "sk-ant-work-..." }
},
"claude-personal": {
"extends": "claude",
"label": "Claude (Personal)",
"env": { "ANTHROPIC_API_KEY": "sk-ant-personal-..." }
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Custom binary</h2>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">command</code> is an array — first element is the binary, the
rest are arguments. It fully replaces the default launch command for that provider.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"claude": {
"command": ["/opt/claude-nightly/claude"]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">ACP providers</h2>
<p className="text-white/60 leading-relaxed">
Any agent that speaks{" "}
<a
href="https://agentclientprotocol.com"
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
ACP
</a>{" "}
over stdio can be added with <code className="font-mono">extends: "acp"</code> and a{" "}
<code className="font-mono">command</code>. Paseo spawns the process, sends an{" "}
<code className="font-mono">initialize</code> JSON-RPC request, and the agent reports its
capabilities, modes, and models at runtime.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"]
},
"hermes": {
"extends": "acp",
"label": "Hermes",
"command": ["hermes", "acp"]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Adding or relabeling models</h2>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">models</code> replaces the model list entirely.{" "}
<code className="font-mono">additionalModels</code> merges with runtime-discovered models
(ACP) or with <code className="font-mono">models</code> — use it to add an extra entry or
relabel a discovered one without redeclaring the full list. An entry with the same{" "}
<code className="font-mono">id</code> as a discovered model updates it in place.
</p>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"gemini": {
"extends": "acp",
"label": "Google Gemini",
"command": ["gemini", "--acp"],
"additionalModels": [
{ "id": "experimental-model", "label": "Experimental", "isDefault": true },
{ "id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro (preferred)" }
]
}
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Disabling a provider</h2>
<pre className="bg-card border border-border rounded-lg p-4 font-mono text-sm overflow-x-auto text-white/80">
{`{
"agents": {
"providers": {
"copilot": { "enabled": false }
}
}
}`}
</pre>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Full reference</h2>
<p className="text-white/60 leading-relaxed">
For the complete field reference (<code className="font-mono">extends</code>,{" "}
<code className="font-mono">label</code>, <code className="font-mono">command</code>,{" "}
<code className="font-mono">env</code>, <code className="font-mono">models</code>,{" "}
<code className="font-mono">additionalModels</code>,{" "}
<code className="font-mono">disallowedTools</code>,{" "}
<code className="font-mono">enabled</code>, <code className="font-mono">order</code>),
model and thinking-option schemas, and deeper examples for each plan, see{" "}
<a
href={CUSTOM_PROVIDERS_URL}
target="_blank"
rel="noreferrer"
className="underline hover:text-white/80"
>
docs/CUSTOM-PROVIDERS.md
</a>{" "}
on GitHub.
</p>
</section>
</div>
);
}
-1
View File
@@ -20,7 +20,6 @@ const sitemapPages = [
"/docs/best-practices",
"/docs/cli",
"/docs/configuration",
"/docs/providers",
"/docs/skills",
"/docs/security",
"/docs/updates",
@@ -139,19 +139,6 @@ function updateReleaseNotes({ releaseId, repo, notesPath }, execFileSync = nodeE
);
}
function exposeGitHubContributorMentions(notes) {
return notes.replace(
/\[@([A-Za-z0-9-]+)\]\(https:\/\/github\.com\/([A-Za-z0-9-]+)\/?\)/g,
(match, labelLogin, profileLogin) => {
if (labelLogin.toLowerCase() !== profileLogin.toLowerCase()) {
return match;
}
return `@${profileLogin}`;
},
);
}
export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
const execFileSync = deps.execFileSync ?? nodeExecFileSync;
const args = parseArgs(argv);
@@ -170,8 +157,6 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
return;
}
notes = exposeGitHubContributorMentions(notes);
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-"));
const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
writeFileSync(notesPath, notes);
@@ -1,15 +1,15 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { syncReleaseNotes } from "./sync-release-notes-from-changelog.mjs";
function withTempChangelog(fn, changelogText = "## 0.1.60-beta.1 - 2026-04-20\n\n- Beta notes.\n") {
function withTempChangelog(fn) {
const previousCwd = process.cwd();
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-test-"));
process.chdir(tempDir);
writeFileSync("CHANGELOG.md", changelogText);
writeFileSync("CHANGELOG.md", "## 0.1.60-beta.1 - 2026-04-20\n\n- Beta notes.\n");
try {
fn();
@@ -63,38 +63,3 @@ test("updates an existing release body through the release id API", () => {
);
});
});
test("converts contributor profile links to mentions in synced release notes", () => {
const changelogText = [
"## 0.1.60-beta.1 - 2026-04-20",
"",
"- Beta notes. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))",
"",
].join("\n");
withTempChangelog(() => {
let syncedNotes = "";
const execFileSync = (command, args) => {
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
return JSON.stringify({ id: 311163621 });
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
const notesArg = args.find((arg) => arg.startsWith("body=@"));
assert.ok(notesArg, "PATCH should send the notes body from a file");
syncedNotes = readFileSync(notesArg.slice("body=@".length), "utf8");
return "";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
execFileSync,
});
assert.match(syncedNotes, /by @therainisme\)/);
assert.doesNotMatch(syncedNotes, /\[@therainisme\]\(https:\/\/github\.com\/therainisme\)/);
}, changelogText);
});