mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c786e70b |
@@ -51,7 +51,7 @@ jobs:
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--notes ""
|
||||
--generate-notes
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Generated
+19
-19
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.62",
|
||||
"version": "0.1.60",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.62",
|
||||
"version": "0.1.60",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user