Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 215566bcb6 fix(server): parse pid start times in stable locale 2026-07-09 15:42:47 +00:00
Mohamed Boudra bc534ad09c fix(server): reclaim reused daemon pid locks 2026-07-09 15:30:45 +00:00
Matt CowgerandMohamed Boudra d96d599466 Show reasoning output automatically when enabled (#1943)
* feat(app): add auto-expand reasoning setting

* chore: add cross-env mise tool

* fix(app): stabilize web export and settings detail placement

* Revert "chore: add cross-env mise tool"

This reverts commit f56a367e0c.

* perf(app): narrow settings reads in agent stream

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-08 21:39:03 +00:00
Mohamed Boudra 29a3261dd6 Restore compact Changes diff controls (#1945)
* feat(app): restore compact Changes diff controls

Keep the folder tree behind a view toggle while preserving the flat file list as the default.

Move secondary diff options into the overflow menu so the Changes toolbar stays compact.

* fix(app): mirror whitespace diff option copy

* fix(app): polish diff controls review follow-up
2026-07-08 21:20:51 +00:00
Slava Goltser c7406a415d fix(app): handle CancelledError from cancelled provider usage queries (#1885)
Move canFetch guard before first await so disabled refresh bails out synchronously instead of entering async path. Add .catch() swallow for fire-and-forget call to prevent unhandled promise rejections when react-query cancels pending queries during rapid tooltip open/close cycles.
2026-07-09 05:00:57 +08:00
Slava Goltser 202c417347 fix(pi): add get_session_stats fallback from get_state for old OMP binaries (#1886)
* fix(pi): add get_session_stats fallback from get_state for old OMP binaries

Older Oh My Pi binaries don't support the get_session_stats RPC command,
leaving session usage stats blank in Paseo. This adds a compat layer in
cli-runtime.ts where getSessionStats() tries get_session_stats first, then
falls back to extracting context window usage from get_state.

The fallback provides contextUsage.tokens and contextWindow even when the
binary lacks full token/cost reporting — enough to keep the context meter
working. Token counts and cost still require the newer RPC.

Added three tests:
- Fallback triggers when get_session_stats is unsupported (throws)
- No fallback when get_session_stats returns valid data
- Returns empty object when both commands fail

* fix(pi): use nullish check instead of truthy for cost field in getSessionStats
2026-07-09 05:00:17 +08:00
Christoph LeiterandClaude Opus 4.8 3259a69466 feat(app): folder tree in the Changes view (#1918)
Group changed files in the Changes view into a collapsible directory
tree instead of a flat list, on every form factor — consistent with the
Files explorer, which already renders a directory tree on mobile.
Folders are collapsible and expanded by default, single-child directory
chains are compressed into one row (packages/app/src), and folder rows
show aggregate +/- stats.

Client-only, built entirely from the existing ParsedDiffFile paths:

- diff-tree.ts: pure build/compress/flatten of the directory hierarchy,
  keyed by full uncompressed dir path so collapse state survives
  compression changes as the diff mutates.
- diff-flat-items.ts: pure buildDiffFlatItems + sumHeightsBefore. The
  function retains a flat mode (showTree=false) as a tested seam for a
  possible future flat/tree toggle; sticky header indices are
  file-headers-only, derived from the post-collapse list.
- panel-store: diffCollapsedFoldersByWorkspace slice (collapsed-set, so
  folders default to expanded), persisted via version 12. The set is
  reconciled against the current diff so folders that leave and reappear
  default to expanded and the stored set stays bounded.

Presentation primitives (chevron, indent guides, indent constant) are
extracted into components/tree-primitives and shared with the Files
explorer, which drops its private copies (and a banned useUnistyles
call site). The two trees now render identical rows; only the
tree-building stays separate (server listings vs. diff paths).

The toolbar's expand-all stays file-diffs-only (renamed
allFileDiffsExpanded); a separate control collapses/expands all folders.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:55:30 +08:00
Mohamed Boudra cab0e4b3f8 Show a recovery screen for app render errors (#1924)
* fix(app): show recovery screen for render failures

Render a retryable fallback with useful error details when the routed app tree throws during render instead of leaving the app blank.

* test(app): cover root error details without mounting

Move caught-value formatting into a pure module, cover AggregateError fields without duplicate output, and remove the component-mounted test.

* fix(app): keep empty error details caught

* fix(app): preserve nullable aggregate errors

* fix(app): guard malformed error details

* fix(app): handle recursive error causes

* fix(app): guard error detail formatting failures

* fix(app): localize root error fallback

* fix(app): keep root error fallback on existing i18n
2026-07-09 00:06:29 +08:00
Mohamed Boudra 6ca1f4dac5 docs: browser is desktop-only, drop phone mention 2026-07-08 13:59:27 +02:00
Mohamed Boudra 6f3ab98986 docs: restructure browser IA, add 'when to use' page 2026-07-08 13:56:57 +02:00
Mohamed Boudra 251d88a37a Stop Android audio interruptions crashing voice mode (#1941)
* fix(android): handle denied voice audio focus

Android can deny voice-mode audio focus while another system audio owner, such as an incoming call, is active. Treat that as an interruption so resume does not crash the app and JS stops voice mode coherently.

* fix(voice): keep interruption state consistent

Make native interruption handling payload-aware so iOS resume events do not stop the shared JS runtime, avoid ending voice mode for duck-only Android focus changes, and roll host voice mode back if capture fails after startup enabled it.

* fix(voice): avoid duplicate interruption handling

Keep exactly one Android audio-focus request active at a time and leave JS capture state untouched for non-terminal interruption events.

* fix(android): abandon blocked audio focus requests

When Android denies or blocks voice audio focus, abandon the pending focus request so delayed focus gain cannot arrive after voice mode has already stopped.

* fix(voice): handle interrupted dictation capture

Stop Android resume from replaying after recording restart fails, and propagate native blocked interruptions into dictation so users do not remain in a stale recording state.
2026-07-08 19:45:02 +08:00
Mohamed Boudra c72bcb49e5 docs: restructure schedules IA (app-first), add Orchestration category 2026-07-08 13:43:59 +02:00
paseo-ai[bot] 1a03171f0c fix: update lockfile signatures and Nix hash [skip ci] 2026-07-08 10:52:05 +00:00
Mohamed Boudra f3fdeab131 chore(release): cut 0.1.104 2026-07-08 12:47:07 +02:00
Mohamed Boudra fdc6a4a57d docs: changelog for v0.1.104 2026-07-08 12:44:08 +02:00
Mohamed Boudra ed67e6e0f1 fix(app): preserve renamed host labels (#1940)
Daemon-advertised hostnames still name new/default hosts, but connection refreshes and re-pairing no longer overwrite labels that users already changed.
2026-07-08 10:20:06 +00:00
Mohamed Boudra 5ec7ef9771 perf(agent-tools): shrink MCP tool catalog context cost (#1939)
Drop advertised MCP output schemas (no supported harness forwards them to the model) and remove the redundant wait_for_agent tool. Core agent tool catalog drops ~20.5k -> ~5.6k estimated tokens; ~24.9k -> ~9k with browser tools. Runtime structuredContent is preserved via server-side text serialization. Also corrects the agent-wait timeout guidance for blocking callers.
2026-07-08 11:39:39 +02:00
Mohamed Boudra 4fb70668f2 Manage the built-in daemon from one place in settings (#1938)
* feat(settings): consolidate daemon management into the host page

The app-level Daemon section and the localhost host page rendered the
same daemon card in two places. The host page is now the single home:
the Host sidebar group leads with Overview, and a desktop-only "Enable
built-in daemon" option in the host switcher recovers a removed
localhost connection. Old settings/daemon deep links redirect
(COMPAT-tagged).

* fix(settings): hide daemon recovery option when the server id probe fails

Review feedback: !query.isLoading treated an errored IPC probe as
resolved, so the recovery affordance could show without knowing the
local server id. Replace the resolved-boolean hook with a single
discriminated state hook (loading | error | resolved) — errors hide the
affordance and fall back to the settings root in the redirect. Also
collapses the two query subscriptions per caller into one.

* fix(settings): keep daemon recovery reachable when no hosts remain

Review feedback: removing localhost as the only host takes the settings
sidebar down its no-host branch, which never mounts the host picker —
so the "Enable built-in daemon" option was unreachable in the most
common recovery case. The sidebar now owns the single hook call, passes
it to the picker, and renders the same action as a sidebar row next to
"Add host" when no hosts exist.

* test(e2e): update settings host header to Overview

Test drift: the host section's sidebar/header label was intentionally
renamed to "Overview"; the spec still asserted the old "Host" title.
Also assert the retired app-level Daemon row stays absent.
2026-07-08 10:20:30 +02:00
paseo-ai[bot] 06c3161008 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-07 21:06:43 +00:00
96 changed files with 3445 additions and 923 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.1.104-beta.5 - 2026-07-07
## 0.1.104 - 2026-07-08
### Added
@@ -14,15 +14,18 @@
### Improved
- Manage the built-in daemon from one place in desktop settings ([#1938](https://github.com/getpaseo/paseo/pull/1938))
- Scheduled and loop runs each get their own workspace in the sidebar ([#1909](https://github.com/getpaseo/paseo/pull/1909), [#1934](https://github.com/getpaseo/paseo/pull/1934))
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
- Diagnostic reports include desktop app logs ([#1914](https://github.com/getpaseo/paseo/pull/1914))
- Paseo's built-in tools take less context ([#1939](https://github.com/getpaseo/paseo/pull/1939))
### Fixed
- Renamed hosts keep their name after reconnecting ([#1940](https://github.com/getpaseo/paseo/pull/1940))
- Desktop finds your installed CLIs even when your shell is slow to start ([#1916](https://github.com/getpaseo/paseo/pull/1916))
- Restarting the daemon from desktop settings works reliably ([#1915](https://github.com/getpaseo/paseo/pull/1915))
- Restarting the daemon from the bundled CLI keeps it managed by the desktop app ([#1919](https://github.com/getpaseo/paseo/pull/1919))
+15
View File
@@ -173,6 +173,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Agent Tool Catalog Measurement
Measure the MCP `tools/list` payload that Paseo injects into agents with:
```bash
npm run measure:agent-tools --workspace=@getpaseo/server
```
The command reports compact JSON bytes, estimated tokens, field totals, largest
tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use
`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json`
for machine-readable output.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
@@ -290,6 +303,8 @@ npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-aud
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
The app Metro config disables Watchman and uses Metro's node crawler for exports. Keep that invariant unless you have verified production app exports on machines with and without Watchman installed; distro Watchman builds can differ in capabilities and change Metro's crawl behavior.
For tighter loops, you can rebuild a single workspace:
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
+13
View File
@@ -198,6 +198,19 @@ for i in $(seq 1 "$ITERATIONS"); do
done
```
### Android audio focus interruptions
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
```bash
adb shell am start -n sh.paseo/.MainActivity
# Start voice mode in an existing composer, then background Paseo with Home.
adb emu gsm call 5551234
# Foreground Paseo while the call is still ringing.
```
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
## Unistyles + Reanimated
### The crash
+1 -1
View File
@@ -1 +1 @@
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
sha256-RtZABQl17BlUyvFiFw9W5g2E/dpNlrOWpuCdV8wZk5o=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36170,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.5",
"@getpaseo/protocol": "0.1.104-beta.5",
"@getpaseo/server": "0.1.104-beta.5",
"@getpaseo/client": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/server": "0.1.104",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36421,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.5",
"@getpaseo/relay": "0.1.104-beta.5",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36678,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37806,7 +37806,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.5",
"@getpaseo/highlight": "0.1.104-beta.5",
"@getpaseo/protocol": "0.1.104-beta.5",
"@getpaseo/relay": "0.1.104-beta.5",
"@getpaseo/client": "0.1.104",
"@getpaseo/highlight": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38582,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+44 -1
View File
@@ -218,6 +218,38 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click();
await expectFlatFileList(page);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -266,13 +298,24 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {
+1
View File
@@ -371,6 +371,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
+1 -1
View File
@@ -64,7 +64,7 @@ test.describe("Settings host page", () => {
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Host");
await expectSettingsHeader(page, "Overview");
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
});
+6
View File
@@ -14,6 +14,12 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
// Keep app exports deterministic across dev machines and CI. Metro's Watchman
// crawler behavior depends on the host Watchman build/capabilities, while the
// node crawler is the path used when Watchman is absent.
config.resolver.useWatchman = false;
const escapedAppSrcRoot = appSrcRoot
.split(path.sep)
.map((segment) => segment.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"private": true,
"main": "index.ts",
"scripts": {
+5 -1
View File
@@ -52,6 +52,7 @@ import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
@@ -318,6 +319,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) {
const { t } = useTranslation();
const router = useRouter();
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -633,10 +635,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={layoutItem.isLastInToolSequence}
defaultExpanded={autoExpandReasoning}
forceInline={autoExpandReasoning}
/>
);
},
[setInlineDetailsExpanded],
[autoExpandReasoning, setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(
+25 -16
View File
@@ -37,6 +37,7 @@ import { LeftSidebar } from "@/components/left-sidebar";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
@@ -998,31 +999,27 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
);
}
// PortalProvider must stay inside normal app-wide context providers here.
// PortalProvider must stay inside normal app-wide context providers.
// `@gorhom/portal` renders portaled children at the host's location in the
// tree, so any context a portaled sheet might consume (QueryClient, theme,
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootProviders({ children }: { children: ReactNode }) {
return (
<QueryProvider>
<I18nProvider>
<SafeAreaProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
</I18nProvider>
</QueryProvider>
<SafeAreaProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</SafeAreaProvider>
);
}
export default function RootLayout() {
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
@@ -1036,6 +1033,18 @@ export default function RootLayout() {
);
}
export default function RootLayout() {
return (
<QueryProvider>
<I18nProvider>
<RootErrorBoundary>
<RootAppTree />
</RootErrorBoundary>
</I18nProvider>
</QueryProvider>
);
}
const layoutStyles = StyleSheet.create((theme) => ({
surfaceFill: {
flex: 1,
+42 -2
View File
@@ -1,7 +1,38 @@
import { useLocalSearchParams } from "expo-router";
import { Redirect, useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
buildSettingsHostSectionRoute,
buildSettingsRoute,
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
function SettingsDaemonRedirect() {
const hosts = useHosts();
const localDaemon = useLocalDaemonServerIdState();
const bootstrapState = useHostRuntimeBootstrapState();
if (localDaemon.status === "loading") {
return <StartupSplashScreen bootstrapState={bootstrapState} />;
}
if (
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId)
) {
return <Redirect href={buildSettingsHostSectionRoute(localDaemon.serverId, "host")} />;
}
return <Redirect href={buildSettingsRoute()} />;
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
@@ -10,5 +41,14 @@ export default function SettingsSectionRoute() {
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
if (rawSection === "daemon") {
return (
<HostRouteBootstrapBoundary>
<SettingsDaemonRedirect />
</HostRouteBootstrapBoundary>
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}
@@ -117,7 +117,7 @@ export function ContextWindowMeter({
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage();
void refreshProviderUsage().catch(() => {});
}
},
[refreshProviderUsage],
@@ -19,7 +19,6 @@ import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
Eye,
@@ -28,6 +27,7 @@ import {
RotateCw,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
@@ -55,8 +55,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "size" },
];
const INDENT_PER_LEVEL = 16;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -129,7 +127,7 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected, theme.spacing],
@@ -143,11 +141,6 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const chevronStyle = useMemo(
() => [styles.chevron, isExpanded && styles.chevronExpanded],
[isExpanded],
);
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
@@ -159,7 +152,7 @@ function TreeRowItem({
return (
<Pressable onPress={handlePress} style={pressableStyle}>
{depth > 0 && Array.from({ length: depth }, (_, i) => <IndentGuide key={i} index={i} />)}
<TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
@@ -167,11 +160,7 @@ function TreeRowItem({
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
}
if (loading) return <ActivityIndicator size="small" />;
return (
<View style={chevronStyle}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
);
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -1149,13 +1138,6 @@ const styles = StyleSheet.create((theme) => ({
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
entryInfo: {
flex: 1,
flexDirection: "row",
@@ -1163,16 +1145,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: {
flexShrink: 0,
},
@@ -1302,16 +1274,3 @@ const styles = StyleSheet.create((theme) => ({
}));
const TREE_PANE_CONTAINER_STYLE = [styles.treePane, styles.treePaneFill];
interface IndentGuideProps {
index: number;
}
function IndentGuide({ index }: IndentGuideProps) {
const { theme } = useUnistyles();
const guideStyle = useMemo(
() => [styles.indentGuide, { left: theme.spacing[3] + index * INDENT_PER_LEVEL + 4 }],
[index, theme.spacing],
);
return <View style={guideStyle} />;
}
@@ -1,5 +1,6 @@
export const ADD_HOST_OPTION_ID = "__add_host__";
export const ALL_HOSTS_OPTION_ID = "__all_hosts__";
export const ENABLE_BUILT_IN_DAEMON_OPTION_ID = "__enable_built_in_daemon__";
export function getHostPickerLabel(
hosts: Array<{ label: string; serverId: string }>,
@@ -11,10 +11,16 @@ import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
getHostPickerLabel,
} from "./host-picker-constants";
export { ADD_HOST_OPTION_ID, ALL_HOSTS_OPTION_ID, getHostPickerLabel };
export {
ADD_HOST_OPTION_ID,
ALL_HOSTS_OPTION_ID,
ENABLE_BUILT_IN_DAEMON_OPTION_ID,
getHostPickerLabel,
};
const SEARCHABLE_THRESHOLD = 10;
type RenderHostOption = NonNullable<ComboboxProps["renderOption"]>;
@@ -115,6 +121,12 @@ export function HostPickerOption({
);
}
const SYSTEM_HOST_PICKER_OPTION_LABELS: Record<"add" | "all" | "enableBuiltInDaemon", string> = {
add: "Add host",
all: "All hosts",
enableBuiltInDaemon: "Enable built-in daemon",
};
function SystemHostPickerOption({
active,
selected,
@@ -125,12 +137,12 @@ function SystemHostPickerOption({
active: boolean;
selected?: boolean;
onPress: () => void;
kind: "add" | "all";
kind: "add" | "all" | "enableBuiltInDaemon";
testID?: string;
}): ReactElement {
const { theme } = useUnistyles();
const Icon = kind === "add" ? Plus : Server;
const label = kind === "add" ? "Add host" : "All hosts";
const label = SYSTEM_HOST_PICKER_OPTION_LABELS[kind];
const leadingSlot = useMemo(
() => <Icon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[Icon, theme.colors.foregroundMuted, theme.iconSize.sm],
@@ -158,6 +170,8 @@ export interface HostPickerProps {
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
includeEnableBuiltInDaemon?: boolean;
onEnableBuiltInDaemon?: () => void;
showActiveConnection?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
@@ -179,6 +193,8 @@ export function HostPicker({
includeAllHost,
includeAddHost,
onAddHost,
includeEnableBuiltInDaemon,
onEnableBuiltInDaemon,
showActiveConnection,
onOpenHostSettings,
searchable,
@@ -199,8 +215,13 @@ export function HostPicker({
const hostOptions = orderedHosts.map((host) => ({ id: host.serverId, label: host.label }));
if (includeAllHost) hostOptions.unshift({ id: ALL_HOSTS_OPTION_ID, label: "All hosts" });
if (includeAddHost) hostOptions.push({ id: ADD_HOST_OPTION_ID, label: "Add host" });
if (includeEnableBuiltInDaemon)
hostOptions.push({
id: ENABLE_BUILT_IN_DAEMON_OPTION_ID,
label: "Enable built-in daemon",
});
return hostOptions;
}, [orderedHosts, includeAllHost, includeAddHost]);
}, [orderedHosts, includeAllHost, includeAddHost, includeEnableBuiltInDaemon]);
const isSearchable = searchable === true && orderedHosts.length > SEARCHABLE_THRESHOLD;
@@ -208,12 +229,14 @@ export function HostPicker({
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost?.();
} else if (id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
onEnableBuiltInDaemon?.();
} else {
onSelect(id);
}
onOpenChange(false);
},
[onAddHost, onOpenChange, onSelect],
[onAddHost, onEnableBuiltInDaemon, onOpenChange, onSelect],
);
const handleOpenHostSettings = useCallback(
@@ -246,6 +269,11 @@ export function HostPicker({
/>
);
}
if (option.id === ENABLE_BUILT_IN_DAEMON_OPTION_ID) {
return (
<SystemHostPickerOption kind="enableBuiltInDaemon" active={active} onPress={onPress} />
);
}
return (
<HostPickerOption
serverId={option.id}
+18 -11
View File
@@ -3038,6 +3038,8 @@ interface ToolCallProps {
onInlineDetailsHoverChange?: (hovered: boolean) => void;
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
onOpenFilePath?: (filePath: string) => void;
defaultExpanded?: boolean;
forceInline?: boolean;
}
export const ToolCall = memo(function ToolCall({
@@ -3054,11 +3056,14 @@ export const ToolCall = memo(function ToolCall({
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
defaultExpanded,
forceInline = false,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
const isMobile = useIsCompactFormFactor();
const shouldRenderInline = !isMobile || forceInline;
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
if (detail) {
@@ -3096,7 +3101,7 @@ export const ToolCall = memo(function ToolCall({
}, [presentation.openFilePath, onOpenFilePath]);
const handleToggle = useCallback(() => {
if (isMobile) {
if (!shouldRenderInline) {
openToolCall({
displayName: presentation.displayName,
summary: presentation.summary,
@@ -3109,7 +3114,7 @@ export const ToolCall = memo(function ToolCall({
setIsExpanded((prev) => !prev);
}
}, [
isMobile,
shouldRenderInline,
openToolCall,
presentation.displayName,
presentation.summary,
@@ -3120,22 +3125,22 @@ export const ToolCall = memo(function ToolCall({
]);
useEffect(() => {
if (!onInlineDetailsHoverChange || isMobile || isExpanded) {
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
return;
}
onInlineDetailsHoverChange(false);
}, [isExpanded, isMobile, onInlineDetailsHoverChange]);
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
useEffect(() => {
if (!onInlineDetailsExpandedChange) {
return;
}
if (isMobile) {
if (!shouldRenderInline) {
onInlineDetailsExpandedChange(false);
return;
}
onInlineDetailsExpandedChange(isExpanded);
}, [isExpanded, isMobile, onInlineDetailsExpandedChange]);
}, [isExpanded, shouldRenderInline, onInlineDetailsExpandedChange]);
useEffect(() => {
if (!onInlineDetailsExpandedChange) {
@@ -3148,7 +3153,7 @@ export const ToolCall = memo(function ToolCall({
// Render inline details for desktop
const renderDetails = useCallback(() => {
if (isMobile) return null;
if (!shouldRenderInline) return null;
return (
<ToolCallDetailsContent
detail={effectiveDetail}
@@ -3157,7 +3162,7 @@ export const ToolCall = memo(function ToolCall({
showLoadingSkeleton={presentation.isLoadingDetails}
/>
);
}, [isMobile, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
}, [shouldRenderInline, effectiveDetail, presentation.errorText, presentation.isLoadingDetails]);
if (presentation.isPlan && effectiveDetail?.type === "plan") {
return (
@@ -3175,10 +3180,10 @@ export const ToolCall = memo(function ToolCall({
label={presentation.displayName}
secondaryLabel={presentation.summary}
icon={presentation.icon}
isExpanded={!isMobile && isExpanded}
isExpanded={shouldRenderInline && isExpanded}
onToggle={presentation.canOpenDetails ? handleToggle : undefined}
onOpenFile={handleOpenFile}
renderDetails={presentation.canOpenDetails && !isMobile ? renderDetails : undefined}
renderDetails={presentation.canOpenDetails && shouldRenderInline ? renderDetails : undefined}
isLoading={status === "running" || status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}
@@ -3200,5 +3205,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.isLastInSequence !== next.isLastInSequence) return false;
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
if (previous.onOpenFilePath !== next.onOpenFilePath) return false;
if (previous.defaultExpanded !== next.defaultExpanded) return false;
if (previous.forceInline !== next.forceInline) return false;
return true;
}
@@ -0,0 +1,157 @@
import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import type { PressableStateCallbackType, StyleProp, ViewStyle } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { formatCaughtValue } from "./root-error-details";
interface RootErrorBoundaryProps {
children: ReactNode;
}
interface RootErrorBoundaryState {
error: string | null;
resetKey: number;
}
export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErrorBoundaryState> {
state: RootErrorBoundaryState = {
error: null,
resetKey: 0,
};
static getDerivedStateFromError(error: unknown): Partial<RootErrorBoundaryState> {
return { error: formatCaughtValue(error) };
}
componentDidCatch(error: unknown, errorInfo: ErrorInfo) {
console.error("[RootErrorBoundary] Unhandled render error", {
error: formatCaughtValue(error),
componentStack: errorInfo.componentStack,
});
}
retry = () => {
this.setState(({ resetKey }) => ({
error: null,
resetKey: resetKey + 1,
}));
};
render() {
const { error, resetKey } = this.state;
if (error !== null) {
return <RootErrorFallback error={error} onRetry={this.retry} />;
}
return <Fragment key={resetKey}>{this.props.children}</Fragment>;
}
}
interface RootErrorFallbackProps {
error: string;
onRetry: () => void;
}
function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
const { t } = useTranslation();
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
testID="root-error-boundary"
>
<View style={styles.content}>
<Text style={styles.kicker}>{t("rootError.kicker")}</Text>
<Text style={styles.title}>{t("rootError.title")}</Text>
<Text style={styles.body}>{t("rootError.body")}</Text>
<View style={styles.messageBox}>
<Text style={styles.messageLabel}>{t("rootError.details")}</Text>
<Text style={styles.message}>{error}</Text>
</View>
<Pressable
accessibilityRole="button"
onPress={onRetry}
style={retryButtonStyle}
testID="root-error-boundary-retry"
>
<Text style={styles.retryButtonText}>{t("common.actions.retry")}</Text>
</Pressable>
</View>
</ScrollView>
);
}
function retryButtonStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
return [styles.retryButton, pressed ? styles.retryButtonPressed : null];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
content: {
alignSelf: "center",
width: "100%",
maxWidth: 520,
gap: theme.spacing[4],
},
kicker: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
},
body: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
messageBox: {
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[4],
},
messageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
message: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
retryButton: {
alignSelf: "flex-start",
minHeight: 40,
justifyContent: "center",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.accent,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
},
retryButtonPressed: {
opacity: 0.85,
},
retryButtonText: {
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
}));
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { formatCaughtValue } from "./root-error-details";
describe("formatCaughtValue", () => {
it("preserves details for Error values", () => {
class RouteRenderError extends Error {
code = "E_ROUTE_RENDER";
cause = "workspace route";
constructor() {
super("route render exploded");
this.name = "RouteRenderError";
this.stack = "RouteRenderError: route render exploded\n at WorkspaceRoute";
}
}
const details = formatCaughtValue(new RouteRenderError());
expect(details).toContain("Name: RouteRenderError");
expect(details).toContain("Message: route render exploded");
expect(details).toContain("Stack:");
expect(details).toContain("RouteRenderError: route render exploded");
expect(details).toContain("Cause:");
expect(details).toContain("workspace route");
expect(details).toContain("E_ROUTE_RENDER");
});
it("does not duplicate aggregate errors as custom fields", () => {
const error = new AggregateError([new Error("first failure")], "multiple failures");
const details = formatCaughtValue(error);
expect(details).toContain("Errors:");
expect(details).toContain("first failure");
expect(details).not.toContain("Fields:");
});
it("preserves null aggregate error values", () => {
class ErrorWithNullableErrors extends Error {
errors = null;
}
const details = formatCaughtValue(new ErrorWithNullableErrors("nullable errors"));
expect(details).toContain("Errors:\nnull");
expect(details).not.toContain("Fields:");
});
it("does not throw for malformed Error text fields", () => {
const error = new Error("fallback");
Object.defineProperties(error, {
name: { configurable: true, value: null },
message: { configurable: true, value: 42 },
stack: { configurable: true, value: { frame: "bad stack" } },
});
const details = formatCaughtValue(error);
expect(details).toContain("Name: null");
expect(details).toContain("Message: 42");
expect(details).toContain('"frame": "bad stack"');
});
it("marks recursive Error causes", () => {
const error = new Error("self cause");
Object.defineProperty(error, "cause", { configurable: true, value: error });
const details = formatCaughtValue(error);
expect(details).toContain("Cause:\n[Circular Error]");
});
it("returns fallback details when Error properties throw", () => {
const error = new Error("fallback");
Object.defineProperty(error, "message", {
configurable: true,
get() {
throw new Error("bad message getter");
},
});
const details = formatCaughtValue(error);
expect(details).toContain("[Unserializable value]");
expect(details).toContain("Details unavailable:");
expect(details).toContain("Error: bad message getter");
});
it("renders string thrown values as the string", () => {
expect(formatCaughtValue("plain failure")).toBe("plain failure");
});
it("preserves empty string thrown values", () => {
expect(formatCaughtValue("")).toBe("");
});
it("renders numeric thrown values without extra category text", () => {
const details = formatCaughtValue(42);
expect(details).toBe("42");
expect(details).not.toContain("non-Error");
});
it("renders circular objects as JSON with circular markers", () => {
const value: { label: string; self?: unknown } = { label: "loop" };
value.self = value;
expect(formatCaughtValue(value)).toBe('{\n "label": "loop",\n "self": "[Circular]"\n}');
});
});
@@ -0,0 +1,155 @@
export function formatCaughtValue(value: unknown): string {
try {
return formatCaughtValueWithSeenErrors(value, new WeakSet<Error>());
} catch (formattingError) {
return formatFormattingFailure(value, formattingError);
}
}
function formatCaughtValueWithSeenErrors(value: unknown, seenErrors: WeakSet<Error>): string {
if (value instanceof Error) {
return formatError(value, seenErrors);
}
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return safeString(value);
}
if (typeof value !== "object" && typeof value !== "function") {
return safeString(value);
}
return stringifyJson(value, seenErrors) ?? safeString(value);
}
function formatError(error: Error, seenErrors: WeakSet<Error>): string {
if (seenErrors.has(error)) {
return "[Circular Error]";
}
seenErrors.add(error);
const sections: string[] = [];
const name = formatErrorTextProperty(Reflect.get(error, "name"), seenErrors);
const message = formatErrorTextProperty(Reflect.get(error, "message"), seenErrors);
const stack = formatErrorTextProperty(Reflect.get(error, "stack"), seenErrors);
if (name) {
sections.push(`Name: ${name}`);
}
if (message) {
sections.push(`Message: ${message}`);
}
if (stack) {
sections.push(`Stack:\n${stack}`);
}
const errorCause = getErrorCause(error);
if (errorCause.hasCause) {
sections.push(`Cause:\n${formatCaughtValueWithSeenErrors(errorCause.value, seenErrors)}`);
}
const aggregateErrors = getAggregateErrors(error);
if (aggregateErrors.hasErrors) {
sections.push(`Errors:\n${formatCaughtValueWithSeenErrors(aggregateErrors.value, seenErrors)}`);
}
const fields = getErrorFields(error);
if (fields !== null) {
sections.push(`Fields:\n${stringifyJson(fields, seenErrors) ?? safeString(fields)}`);
}
seenErrors.delete(error);
return sections.join("\n\n") || safeString(error);
}
function formatErrorTextProperty(value: unknown, seenErrors: WeakSet<Error>): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || null;
}
if (value === undefined) {
return null;
}
return stringifyJson(value, seenErrors) ?? safeString(value);
}
function getErrorCause(error: Error): { hasCause: boolean; value: unknown } {
if (!Reflect.has(error, "cause")) {
return { hasCause: false, value: null };
}
return { hasCause: true, value: Reflect.get(error, "cause") };
}
function getAggregateErrors(error: Error): { hasErrors: boolean; value: unknown } {
if (!Reflect.has(error, "errors")) {
return { hasErrors: false, value: null };
}
return { hasErrors: true, value: Reflect.get(error, "errors") };
}
function getErrorFields(error: Error): Record<string, unknown> | null {
const fields: Record<string, unknown> = {};
for (const key of Object.keys(error)) {
if (
key === "name" ||
key === "message" ||
key === "stack" ||
key === "cause" ||
key === "errors"
) {
continue;
}
fields[key] = Reflect.get(error, key);
}
return Object.keys(fields).length > 0 ? fields : null;
}
function stringifyJson(value: unknown, seenErrors: WeakSet<Error>): string | null {
const seen = new WeakSet<object>();
try {
const serialized = JSON.stringify(
value,
(_key, nestedValue: unknown) => {
if (nestedValue instanceof Error) {
return formatError(nestedValue, seenErrors);
}
if (typeof nestedValue === "bigint") {
return String(nestedValue);
}
if (nestedValue !== null && typeof nestedValue === "object") {
if (seen.has(nestedValue)) {
return "[Circular]";
}
seen.add(nestedValue);
}
return nestedValue;
},
2,
);
return typeof serialized === "string" ? serialized : null;
} catch {
return null;
}
}
function safeString(value: unknown): string {
try {
return String(value);
} catch {
return "[Unserializable value]";
}
}
function formatFormattingFailure(value: unknown, formattingError: unknown): string {
const valueText = safeString(value);
const formattingErrorText = safeString(formattingError);
if (formattingErrorText === "[Unserializable value]") {
return valueText;
}
return `${valueText}\n\nDetails unavailable:\n${formattingErrorText}`;
}
@@ -0,0 +1,82 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { SPACING, type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// Shared presentation primitives for the app's directory trees. Both the Files
// explorer (server-loaded listings) and the Changes view (client-built from diff
// paths) render different data, but their ROWS should look identical — same
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
}
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedChevronRight = withUnistyles(ChevronRight);
/**
* Vertical guide lines connecting nested rows to their ancestors — one line per
* ancestor depth level, positioned absolutely within the (relative) row. Renders
* nothing at depth 0.
*/
export function TreeIndentGuides({ depth }: { depth: number }) {
const guides = useMemo(
() =>
Array.from({ length: depth }, (_, index) => ({
key: index,
style: [
styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
],
})),
[depth],
);
return (
<>
{guides.map((guide) => (
<View key={guide.key} style={guide.style} pointerEvents="none" />
))}
</>
);
}
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) {
return (
<View style={expanded ? CHEVRON_EXPANDED_STYLE : styles.chevron}>
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
const styles = StyleSheet.create((theme: Theme) => ({
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
}));
// Stable module-level style ref so TreeChevron passes a constant array, not one created
// per render — satisfies react-perf (no inline-array prop) without a per-render useMemo.
const CHEVRON_EXPANDED_STYLE = [styles.chevron, styles.chevronExpanded];
@@ -125,6 +125,11 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
onVolumeLevel: (level) => {
runtime?.handleCaptureVolume(level);
},
onInterruption: () => {
void runtime?.stopVoice().catch((error) => {
console.error("[VoiceEngine] Failed to stop after audio interruption:", error);
});
},
onError: (error) => {
console.error("[VoiceEngine] Capture error:", error);
},
@@ -34,6 +34,7 @@ interface UseBuiltInDaemonManagementInput {
interface UseBuiltInDaemonManagementResult {
isUpdating: boolean;
toggle: () => void;
enable: () => Promise<DaemonManagementToggleResult | null>;
}
export function useBuiltInDaemonManagement(
@@ -42,12 +43,16 @@ export function useBuiltInDaemonManagement(
const { t } = useTranslation();
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
DaemonManagementToggleResult,
Error
>({
mutationFn: async () => {
const wasManagingDaemon = settings.manageBuiltInDaemon;
const {
mutate: toggleDaemonManagement,
mutateAsync: toggleDaemonManagementAsync,
isPending: isUpdating,
} = useMutation<DaemonManagementToggleResult, Error, { forceEnable: boolean }>({
mutationFn: async ({ forceEnable }) => {
// forceEnable takes the enable branch regardless of the persisted
// setting — the recovery affordance must never reach the stop/confirm
// path even if manageBuiltInDaemon was left true.
const wasManagingDaemon = forceEnable ? false : settings.manageBuiltInDaemon;
try {
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
confirm: () =>
@@ -109,8 +114,21 @@ export function useBuiltInDaemonManagement(
return;
}
toggleDaemonManagement();
toggleDaemonManagement({ forceEnable: false });
}, [isUpdating, toggleDaemonManagement]);
return { isUpdating, toggle };
const enable = useCallback(async () => {
if (isUpdating) {
return null;
}
try {
return await toggleDaemonManagementAsync({ forceEnable: true });
} catch {
// onError has already surfaced the failure; callers only act on success.
return null;
}
}, [isUpdating, toggleDaemonManagementAsync]);
return { isUpdating, toggle, enable };
}
@@ -0,0 +1,46 @@
import { useCallback } from "react";
import { router } from "expo-router";
import { getIsElectron } from "@/constants/platform";
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { useHosts } from "@/runtime/host-runtime";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
export interface EnableBuiltInDaemonOption {
visible: boolean;
onPress: () => void;
}
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
const isElectron = getIsElectron();
const localDaemon = useLocalDaemonServerIdState();
const hosts = useHosts();
const { settings, updateSettings } = useDesktopSettings();
const { data, setStatus, refetch } = useDaemonStatus();
const { enable } = useBuiltInDaemonManagement({
daemonStatus: data?.status ?? null,
settings: settings.daemon,
updateSettings: (updates) => updateSettings({ daemon: updates }),
setStatus,
refreshStatus: refetch,
});
const isLocalhostConfigured =
localDaemon.status === "resolved" &&
localDaemon.serverId !== null &&
hosts.some((host) => host.serverId === localDaemon.serverId);
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
const onPress = useCallback(() => {
void (async () => {
const result = await enable();
if (result?.kind === "enabled") {
router.push(buildSettingsHostSectionRoute(result.newStatus.serverId, "host"));
}
})();
}, [enable]);
return { visible, onPress };
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
}
function summarize(items: DiffFlatItem[]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
});
}
describe("buildDiffFlatItems", () => {
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
it("emits a body and a sticky index for each expanded file", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files: [createFile("a.ts"), createFile("b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["a.ts"]),
});
// Root-level files (no folder): header, body (for expanded a.ts), header
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
// sticky points at the header (index 0), not the body
expect(stickyHeaderIndices).toEqual([0]);
expect(items[0].type).toBe("header");
});
it("emits the old flat list without folder rows or indentation", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
expect(stickyHeaderIndices).toEqual([0]);
expect(items.every((item) => item.type !== "folder")).toBe(true);
});
it("groups files under compressed folder rows, all expanded by default", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// dirs sort before files within a level: [nested] precedes a.ts
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
});
it("collapsing a folder hides its descendants but keeps the row", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
});
it("collapsing an ancestor hides everything below it", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
});
it("derives sticky indices file-headers-only from the post-collapse list", () => {
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
// header at index 3, with the body right after.
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual([
"[src/app]",
" [nested]",
" b.ts",
" a.ts",
" body:a.ts",
]);
expect(stickyHeaderIndices).toEqual([3]);
for (const idx of stickyHeaderIndices) {
expect(items[idx].type).toBe("header");
}
});
it("maps tree file rows back to their original files array index", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
const headers = items.filter((i) => i.type === "header");
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
});
});
describe("sumHeightsBefore", () => {
const items = buildDiffFlatItems({
files: [createFile("src/a.ts"), createFile("src/b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
}).items; // [folder, a.ts, b.ts]
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
it("sums the heights of items before the index, counting folder rows", () => {
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
});
it("clamps an out-of-range index to the list length", () => {
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
// The row model for the Changes FlatList. `fileIndex` is the file's index within
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
// independent of folder rows and collapse state. `depth` drives indentation.
export type DiffFlatItem =
| {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
export interface DiffFlatItemsResult {
items: DiffFlatItem[];
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
stickyHeaderIndices: number[];
}
export interface BuildDiffFlatItemsInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
collapsedFolders: ReadonlySet<string>;
/** File paths whose diff body is expanded. */
expandedPaths: ReadonlySet<string>;
/**
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
* the tree on every collapse toggle; omitted callers (tests) build it internally.
*/
tree?: DiffTreeDirNode;
}
/**
* Build the FlatList item model plus its sticky-header indices: files grouped
* under directory rows, single-child chains compressed, descendants of collapsed
* folders omitted.
*
* Sticky indices are derived from the FINAL (post-collapse) item list and only
* ever point at expanded FILE headers, so folder rows can't stack or collide in
* the sticky header stack.
*/
export function buildDiffFlatItems({
files,
viewMode,
collapsedFolders,
expandedPaths,
tree,
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
const items: DiffFlatItem[] = [];
const stickyHeaderIndices: number[] = [];
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex, isExpanded, depth });
if (isExpanded) {
stickyHeaderIndices.push(items.length - 1);
items.push({ type: "body", file, fileIndex, depth });
}
};
if (viewMode === "flat") {
for (const [fileIndex, file] of files.entries()) {
pushFile(file, fileIndex, 0);
}
return { items, stickyHeaderIndices };
}
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
const rows = flattenDiffTree(compressedTree, collapsedFolders);
for (const row of rows) {
if (row.kind === "folder") {
items.push({
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
});
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex === undefined) {
// Should never happen: the tree is built from the same `files` array.
continue;
}
pushFile(row.file, fileIndex, row.depth);
}
return { items, stickyHeaderIndices };
}
/** Cumulative height of every item before `index`. Single source of truth for
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
export function sumHeightsBefore(
items: DiffFlatItem[],
index: number,
heightFor: (item: DiffFlatItem) => number,
): number {
let offset = 0;
const end = Math.min(index, items.length);
for (let i = 0; i < end; i++) {
offset += heightFor(items[i]);
}
return Math.max(0, offset);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useMemo } from "react";
import {
View,
Text,
Pressable,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
interface DiffFolderRowProps {
/** full uncompressed directory path — the collapse identity */
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
onToggle: (dirPath: string) => void;
onHeightChange?: (height: number) => void;
testID?: string;
}
function folderRowPressableStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
// Subtle background highlight on hover/press, matching the Files explorer rows
// (entryRowActive) — no opacity darken.
return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
}
export function DiffFolderRow({
dirPath,
displayName,
depth,
collapsed,
additions,
deletions,
onToggle,
onHeightChange,
testID,
}: DiffFolderRowProps) {
const handlePress = useCallback(() => {
onToggle(dirPath);
}, [dirPath, onToggle]);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
onHeightChange?.(event.nativeEvent.layout.height);
},
[onHeightChange],
);
const leftStyle = useMemo(
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
[depth],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<View style={styles.container} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Pressable
onPress={handlePress}
style={folderRowPressableStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<TreeChevron expanded={!collapsed} />
<Text style={styles.folderName} numberOfLines={1}>
{displayName}
</Text>
</View>
<View style={styles.right}>
<DiffStat additions={additions} deletions={deletions} />
</View>
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme: Theme) => ({
container: {
overflow: "hidden",
},
folderRow: {
flexDirection: "row",
alignItems: "center",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[1],
minWidth: 0,
},
folderRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
left: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
right: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
},
folderName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
},
}));
+417 -306
View File
@@ -36,8 +36,10 @@ import {
ChevronDown,
Columns2,
Download,
FolderTree,
GitCommitHorizontal,
GitMerge,
List,
ListChevronsDownUp,
ListChevronsUpDown,
Pilcrow,
@@ -52,6 +54,12 @@ import {
type DiffLine,
type HighlightToken,
} from "@/git/use-diff-query";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -191,6 +199,10 @@ function HighlightedText({
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
/** Show the muted directory suffix (flat list); false inside the folder tree. */
showDir?: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
@@ -889,6 +901,8 @@ function SplitDiffColumn({
const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
depth = 0,
showDir = true,
onToggle,
onHeaderHeightChange,
testID,
@@ -940,13 +954,27 @@ const DiffFileHeader = memo(function DiffFileHeader({
[isExpanded],
);
const headerPressableStyle = useCallback(
(state: PressableStateCallbackType) =>
depth > 0
? [
fileHeaderPressableStyle(state),
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
]
: fileHeaderPressableStyle(state),
[depth],
);
const fileName = file.path.split("/").pop() ?? file.path;
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={fileHeaderPressableStyle}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
@@ -954,14 +982,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>
{file.path.split("/").pop()}
</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
{fileName}
</Text>
{showDir ? (
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/")
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
: ""}
</Text>
) : (
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
// stays right-aligned next to the diff stats, as in the flat list.
<View style={styles.fileDirSpacer} />
)}
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
@@ -1181,7 +1220,6 @@ type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
@@ -1191,6 +1229,8 @@ const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedList = withUnistyles(List);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
@@ -1201,101 +1241,89 @@ const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown);
interface DiffLayoutToggleGroupProps {
const DIFF_OPTIONS_WHITESPACE_ICON = (
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_WRAP_ICON = (
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
);
interface DiffLayoutToggleProps {
layout: "unified" | "split";
unifiedToggleStyle: PressableStyleFn;
splitToggleStyle: PressableStyleFn;
onUnified: () => void;
onSplit: () => void;
}
function DiffLayoutToggleGroup({
layout,
unifiedToggleStyle,
splitToggleStyle,
onUnified,
onSplit,
}: DiffLayoutToggleGroupProps) {
const { t } = useTranslation();
const unifiedLabel = t("workspace.git.diff.unified");
const splitLabel = t("workspace.git.diff.split");
return (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={unifiedLabel}
testID="changes-layout-unified"
onPress={onUnified}
style={unifiedToggleStyle}
>
<ThemedAlignJustify
size={14}
uniProps={
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{unifiedLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={splitLabel}
testID="changes-layout-split"
onPress={onSplit}
style={splitToggleStyle}
>
<ThemedColumns2
size={14}
uniProps={
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{splitLabel}</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
interface DiffWhitespaceToggleProps {
hideWhitespace: boolean;
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffWhitespaceToggle({
hideWhitespace,
isMobile,
toggleStyle,
onToggle,
}: DiffWhitespaceToggleProps) {
function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) {
const { t } = useTranslation();
const label = t("workspace.git.diff.hideWhitespace");
const label =
layout === "unified"
? t("workspace.git.diff.switchToSplit")
: t("workspace.git.diff.switchToUnified");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-whitespace"
testID="changes-toggle-layout"
onPress={onToggle}
style={toggleStyle}
>
{layout === "unified" ? (
<ThemedColumns2 size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedAlignJustify
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface DiffViewModeToggleProps {
viewMode: "flat" | "tree";
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffViewModeToggle({
viewMode,
isMobile,
toggleStyle,
onToggle,
}: DiffViewModeToggleProps) {
const { t } = useTranslation();
const label =
viewMode === "flat"
? t("workspace.git.diff.showTreeView")
: t("workspace.git.diff.showFlatView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-view-mode"
style={toggleStyle}
onPress={onToggle}
>
<ThemedPilcrow
size={isMobile ? 18 : 14}
uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
{viewMode === "flat" ? (
<ThemedFolderTree
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedList size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
@@ -1306,50 +1334,33 @@ function DiffWhitespaceToggle({
}
interface DiffFilesToolbarProps {
wrapLines: boolean;
allExpanded: boolean;
allFileDiffsExpanded: boolean;
isMobile: boolean;
wrapLinesToggleStyle: PressableStyleFn;
expandAllToggleStyle: PressableStyleFn;
onToggleWrapLines: () => void;
onToggleExpandAll: () => void;
}
function DiffFilesToolbar({
wrapLines,
allExpanded,
allFileDiffsExpanded,
isMobile,
wrapLinesToggleStyle,
expandAllToggleStyle,
onToggleWrapLines,
onToggleExpandAll,
}: DiffFilesToolbarProps) {
const { t } = useTranslation();
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const expandAllLabel = allExpanded
const expandAllLabel = allFileDiffsExpanded
? t("workspace.git.diff.collapseAll")
: t("workspace.git.diff.expandAll");
return (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
<ThemedWrapText
size={isMobile ? 18 : 14}
uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{wrapLinesLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
{allExpanded ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={expandAllLabel}
style={expandAllToggleStyle}
onPress={onToggleExpandAll}
>
{allFileDiffsExpanded ? (
<ThemedListChevronsDownUp
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
@@ -1370,53 +1381,105 @@ function DiffFilesToolbar({
);
}
interface DiffRefreshButtonProps {
interface DiffOptionsMenuProps {
hideWhitespace: boolean;
isMobile: boolean;
isRefreshing: boolean;
toggleStyle: PressableStyleFn;
onPress: () => void;
overflowToggleStyle: PressableStyleFn;
refreshSupported: boolean;
wrapLines: boolean;
onRefresh: () => void;
onToggleHideWhitespace: () => void;
onToggleWrapLines: () => void;
}
function DiffOptionsMenu({
hideWhitespace,
isMobile,
isRefreshing,
overflowToggleStyle,
refreshSupported,
wrapLines,
onRefresh,
onToggleHideWhitespace,
onToggleWrapLines,
}: DiffOptionsMenuProps) {
const { t } = useTranslation();
const whitespaceLabel = hideWhitespace
? t("workspace.git.diff.showWhitespace")
: t("workspace.git.diff.hideWhitespace");
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const optionsLabel = t("workspace.git.diff.options");
const refreshIcon = useMemo(
() =>
isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
),
[isRefreshing],
);
return (
<DropdownMenu>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={optionsLabel}
testID="changes-options-menu"
style={overflowToggleStyle}
>
<ThemedChevronDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{optionsLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" width={240} testID="changes-options-menu-content">
<DropdownMenuItem
leading={DIFF_OPTIONS_WHITESPACE_ICON}
selected={hideWhitespace}
testID="changes-toggle-whitespace"
onSelect={onToggleHideWhitespace}
>
{whitespaceLabel}
</DropdownMenuItem>
<DropdownMenuItem
leading={DIFF_OPTIONS_WRAP_ICON}
selected={wrapLines}
testID="changes-toggle-wrap-lines"
onSelect={onToggleWrapLines}
>
{wrapLinesLabel}
</DropdownMenuItem>
{refreshSupported ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={refreshIcon}
disabled={isRefreshing}
testID="changes-refresh"
onSelect={onRefresh}
>
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
const { t } = useTranslation();
const refreshLabel = t("workspace.git.diff.refresh");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={
isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refreshState")
}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{refreshLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
@@ -1679,30 +1742,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const viewMode = changesPreferences.viewMode;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
const handleLayoutChange = useCallback(
(nextLayout: "unified" | "split") => {
void updateChangesPreferences({ layout: nextLayout });
},
[updateChangesPreferences],
);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
}, [handleLayoutChange]);
const handleLayoutSplit = useCallback(() => {
handleLayoutChange("split");
}, [handleLayoutChange]);
const handleToggleLayout = useCallback(() => {
void updateChangesPreferences({
layout: changesPreferences.layout === "unified" ? "split" : "unified",
});
}, [changesPreferences.layout, updateChangesPreferences]);
const codeFontSize = appSettings.codeFontSize;
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
@@ -1719,37 +1774,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
const layoutToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.expandAllButton),
[],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
);
const hideWhitespaceToggleStyle = useMemo(
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
const viewModeToggleStyle = useMemo(
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
[viewMode],
);
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const toast = useToast();
const refreshSupported = useSessionStore(
@@ -1892,7 +1929,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
// The Changes view groups files into a directory tree on every form factor,
// consistent with the Files explorer (which is also a tree on mobile).
const collapsedFoldersArray = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
(state) => state.setDiffCollapsedFoldersForWorkspace,
);
// Build the directory tree once per files-change; collapse/expand toggles only
// re-flatten it (they don't change tree shape).
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
// Every directory path currently in the tree — used by "collapse all folders" and to
// filter stale collapse state.
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
// Effective collapsed set: intersect the persisted paths with the folders actually
// present, purely at render (no store-syncing effect). A folder that left the diff and
// reappears defaults to expanded; toggles write back this pruned set, so the stored
// array stays bounded. (empty = all folders expanded, the default)
const collapsedFolders = useMemo(
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
[collapsedFoldersArray, allFolderPathSet],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const handleToggleViewMode = useCallback(() => {
const nextViewMode = viewMode === "flat" ? "tree" : "flat";
if (nextViewMode === "tree") {
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
if (workspaceStateKey) {
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []);
}
}
void updateChangesPreferences({ viewMode: nextViewMode });
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
@@ -1900,26 +1970,23 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
// (Codex item 6) so file/folder heights can't collide by path.
const folderRowHeightRef = useRef<number>(0);
const defaultHeaderHeightRef = useRef<number>(44);
const [heightVersion, setHeightVersion] = useState(0);
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
}
}
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
files,
viewMode,
tree: compressedTree,
collapsedFolders,
expandedPaths,
});
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [expandedPaths, files]);
}, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]);
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
@@ -1958,6 +2025,35 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
);
// Single height source of truth for both getItemLayout and the collapse
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
// falling back to the default header height before first measurement.
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "folder") {
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
}
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const handleFolderRowHeightChange = useCallback((height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
const previousHeight = folderRowHeightRef.current;
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
return;
}
folderRowHeightRef.current = height;
setHeightVersion((version) => version + 1);
}, []);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
@@ -2013,23 +2109,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[scrollbar],
);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedPaths.has(file.path)) {
const bodyHeightKey = getBodyHeightKey(file);
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
}
// Offset of the first item matching `predicate`, walking the SAME flatItems
// list getFlatItemLayout uses so folder rows are counted (single source of
// truth — Codex item 5 / finding 2).
const computeItemOffset = useCallback(
(predicate: (item: DiffFlatItem) => boolean): number | null => {
const index = flatItems.findIndex(predicate);
if (index < 0) {
return null;
}
return Math.max(0, offset);
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
},
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
[flatItems, getFlatItemHeight],
);
const computeHeaderOffset = useCallback(
(path: string): number =>
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
[computeItemOffset],
);
const handleToggleExpanded = useCallback(
@@ -2067,7 +2164,41 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
const handleToggleFolder = useCallback(
(dirPath: string) => {
if (!workspaceStateKey) {
return;
}
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
// Collapsing hides the subtree below this row; anchor to the folder row
// first so the viewport doesn't jump to a dead offset (Codex item 5).
if (!isCurrentlyCollapsed) {
const targetOffset = computeItemOffset(
(item) => item.type === "folder" && item.dirPath === dirPath,
);
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
if (
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight: folderHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
})
) {
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
}
}
const nextCollapsed = isCurrentlyCollapsed
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
},
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
);
const allFileDiffsExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
@@ -2076,7 +2207,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
if (!workspaceStateKey) {
return;
}
if (allExpanded) {
if (allFileDiffsExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
@@ -2084,15 +2215,32 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={item.collapsed}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onHeightChange={handleFolderRowHeightChange}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
if (item.type === "header") {
return (
<DiffFileHeader
file={item.file}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={viewMode === "flat"}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
@@ -2117,40 +2265,25 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
diffTextMetricsStyle,
effectiveLayout,
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
handleToggleFolder,
reviewActions,
viewMode,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
(item: DiffFlatItem) =>
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
[],
);
const getFlatItemHeight = useCallback(
(item: DiffFlatItem): number => {
if (item.type === "header") {
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
}
const bodyHeightKey = getBodyHeightKey(item.file);
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
},
[estimateBodyHeight, getBodyHeightKey],
);
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
(_data, index) => {
let offset = 0;
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
const item = flatItems[itemIndex];
if (item) {
offset += getFlatItemHeight(item);
}
}
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
const item = flatItems[index];
const length = item ? getFlatItemHeight(item) : 0;
return { length, offset, index };
@@ -2161,17 +2294,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const flatExtraData = useMemo(
() => ({
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
}),
[
expandedPathsArray,
collapsedFoldersArray,
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
],
@@ -2301,38 +2438,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
</DropdownMenu>
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<DiffLayoutToggleGroup
<DiffLayoutToggle
layout={changesPreferences.layout}
unifiedToggleStyle={unifiedToggleStyle}
splitToggleStyle={splitToggleStyle}
onUnified={handleLayoutUnified}
onSplit={handleLayoutSplit}
isMobile={isMobile}
toggleStyle={layoutToggleStyle}
onToggle={handleToggleLayout}
/>
) : null}
{files.length > 0 ? (
<DiffViewModeToggle
viewMode={viewMode}
isMobile={isMobile}
toggleStyle={viewModeToggleStyle}
onToggle={handleToggleViewMode}
/>
) : null}
<DiffWhitespaceToggle
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
toggleStyle={hideWhitespaceToggleStyle}
onToggle={handleToggleHideWhitespace}
/>
{files.length > 0 ? (
<DiffFilesToolbar
wrapLines={wrapLines}
allExpanded={allExpanded}
allFileDiffsExpanded={allFileDiffsExpanded}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
/>
) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
wrapLines={wrapLines}
onRefresh={handleRefresh}
onToggleHideWhitespace={handleToggleHideWhitespace}
onToggleWrapLines={handleToggleWrapLines}
/>
</View>
</View>
</View>
@@ -2414,46 +2553,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
},
toggleButtonGroupStart: {
borderTopLeftRadius: theme.borderRadius.base,
borderBottomLeftRadius: theme.borderRadius.base,
},
toggleButtonGroupEnd: {
borderTopRightRadius: theme.borderRadius.base,
borderBottomRightRadius: theme.borderRadius.base,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
@@ -2574,6 +2676,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexShrink: 0,
},
fileIcon: {
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
fileName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
@@ -2588,6 +2695,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
fileDirSpacer: {
flex: 1,
minWidth: 0,
},
newBadge: {
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "./diff-tree";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions,
deletions,
hunks: [],
};
}
// The Changes tree is fed the already-path-sorted diff files; build against that.
function tree(paths: Array<string | ParsedDiffFile>) {
return buildDiffTree(paths.map((p) => (typeof p === "string" ? createFile(p) : p)));
}
function compressed(paths: Array<string | ParsedDiffFile>) {
return compressSingleChildChains(tree(paths));
}
function rowLabels(rows: DiffTreeRow[]): string[] {
return rows.map((row) =>
row.kind === "folder"
? `${" ".repeat(row.depth)}[${row.displayName}]`
: `${" ".repeat(row.depth)}${row.file.path.split("/").pop()}`,
);
}
describe("buildDiffTree", () => {
it("returns an empty root for no files", () => {
const root = tree([]);
expect(root.dirPath).toBe("");
expect(root.children).toEqual([]);
});
it("places root-level files directly under the root with no folder", () => {
const root = tree(["README.md", "package.json"]);
expect(root.children.every((c) => c.kind === "file")).toBe(true);
expect(root.children.map((c) => (c.kind === "file" ? c.name : c.dirPath))).toEqual([
"README.md",
"package.json",
]);
});
it("nests files under their directory, keyed by full path", () => {
const root = tree(["src/a.ts", "src/nested/b.ts"]);
expect(root.children).toHaveLength(1);
const src = root.children[0];
expect(src.kind).toBe("dir");
if (src.kind !== "dir") return;
expect(src.dirPath).toBe("src");
const nested = src.children.find((c) => c.kind === "dir");
expect(nested?.kind === "dir" && nested.dirPath).toBe("src/nested");
});
it("orders directories before files within a level, each alphabetically", () => {
// Input alpha-by-full-path puts the file 'src/a.ts' before dir 'src/z/...',
// but rendering wants dirs first.
const root = tree(["src/a.ts", "src/z/deep.ts", "src/m/mid.ts"]);
const src = root.children[0];
if (src.kind !== "dir") throw new Error("expected dir");
expect(
src.children.map((c) => (c.kind === "dir" ? `dir:${c.name}` : `file:${c.name}`)),
).toEqual(["dir:m", "dir:z", "file:a.ts"]);
});
});
describe("compressSingleChildChains", () => {
it("collapses a single-child chain into one row keyed by the deepest dir", () => {
const rows = flattenDiffTree(compressed(["packages/app/src/git/diff-pane.tsx"]), new Set());
expect(rowLabels(rows)).toEqual(["[packages/app/src/git]", " diff-pane.tsx"]);
const folder = rows[0];
expect(folder.kind === "folder" && folder.dirPath).toBe("packages/app/src/git");
});
it("stops compressing where a directory has multiple children", () => {
const rows = flattenDiffTree(
compressed(["packages/app/a.ts", "packages/server/b.ts"]),
new Set(),
);
// "packages" has two dir children, so it stays its own row.
expect(rowLabels(rows)).toEqual([
"[packages]",
" [app]",
" a.ts",
" [server]",
" b.ts",
]);
});
it("compresses a chain that ends in a file-bearing directory", () => {
const rows = flattenDiffTree(compressed(["a/b/c/one.ts", "a/b/c/two.ts"]), new Set());
expect(rowLabels(rows)).toEqual(["[a/b/c]", " one.ts", " two.ts"]);
});
it("does not merge the virtual root with a single top-level dir's siblings", () => {
const rows = flattenDiffTree(compressed(["only/deep/file.ts"]), new Set());
expect(rows[0].kind === "folder" && rows[0].displayName).toBe("only/deep");
});
});
describe("flattenDiffTree", () => {
it("expands everything when the collapsed set is empty", () => {
const rows = flattenDiffTree(compressed(["src/a.ts", "src/b.ts"]), new Set());
expect(rowLabels(rows)).toEqual(["[src]", " a.ts", " b.ts"]);
});
it("omits descendants of a collapsed directory but keeps the folder row", () => {
const root = compressed(["src/a.ts", "src/b.ts"]);
const rows = flattenDiffTree(root, new Set(["src"]));
expect(rowLabels(rows)).toEqual(["[src]"]);
});
it("collapsing a parent hides nested folders and files", () => {
const root = compressed(["src/deep/a.ts", "src/top.ts"]);
const rows = flattenDiffTree(root, new Set(["src"]));
expect(rowLabels(rows)).toEqual(["[src]"]);
});
it("sums descendant additions/deletions onto the folder row", () => {
const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/nested/b.ts", 5, 2)]);
const rows = flattenDiffTree(root, new Set());
const srcFolder = rows.find((r) => r.kind === "folder" && r.dirPath === "src");
expect(srcFolder).toMatchObject({ additions: 8, deletions: 3 });
});
it("reports full aggregate stats even when the folder is collapsed", () => {
const root = compressed([createFile("src/a.ts", 3, 1), createFile("src/b.ts", 4, 0)]);
const rows = flattenDiffTree(root, new Set(["src"]));
expect(rows[0]).toMatchObject({ kind: "folder", additions: 7, deletions: 1 });
});
});
describe("collectDirPaths", () => {
it("returns every logical directory path in the compressed tree", () => {
const root = compressed(["packages/app/a.ts", "packages/server/b.ts"]);
expect(collectDirPaths(root).sort()).toEqual(["packages", "packages/app", "packages/server"]);
});
});
+207
View File
@@ -0,0 +1,207 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
// Builds a directory hierarchy from the flat, path-sorted `ParsedDiffFile[]` the
// Changes view renders. The tree renders on every form factor, consistent with
// the Files explorer.
//
// Directory nodes are keyed by their FULL uncompressed path (e.g. "packages/app/src").
// That path is the stable identity used to persist folder-collapse state, so the
// state survives path-compression changes as the diff mutates: if a compressed
// row later splits because a sibling appears, the logical directories keep the
// same keys.
export interface DiffTreeFileNode {
kind: "file";
file: ParsedDiffFile;
/** basename, e.g. "diff-pane.tsx" */
name: string;
}
export interface DiffTreeDirNode {
kind: "dir";
/** full uncompressed directory path, e.g. "packages/app/src"; "" for the virtual root */
dirPath: string;
/** display label; a compressed chain joins segments, e.g. "packages/app/src" */
name: string;
children: DiffTreeNode[];
}
export type DiffTreeNode = DiffTreeFileNode | DiffTreeDirNode;
export interface DiffTreeFolderRow {
kind: "folder";
/** full uncompressed path of the DEEPEST directory this row represents */
dirPath: string;
/** compressed display label, e.g. "packages/app/src" */
displayName: string;
depth: number;
additions: number;
deletions: number;
}
export interface DiffTreeFileRow {
kind: "file";
file: ParsedDiffFile;
depth: number;
}
export type DiffTreeRow = DiffTreeFolderRow | DiffTreeFileRow;
function sortTree(node: DiffTreeDirNode): void {
node.children.sort((a, b) => {
if (a.kind !== b.kind) {
// directories before files within a level
return a.kind === "dir" ? -1 : 1;
}
// Plain ASCII comparison, matching compareCheckoutDiffPaths (diff-order.ts)
// so the tree order is consistent with the rest of the Changes view.
if (a.name === b.name) return 0;
return a.name < b.name ? -1 : 1;
});
for (const child of node.children) {
if (child.kind === "dir") {
sortTree(child);
}
}
}
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
function ensureDir(dirPath: string): DiffTreeDirNode {
const existing = dirByPath.get(dirPath);
if (existing) {
return existing;
}
const parts = dirPath.split("/");
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join("/");
const parent = ensureDir(parentPath);
const node: DiffTreeDirNode = { kind: "dir", dirPath, name, children: [] };
parent.children.push(node);
dirByPath.set(dirPath, node);
return node;
}
for (const file of files) {
const parts = file.path.split("/");
const name = parts[parts.length - 1];
const dirPath = parts.slice(0, -1).join("/");
ensureDir(dirPath).children.push({ kind: "file", file, name });
}
sortTree(root);
return root;
}
// Collapse runs of single-child directories into one row, like VS Code / GitHub:
// a directory whose only child is another directory absorbs it. The merged row
// displays the joined segments ("packages/app/src") but keeps the DEEPEST
// directory's full path as its identity.
function compressNode(node: DiffTreeDirNode): DiffTreeDirNode {
let name = node.name;
let dirPath = node.dirPath;
let children = node.children.map((child) => (child.kind === "dir" ? compressNode(child) : child));
while (children.length === 1 && children[0].kind === "dir") {
const only = children[0];
name = name ? `${name}/${only.name}` : only.name;
dirPath = only.dirPath;
children = only.children;
}
return { kind: "dir", dirPath, name, children };
}
/**
* Compress single-child directory chains. The virtual root is never merged
* (it isn't rendered); only its subtrees are compressed.
*/
export function compressSingleChildChains(root: DiffTreeDirNode): DiffTreeDirNode {
return {
...root,
children: root.children.map((child) => (child.kind === "dir" ? compressNode(child) : child)),
};
}
interface DirStats {
additions: number;
deletions: number;
}
const EMPTY_DIR_STATS: DirStats = { additions: 0, deletions: 0 };
// Single post-order pass computing every directory node's aggregate stats from
// its already-summed children — O(n), vs. re-walking each subtree per folder row.
function computeDirStats(root: DiffTreeDirNode): Map<DiffTreeDirNode, DirStats> {
const statsByNode = new Map<DiffTreeDirNode, DirStats>();
function visit(node: DiffTreeDirNode): DirStats {
const stats: DirStats = { additions: 0, deletions: 0 };
for (const child of node.children) {
if (child.kind === "file") {
stats.additions += child.file.additions;
stats.deletions += child.file.deletions;
} else {
const childStats = visit(child);
stats.additions += childStats.additions;
stats.deletions += childStats.deletions;
}
}
statsByNode.set(node, stats);
return stats;
}
visit(root);
return statsByNode;
}
/**
* Flatten the (compressed) tree into depth-tagged rows for the list. Descendants
* of any directory whose `dirPath` is in `collapsed` are omitted; folder rows
* always carry the FULL aggregate stats of their subtree, collapsed or not.
*/
export function flattenDiffTree(
root: DiffTreeDirNode,
collapsed: ReadonlySet<string>,
): DiffTreeRow[] {
const statsByNode = computeDirStats(root);
const rows: DiffTreeRow[] = [];
function walk(node: DiffTreeDirNode, depth: number): void {
for (const child of node.children) {
if (child.kind === "file") {
rows.push({ kind: "file", file: child.file, depth });
continue;
}
const stats = statsByNode.get(child) ?? EMPTY_DIR_STATS;
rows.push({
kind: "folder",
dirPath: child.dirPath,
displayName: child.name,
depth,
additions: stats.additions,
deletions: stats.deletions,
});
if (!collapsed.has(child.dirPath)) {
walk(child, depth + 1);
}
}
}
walk(root, 0);
return rows;
}
/** Every directory path in the (compressed) tree — used for "collapse all folders". */
export function collectDirPaths(root: DiffTreeDirNode): string[] {
const paths: string[] = [];
function walk(node: DiffTreeDirNode): void {
for (const child of node.children) {
if (child.kind === "dir") {
paths.push(child.dirPath);
walk(child);
}
}
}
walk(root);
return paths;
}
@@ -28,6 +28,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({
layout: "unified",
viewMode: "flat",
wrapLines: true,
hideWhitespace: false,
});
@@ -37,6 +38,7 @@ describe("loadChangesPreferencesFromStorage", () => {
it("loads persisted layout and whitespace preferences without rewriting storage", async () => {
const persisted = JSON.stringify({
layout: "split",
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
});
@@ -48,6 +50,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({
layout: "split",
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
});
@@ -64,11 +67,16 @@ describe("saveChangesPreferences", () => {
await saveChangesPreferences({
queryClient,
updates: { layout: "split", hideWhitespace: true },
updates: { layout: "split", viewMode: "tree", hideWhitespace: true },
storage,
});
const expected = { ...DEFAULT_CHANGES_PREFERENCES, layout: "split", hideWhitespace: true };
const expected = {
...DEFAULT_CHANGES_PREFERENCES,
layout: "split",
viewMode: "tree",
hideWhitespace: true,
};
expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected);
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected));
});
@@ -7,18 +7,21 @@ export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
viewMode: "flat" | "tree";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
};
@@ -12,6 +12,7 @@ import type {
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
const onPcmSegmentRef = useRef(config.onPcmSegment);
const onErrorRef = useRef(config.onError);
const onInterruptionRef = useRef(config.onInterruption);
const [volume, setVolume] = useState(0);
const engineRef = useRef<ReturnType<typeof createAudioEngine> | null>(null);
@@ -30,6 +31,9 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
onError: (error) => {
onErrorRef.current?.(error);
},
onInterruption: () => {
onInterruptionRef.current?.();
},
});
return engineRef.current;
}, []);
@@ -37,7 +41,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
useEffect(() => {
onPcmSegmentRef.current = config.onPcmSegment;
onErrorRef.current = config.onError;
}, [config.onPcmSegment, config.onError]);
onInterruptionRef.current = config.onInterruption;
}, [config.onPcmSegment, config.onError, config.onInterruption]);
const start = useCallback(async () => {
const engine = getOrCreateEngine();
@@ -1,6 +1,7 @@
export interface DictationAudioSourceConfig {
onPcmSegment: (pcm16Base64: string) => void;
onError?: (error: Error) => void;
onInterruption?: () => void;
}
export interface DictationAudioSource {
+23 -14
View File
@@ -184,19 +184,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
});
}, [client]);
const audio = useDictationAudioSource({
onPcmSegment: (audioData) => {
senderRef.current?.enqueueSegment(audioData);
},
onError: (err) => {
onErrorRef.current?.(err);
},
});
const audioStopRef = useRef(audio.stop);
useEffect(() => {
audioStopRef.current = audio.stop;
}, [audio.stop]);
const handleStreamingTranscriptionSuccess = useCallback(
(text: string, requestId: string) => {
setIsProcessing(false);
@@ -220,6 +207,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
(failure: unknown) => {
const normalized = toError(failure);
const failureId = generateMessageId();
stopDurationTracking();
setIsProcessing(false);
isProcessingRef.current = false;
isRecordingRef.current = false;
@@ -234,9 +222,30 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
reportError(normalized, "Failed to complete dictation");
},
[reportError],
[reportError, stopDurationTracking],
);
const audio = useDictationAudioSource({
onPcmSegment: (audioData) => {
senderRef.current?.enqueueSegment(audioData);
},
onError: (err) => {
onErrorRef.current?.(err);
},
onInterruption: () => {
try {
senderRef.current?.cancel();
} catch {
// no-op
}
handleDictationFailure(new Error("Dictation was interrupted by another audio source."));
},
});
const audioStopRef = useRef(audio.stop);
useEffect(() => {
audioStopRef.current = audio.stop;
}, [audio.stop]);
const startDictation = useCallback(async () => {
if (
actionGateRef.current.starting ||
+28 -2
View File
@@ -15,10 +15,10 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
};
}
export function useLocalDaemonServerId(): string | null {
function useLocalDaemonServerIdQuery() {
const isDesktopApp = shouldUseDesktopDaemon();
const query = useQuery({
return useQuery({
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktopApp,
@@ -30,6 +30,11 @@ export function useLocalDaemonServerId(): string | null {
refetchOnWindowFocus: false,
retry: false,
});
}
export function useLocalDaemonServerId(): string | null {
const isDesktopApp = shouldUseDesktopDaemon();
const query = useLocalDaemonServerIdQuery();
if (!isDesktopApp) {
return null;
@@ -38,6 +43,27 @@ export function useLocalDaemonServerId(): string | null {
return query.data?.serverId ?? null;
}
export type LocalDaemonServerIdState =
| { status: "loading" }
| { status: "error" }
| { status: "resolved"; serverId: string | null };
export function useLocalDaemonServerIdState(): LocalDaemonServerIdState {
const isDesktopApp = shouldUseDesktopDaemon();
const query = useLocalDaemonServerIdQuery();
if (!isDesktopApp) {
return { status: "resolved", serverId: null };
}
if (query.isError) {
return { status: "error" };
}
if (query.isSuccess) {
return { status: "resolved", serverId: query.data.serverId };
}
return { status: "loading" };
}
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim();
const localServerId = useLocalDaemonServerId();
+22 -7
View File
@@ -96,6 +96,8 @@ export interface UseSettingsReturn {
resetSettings: () => Promise<void>;
}
type SettingsSelector<TSelected> = (settings: Settings) => TSelected;
export function useAppSettings(): UseAppSettingsReturn {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
@@ -137,7 +139,11 @@ export function useAppSettings(): UseAppSettingsReturn {
};
}
export function useSettings(): UseSettingsReturn {
export function useSettings(): UseSettingsReturn;
export function useSettings<TSelected>(selector: SettingsSelector<TSelected>): TSelected;
export function useSettings<TSelected>(
selector?: SettingsSelector<TSelected>,
): UseSettingsReturn | TSelected {
const appSettings = useAppSettings();
const desktopSettings = useDesktopSettings();
@@ -177,6 +183,9 @@ export function useSettings(): UseSettingsReturn {
if (updates.workspaceTitleSource !== undefined) {
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
}
if (updates.autoExpandReasoning !== undefined) {
appUpdates.autoExpandReasoning = updates.autoExpandReasoning;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -210,13 +219,19 @@ export function useSettings(): UseSettingsReturn {
await Promise.all(resets);
}, [appSettings, desktopSettings]);
const settings = {
...DEFAULT_APP_SETTINGS,
...appSettings.settings,
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.settings.releaseChannel,
};
if (selector) {
return selector(settings);
}
return {
settings: {
...DEFAULT_APP_SETTINGS,
...appSettings.settings,
manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon,
releaseChannel: desktopSettings.settings.releaseChannel,
},
settings,
isLoading: appSettings.isLoading || desktopSettings.isLoading,
error: appSettings.error ?? desktopSettings.error,
updateSettings,
@@ -39,6 +39,7 @@ export interface AppSettings {
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
autoExpandReasoning: boolean;
}
export interface Settings extends AppSettings {
@@ -58,6 +59,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
autoExpandReasoning: false,
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -204,6 +206,9 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
return result;
}
+23 -2
View File
@@ -707,11 +707,19 @@ export const ar: TranslationResources = {
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
unified: "الفرق الموحدة",
split: "فرق جنبًا إلى جنب",
switchToUnified: "التبديل إلى الفرق الموحد",
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
showTreeView: "إظهار شجرة المجلدات",
showFlatView: "إظهار قائمة الملفات المسطحة",
options: "خيارات الفرق",
hideWhitespace: "إخفاء المسافة البيضاء",
showWhitespace: "إظهار المسافة البيضاء",
scrollLongLines: "قم بتمرير الخطوط الطويلة",
wrapLongLines: "لف الخطوط الطويلة",
collapseAll: "طي كافة الملفات",
expandAll: "قم بتوسيع كافة الملفات",
collapseAllFolders: "طي كافة المجلدات",
expandAllFolders: "توسيع كافة المجلدات",
refreshing: "منعش",
refresh: "ينعش",
refreshState: "تحديث بوابة وحالة GitHub",
@@ -1048,6 +1056,12 @@ export const ar: TranslationResources = {
},
},
},
rootError: {
kicker: "حدث خطأ",
title: "واجه Paseo مشكلة.",
body: "جرّب مرة أخرى لإعادة تحميل التطبيق. إذا استمر حدوث ذلك، فأرفق التفاصيل أدناه عند الإبلاغ عنه.",
details: "التفاصيل",
},
startup: {
errorTitle: "حدث خطأ ما",
errorDescription:
@@ -1404,6 +1418,7 @@ export const ar: TranslationResources = {
},
backToWorkspace: "خلف",
addHost: "أضف مضيفًا",
enableBuiltInDaemon: "تفعيل البرنامج الخفي المدمج",
projects: "المشاريع",
projectList: {
hostLoadFailed: "تعذر تحميل المشاريع من المضيف{{hostName}}:{{message}}",
@@ -1412,7 +1427,6 @@ export const ar: TranslationResources = {
groupInfo: "حول{{title}}",
sections: {
general: "عام",
daemon: "Daemon",
appearance: "مظهر",
shortcuts: "الاختصارات",
integrations: "التكامل",
@@ -1427,7 +1441,7 @@ export const ar: TranslationResources = {
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
host: "Host",
host: "نظرة عامة",
},
general: {
title: "عام",
@@ -1457,6 +1471,10 @@ export const ar: TranslationResources = {
description: "يتم الاحتفاظ بالخطوط في المخزن المؤقت الطرفي المدمج",
accessibilityLabel: "خطوط التمرير Terminal",
},
autoExpandReasoning: {
label: "عرض التفكير دائماً",
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
},
language: {
label: "لغة",
description: "لغة التطبيق",
@@ -1538,6 +1556,9 @@ export const ar: TranslationResources = {
auto: "نظام",
},
},
detailLevel: {
title: "مستوى التفاصيل",
},
fonts: {
title: "الخطوط",
systemDefault: "الافتراضي للنظام",
+23 -2
View File
@@ -714,11 +714,19 @@ export const en = {
tooLarge: "Diff too large to display",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "Switch to unified diff",
switchToSplit: "Switch to side-by-side diff",
showTreeView: "Show folder tree",
showFlatView: "Show flat file list",
options: "Diff options",
hideWhitespace: "Hide whitespace",
showWhitespace: "Show whitespace",
scrollLongLines: "Scroll long lines",
wrapLongLines: "Wrap long lines",
collapseAll: "Collapse all files",
expandAll: "Expand all files",
collapseAllFolders: "Collapse all folders",
expandAllFolders: "Expand all folders",
refreshing: "Refreshing",
refresh: "Refresh",
refreshState: "Refresh git and GitHub state",
@@ -1055,6 +1063,12 @@ export const en = {
},
},
},
rootError: {
kicker: "Something went wrong",
title: "Paseo ran into a problem.",
body: "Try again to reload the app. If this keeps happening, include the details below when you report it.",
details: "Details",
},
startup: {
errorTitle: "Something went wrong",
errorDescription:
@@ -1412,6 +1426,7 @@ export const en = {
},
backToWorkspace: "Back",
addHost: "Add host",
enableBuiltInDaemon: "Enable built-in daemon",
projects: "Projects",
projectList: {
hostLoadFailed: "Couldn't load projects from host {{hostName}}: {{message}}",
@@ -1420,7 +1435,6 @@ export const en = {
groupInfo: "About {{title}}",
sections: {
general: "General",
daemon: "Daemon",
appearance: "Appearance",
shortcuts: "Shortcuts",
integrations: "Integrations",
@@ -1435,7 +1449,7 @@ export const en = {
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
host: "Host",
host: "Overview",
},
general: {
title: "General",
@@ -1464,6 +1478,10 @@ export const en = {
description: "Lines kept in the built-in terminal buffer",
accessibilityLabel: "Terminal scrollback lines",
},
autoExpandReasoning: {
label: "Always expand reasoning",
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
},
language: {
label: "Language",
description: "App language",
@@ -1545,6 +1563,9 @@ export const en = {
auto: "System",
},
},
detailLevel: {
title: "Detail level",
},
fonts: {
title: "Fonts",
systemDefault: "System default",
+24 -2
View File
@@ -734,11 +734,19 @@ export const es: TranslationResources = {
tooLarge: "La diferencia es demasiado grande para mostrarse",
unified: "Diferencia unificada",
split: "Diferencia de lado a lado",
switchToUnified: "Cambiar a diferencia unificada",
switchToSplit: "Cambiar a diferencia lado a lado",
showTreeView: "Mostrar árbol de carpetas",
showFlatView: "Mostrar lista plana de archivos",
options: "Opciones de diferencia",
hideWhitespace: "Ocultar espacios en blanco",
showWhitespace: "Mostrar espacios en blanco",
scrollLongLines: "Desplazarse por largas filas",
wrapLongLines: "Envolver largas filas",
collapseAll: "Contraer todos los archivos",
expandAll: "Expandir todos los archivos",
collapseAllFolders: "Contraer todas las carpetas",
expandAllFolders: "Expandir todas las carpetas",
refreshing: "Refrescante",
refresh: "Refrescar",
refreshState: "Actualizar el estado de git yGitHub",
@@ -1084,6 +1092,12 @@ export const es: TranslationResources = {
},
},
},
rootError: {
kicker: "Algo salió mal",
title: "Paseo tuvo un problema.",
body: "Vuelve a intentarlo para recargar la app. Si sigue ocurriendo, incluye los detalles de abajo al reportarlo.",
details: "Detalles",
},
startup: {
errorTitle: "algo salió mal",
errorDescription:
@@ -1443,6 +1457,7 @@ export const es: TranslationResources = {
},
backToWorkspace: "Atrás",
addHost: "Agregar anfitrión",
enableBuiltInDaemon: "Activar el demonio integrado",
projects: "Proyectos",
projectList: {
hostLoadFailed: "No se pudieron cargar proyectos desde el host{{hostName}}:{{message}}",
@@ -1451,7 +1466,6 @@ export const es: TranslationResources = {
groupInfo: "Acerca de{{title}}",
sections: {
general: "General",
daemon: "Daemon",
appearance: "Apariencia",
shortcuts: "Atajos",
integrations: "Integraciones",
@@ -1466,7 +1480,7 @@ export const es: TranslationResources = {
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
host: "Host",
host: "Resumen",
},
general: {
title: "General",
@@ -1497,6 +1511,11 @@ export const es: TranslationResources = {
description: "Líneas mantenidas en el búfer de terminal incorporado",
accessibilityLabel: "Líneas del historial de terminal",
},
autoExpandReasoning: {
label: "Siempre expandir razonamiento",
description:
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
},
language: {
label: "Idioma",
description: "Idioma de la aplicación",
@@ -1578,6 +1597,9 @@ export const es: TranslationResources = {
auto: "Sistema",
},
},
detailLevel: {
title: "Nivel de detalle",
},
fonts: {
title: "Fuentes",
systemDefault: "Valor predeterminado del sistema",
+23 -2
View File
@@ -733,11 +733,19 @@ export const fr: TranslationResources = {
tooLarge: "Diff trop grand pour être affiché",
unified: "Différentiel unifié",
split: "Différent côte à côte",
switchToUnified: "Passer au diff unifié",
switchToSplit: "Passer au diff côte à côte",
showTreeView: "Afficher l'arborescence des dossiers",
showFlatView: "Afficher la liste de fichiers à plat",
options: "Options du diff",
hideWhitespace: "Masquer les espaces",
showWhitespace: "Afficher les espaces",
scrollLongLines: "Faire défiler les longues lignes",
wrapLongLines: "Enroulez les longues lignes",
collapseAll: "Réduire tous les fichiers",
expandAll: "Développer tous les fichiers",
collapseAllFolders: "Réduire tous les dossiers",
expandAllFolders: "Développer tous les dossiers",
refreshing: "Rafraîchissant",
refresh: "Rafraîchir",
refreshState: "Actualiser l'état de git etGitHub",
@@ -1086,6 +1094,12 @@ export const fr: TranslationResources = {
},
},
},
rootError: {
kicker: "Une erreur s'est produite",
title: "Paseo a rencontré un problème.",
body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.",
details: "Détails",
},
startup: {
errorTitle: "Quelque chose s'est mal passé",
errorDescription:
@@ -1446,6 +1460,7 @@ export const fr: TranslationResources = {
},
backToWorkspace: "Dos",
addHost: "Ajouter un hôte",
enableBuiltInDaemon: "Activer le démon intégré",
projects: "Projets",
projectList: {
hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}",
@@ -1454,7 +1469,6 @@ export const fr: TranslationResources = {
groupInfo: "À propos de{{title}}",
sections: {
general: "Général",
daemon: "Daemon",
appearance: "Apparence",
shortcuts: "Raccourcis",
integrations: "Intégrations",
@@ -1469,7 +1483,7 @@ export const fr: TranslationResources = {
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
host: "Host",
host: "Aperçu",
},
general: {
title: "Général",
@@ -1500,6 +1514,10 @@ export const fr: TranslationResources = {
description: "Lignes conservées dans le tampon du terminal intégré",
accessibilityLabel: "Lignes de défilementTerminal",
},
autoExpandReasoning: {
label: "Toujours afficher le raisonnement",
description: "Afficher le raisonnement de l'agent entièrement développé par défaut",
},
language: {
label: "Langue",
description: "Langue de l'application",
@@ -1582,6 +1600,9 @@ export const fr: TranslationResources = {
auto: "Système",
},
},
detailLevel: {
title: "Niveau de détail",
},
fonts: {
title: "Polices",
systemDefault: "Valeur par défaut du système",
+23 -2
View File
@@ -719,11 +719,19 @@ export const ja: TranslationResources = {
tooLarge: "差分が大きすぎて表示できません",
unified: "ユニファイド差分",
split: "左右比較",
switchToUnified: "ユニファイド差分に切り替え",
switchToSplit: "左右比較差分に切り替え",
showTreeView: "フォルダツリーを表示",
showFlatView: "フラットなファイル一覧を表示",
options: "差分オプション",
hideWhitespace: "空白を非表示",
showWhitespace: "空白を表示",
scrollLongLines: "長い行をスクロール",
wrapLongLines: "長い行を折り返す",
collapseAll: "すべて折りたたむ",
expandAll: "すべて展開",
collapseAllFolders: "すべてのフォルダを折りたたむ",
expandAllFolders: "すべてのフォルダを展開",
refreshing: "更新中",
refresh: "更新",
refreshState: "gitとGitHubの状態を更新",
@@ -1062,6 +1070,12 @@ export const ja: TranslationResources = {
},
},
},
rootError: {
kicker: "問題が発生しました",
title: "Paseo で問題が発生しました。",
body: "アプリを再読み込みするにはもう一度お試しください。繰り返し発生する場合は、以下の詳細を添えて報告してください。",
details: "詳細",
},
startup: {
errorTitle: "問題が発生しました",
errorDescription:
@@ -1421,6 +1435,7 @@ export const ja: TranslationResources = {
},
backToWorkspace: "戻る",
addHost: "ホストを追加",
enableBuiltInDaemon: "組み込みデーモンを有効にする",
projects: "プロジェクト",
projectList: {
hostLoadFailed: "ホスト{{hostName}}からプロジェクトを読み込めませんでした: {{message}}",
@@ -1429,7 +1444,6 @@ export const ja: TranslationResources = {
groupInfo: "{{title}}について",
sections: {
general: "一般",
daemon: "デーモン",
appearance: "外観",
shortcuts: "ショートカット",
integrations: "連携",
@@ -1444,7 +1458,7 @@ export const ja: TranslationResources = {
providers: "プロバイダー",
usage: "使用状況",
terminals: "ターミナル",
host: "ホスト",
host: "概要",
},
general: {
title: "一般",
@@ -1473,6 +1487,10 @@ export const ja: TranslationResources = {
description: "組み込みターミナルバッファに保持する行数",
accessibilityLabel: "ターミナルスクロールバック行数",
},
autoExpandReasoning: {
label: "常に思考プロセスを展開",
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
},
language: {
label: "言語",
description: "アプリの言語",
@@ -1554,6 +1572,9 @@ export const ja: TranslationResources = {
auto: "システム",
},
},
detailLevel: {
title: "詳細レベル",
},
fonts: {
title: "フォント",
systemDefault: "システムデフォルト",
+24 -2
View File
@@ -725,11 +725,19 @@ export const ptBR: TranslationResources = {
tooLarge: "Diff grande demais para exibir",
unified: "Diff unificado",
split: "Diff lado a lado",
switchToUnified: "Alternar para diff unificado",
switchToSplit: "Alternar para diff lado a lado",
showTreeView: "Mostrar árvore de pastas",
showFlatView: "Mostrar lista plana de arquivos",
options: "Opções do diff",
hideWhitespace: "Ocultar espaços em branco",
showWhitespace: "Mostrar espaços em branco",
scrollLongLines: "Rolar linhas longas",
wrapLongLines: "Quebrar linhas longas",
collapseAll: "Recolher todos os arquivos",
expandAll: "Expandir todos os arquivos",
collapseAllFolders: "Recolher todas as pastas",
expandAllFolders: "Expandir todas as pastas",
refreshing: "Atualizando",
refresh: "Atualizar",
refreshState: "Atualizar estado do git e do GitHub",
@@ -1070,6 +1078,12 @@ export const ptBR: TranslationResources = {
},
},
},
rootError: {
kicker: "Algo deu errado",
title: "O Paseo encontrou um problema.",
body: "Tente novamente para recarregar o app. Se isso continuar acontecendo, inclua os detalhes abaixo ao relatar o problema.",
details: "Detalhes",
},
startup: {
errorTitle: "Algo deu errado",
errorDescription:
@@ -1429,6 +1443,7 @@ export const ptBR: TranslationResources = {
},
backToWorkspace: "Voltar",
addHost: "Adicionar host",
enableBuiltInDaemon: "Ativar o daemon integrado",
projects: "Projetos",
projectList: {
hostLoadFailed: "Não foi possível carregar projetos do host {{hostName}}: {{message}}",
@@ -1437,7 +1452,6 @@ export const ptBR: TranslationResources = {
groupInfo: "Sobre {{title}}",
sections: {
general: "Geral",
daemon: "Daemon",
appearance: "Aparência",
shortcuts: "Atalhos",
integrations: "Integrações",
@@ -1452,7 +1466,7 @@ export const ptBR: TranslationResources = {
providers: "Provedores",
usage: "Uso",
terminals: "Terminais",
host: "Host",
host: "Visão geral",
},
general: {
title: "Geral",
@@ -1482,6 +1496,11 @@ export const ptBR: TranslationResources = {
description: "Linhas mantidas no buffer do terminal integrado",
accessibilityLabel: "Linhas do scrollback do terminal",
},
autoExpandReasoning: {
label: "Sempre expandir raciocínio",
description:
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
},
language: {
label: "Idioma",
description: "Idioma do app",
@@ -1563,6 +1582,9 @@ export const ptBR: TranslationResources = {
auto: "Sistema",
},
},
detailLevel: {
title: "Nível de detalhe",
},
fonts: {
title: "Fontes",
systemDefault: "Sistema padrão",
+24 -2
View File
@@ -726,11 +726,19 @@ export const ru: TranslationResources = {
tooLarge: "Разница слишком велика для отображения",
unified: "Единый дифференциал",
split: "Параллельная разница",
switchToUnified: "Переключить на объединенный diff",
switchToSplit: "Переключить на diff рядом",
showTreeView: "Показать дерево папок",
showFlatView: "Показать плоский список файлов",
options: "Параметры diff",
hideWhitespace: "Скрыть пробелы",
showWhitespace: "Показать пробелы",
scrollLongLines: "Прокручивать длинные строки",
wrapLongLines: "Перенос длинных строк",
collapseAll: "Свернуть все файлы",
expandAll: "Развернуть все файлы",
collapseAllFolders: "Свернуть все папки",
expandAllFolders: "Развернуть все папки",
refreshing: "Освежающий",
refresh: "Обновить",
refreshState: "Обновить состояние git и GitHub.",
@@ -1074,6 +1082,12 @@ export const ru: TranslationResources = {
},
},
},
rootError: {
kicker: "Что-то пошло не так",
title: "В Paseo возникла проблема.",
body: "Попробуйте снова перезагрузить приложение. Если это повторяется, приложите приведенные ниже подробности к отчету.",
details: "Подробности",
},
startup: {
errorTitle: "Что- то пошло не так",
errorDescription:
@@ -1435,6 +1449,7 @@ export const ru: TranslationResources = {
},
backToWorkspace: "Назад",
addHost: "Добавить хост",
enableBuiltInDaemon: "Включить встроенный демон",
projects: "Проекты",
projectList: {
hostLoadFailed: "Не удалось загрузить проекты с хоста{{hostName}}:{{message}}.",
@@ -1443,7 +1458,6 @@ export const ru: TranslationResources = {
groupInfo: "О{{title}}",
sections: {
general: "Общий",
daemon: "Daemon",
appearance: "Появление",
shortcuts: "Ярлыки",
integrations: "Интеграции",
@@ -1458,7 +1472,7 @@ export const ru: TranslationResources = {
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
host: "Host",
host: "Обзор",
},
general: {
title: "Общий",
@@ -1487,6 +1501,11 @@ export const ru: TranslationResources = {
description: "Строки, хранящиеся во встроенном буфере терминала.",
accessibilityLabel: "Линии прокрутки Terminal",
},
autoExpandReasoning: {
label: "Всегда разворачивать размышления",
description:
"По умолчанию показывать блоки размышлений и логики агента полностью развернутыми",
},
language: {
label: "Язык",
description: "Язык приложения",
@@ -1569,6 +1588,9 @@ export const ru: TranslationResources = {
auto: "Система",
},
},
detailLevel: {
title: "Уровень детализации",
},
fonts: {
title: "Шрифты",
systemDefault: "Система по умолчанию",
+23 -2
View File
@@ -701,11 +701,19 @@ export const zhCN: TranslationResources = {
tooLarge: "Diff 过大,无法显示",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "切换到统一差异视图",
switchToSplit: "切换到并排差异视图",
showTreeView: "显示文件夹树",
showFlatView: "显示平铺文件列表",
options: "Diff 选项",
hideWhitespace: "隐藏空白差异",
showWhitespace: "显示空白差异",
scrollLongLines: "滚动长行",
wrapLongLines: "自动换行长行",
collapseAll: "折叠所有文件",
expandAll: "展开所有文件",
collapseAllFolders: "折叠所有文件夹",
expandAllFolders: "展开所有文件夹",
refreshing: "正在刷新",
refresh: "刷新",
refreshState: "刷新 git 和 GitHub 状态",
@@ -1034,6 +1042,12 @@ export const zhCN: TranslationResources = {
},
},
},
rootError: {
kicker: "出现问题",
title: "Paseo 遇到了问题。",
body: "请重试以重新加载应用。如果问题持续发生,请在报告时附上下面的详细信息。",
details: "详情",
},
startup: {
errorTitle: "出现问题",
errorDescription: "本地服务器启动失败。如果持续发生,请在 GitHub 报告问题并附上下方日志。",
@@ -1387,6 +1401,7 @@ export const zhCN: TranslationResources = {
},
backToWorkspace: "返回",
addHost: "添加主机",
enableBuiltInDaemon: "启用内置 daemon",
projects: "项目",
projectList: {
hostLoadFailed: "无法从 Host {{hostName}} 加载 projects:{{message}}",
@@ -1395,7 +1410,6 @@ export const zhCN: TranslationResources = {
groupInfo: "关于 {{title}}",
sections: {
general: "通用",
daemon: "Daemon",
appearance: "外观",
shortcuts: "快捷键",
integrations: "集成",
@@ -1410,7 +1424,7 @@ export const zhCN: TranslationResources = {
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
host: "Host",
host: "概览",
},
general: {
title: "通用",
@@ -1439,6 +1453,10 @@ export const zhCN: TranslationResources = {
description: "内置终端缓冲区保留的行数",
accessibilityLabel: "终端回滚行数",
},
autoExpandReasoning: {
label: "始终展开推理过程",
description: "默认情况下完全展开 AI 的思考和推理过程",
},
language: {
label: "语言",
description: "应用语言",
@@ -1520,6 +1538,9 @@ export const zhCN: TranslationResources = {
auto: "系统",
},
},
detailLevel: {
title: "详细程度",
},
fonts: {
title: "字体",
systemDefault: "系统默认",
@@ -58,10 +58,8 @@ export function useProviderUsage(
});
const refresh = useCallback(async () => {
if (!canFetch) return;
await queryClient.invalidateQueries({ queryKey });
if (!canFetch) {
return;
}
await queryClient.fetchQuery({
queryKey,
queryFn,
+38 -2
View File
@@ -1775,6 +1775,41 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("preserves a manual host rename when desktop status re-advertises the daemon hostname", async () => {
const advertisedHostname = "macbook-pro.local";
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: advertisedHostname,
}),
getClientId: async () => "cid_test_runtime",
},
storage: createMemoryHostRuntimeStorage(),
});
try {
await store.upsertConnectionFromListen({
listenAddress: "127.0.0.1:6767",
serverId: "srv_desktop",
hostname: advertisedHostname,
});
await store.renameHost("srv_desktop", "mac-dev");
await store.upsertConnectionFromListen({
listenAddress: "127.0.0.1:6767",
serverId: "srv_desktop",
hostname: advertisedHostname,
});
expect(store.getHosts().find((h) => h.serverId === "srv_desktop")?.label).toBe("mac-dev");
} finally {
store.syncHosts([]);
}
});
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
@@ -1982,7 +2017,7 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
@@ -1993,6 +2028,7 @@ describe("HostRuntimeStore", () => {
}),
getClientId: async () => "cid_test_runtime",
},
storage: createMemoryHostRuntimeStorage(),
});
await store.upsertRelayConnection({
@@ -2005,7 +2041,7 @@ describe("HostRuntimeStore", () => {
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
expect(pairedHost?.label).toBe("Custom name");
store.syncHosts([]);
});
+31 -6
View File
@@ -72,7 +72,6 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -100,6 +99,10 @@ import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import {
type EnableBuiltInDaemonOption,
useEnableBuiltInDaemonOption,
} from "@/desktop/hooks/use-enable-built-in-daemon-option";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildOpenProjectRoute,
@@ -131,7 +134,6 @@ interface SidebarSectionItem {
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
{ id: "daemon", labelKey: "settings.sections.daemon", icon: Server, desktopOnly: true },
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
{
@@ -157,13 +159,13 @@ interface HostSectionItem {
}
const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
];
function renderHostSettingsContent(
@@ -893,6 +895,7 @@ interface HostPickerProps {
sortedHosts: HostProfile[];
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
enableBuiltInDaemonOption: EnableBuiltInDaemonOption;
}
/**
@@ -902,7 +905,13 @@ interface HostPickerProps {
* is using right now; an "Add host" row is always reachable from the list —
* even with a single host.
*/
function HostPicker({ activeServerId, sortedHosts, onSelectHost, onAddHost }: HostPickerProps) {
function HostPicker({
activeServerId,
sortedHosts,
onSelectHost,
onAddHost,
enableBuiltInDaemonOption,
}: HostPickerProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
@@ -932,6 +941,8 @@ function HostPicker({ activeServerId, sortedHosts, onSelectHost, onAddHost }: Ho
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
includeEnableBuiltInDaemon={enableBuiltInDaemonOption.visible}
onEnableBuiltInDaemon={enableBuiltInDaemonOption.onPress}
showActiveConnection
searchable={false}
title={t("settings.hostPicker.switchHost")}
@@ -990,6 +1001,7 @@ function SettingsSidebar({
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hasHosts = sortedHosts.length > 0;
const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption();
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
@@ -1036,6 +1048,7 @@ function SettingsSidebar({
sortedHosts={sortedHosts}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
enableBuiltInDaemonOption={enableBuiltInDaemonOption}
/>
{HOST_SECTION_ITEMS.map((item) => (
<SidebarHostSectionButton
@@ -1062,6 +1075,20 @@ function SettingsSidebar({
{t("settings.addHost")}
</Text>
</Pressable>
{enableBuiltInDaemonOption.visible ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.enableBuiltInDaemon")}
onPress={enableBuiltInDaemonOption.onPress}
testID="settings-enable-built-in-daemon"
style={sidebarItemStyle}
>
<Server size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
{t("settings.enableBuiltInDaemon")}
</Text>
</Pressable>
) : null}
</View>
)}
</>
@@ -1387,8 +1414,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "daemon":
return <LocalDaemonSection />;
case "appearance":
return <AppearanceSection />;
case "shortcuts":
@@ -16,6 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import {
MAX_CODE_FONT_SIZE,
@@ -189,6 +190,28 @@ function ThemeRow({ value, onChange }: ThemeRowProps) {
);
}
interface AutoExpandReasoningRowProps {
value: boolean;
onChange: (value: boolean) => void;
}
function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) {
const { t } = useTranslation();
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.autoExpandReasoning.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.autoExpandReasoning.description")}
</Text>
</View>
<Switch value={value} onValueChange={onChange} />
</View>
);
}
// ---------------------------------------------------------------------------
// Fonts: family text fields + numeric size fields (commit on blur/submit)
// ---------------------------------------------------------------------------
@@ -397,6 +420,13 @@ export function AppearanceSection() {
[updateSettings],
);
const handleAutoExpandReasoningChange = useCallback(
(autoExpandReasoning: boolean) => {
void updateSettings({ autoExpandReasoning });
},
[updateSettings],
);
const commitUiFontFamily = useCallback(
(value: string) => {
const sanitized = sanitizeFontFamily(value);
@@ -477,6 +507,14 @@ export function AppearanceSection() {
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
<View style={settingsStyles.card}>
<AutoExpandReasoningRow
value={settings.autoExpandReasoning}
onChange={handleAutoExpandReasoningChange}
/>
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.fonts.title")}>
<View style={settingsStyles.card}>
{showFontFamilyRows ? (
+16 -1
View File
@@ -72,6 +72,11 @@ export interface PanelState {
explorerTabByCheckout: Record<string, ExplorerTab>;
expandedPathsByWorkspace: Record<string, string[]>;
diffExpandedPathsByWorkspace: Record<string, string[]>;
// Changes-view folder tree. Inverted semantics vs the fields above:
// this stores COLLAPSED directory paths (empty = all folders expanded), keyed
// by full uncompressed dir path, so folders default to expanded and new
// folders stay expanded as the diff changes.
diffCollapsedFoldersByWorkspace: Record<string, string[]>;
sidebarWidth: number;
explorerWidth: number;
explorerSortOption: SortOption;
@@ -98,6 +103,7 @@ export interface PanelState {
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void;
setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setSidebarWidth: (width: number) => void;
setExplorerWidth: (width: number) => void;
@@ -126,6 +132,7 @@ export const usePanelStore = create<PanelState>()(
explorerTabByCheckout: {},
expandedPathsByWorkspace: {},
diffExpandedPathsByWorkspace: {},
diffCollapsedFoldersByWorkspace: {},
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
@@ -253,6 +260,13 @@ export const usePanelStore = create<PanelState>()(
[workspaceKey]: paths,
},
})),
setDiffCollapsedFoldersForWorkspace: (workspaceKey, dirPaths) =>
set((state) => ({
diffCollapsedFoldersByWorkspace: {
...state.diffCollapsedFoldersByWorkspace,
[workspaceKey]: dirPaths,
},
})),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
explorerTab: resolveExplorerTabForCheckout({
@@ -276,7 +290,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 11,
version: 12,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
@@ -287,6 +301,7 @@ export const usePanelStore = create<PanelState>()(
explorerTabByCheckout: state.explorerTabByCheckout,
expandedPathsByWorkspace: state.expandedPathsByWorkspace,
diffExpandedPathsByWorkspace: state.diffExpandedPathsByWorkspace,
diffCollapsedFoldersByWorkspace: state.diffCollapsedFoldersByWorkspace,
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
@@ -103,6 +103,20 @@ describe("panel-store migration", () => {
expect(state.explorerShowHiddenFiles).toBe(true);
});
it("initializes diffCollapsedFoldersByWorkspace for pre-v12 state", () => {
const state = migratePanelState({}, 11, { isWeb: false });
expect(state.diffCollapsedFoldersByWorkspace).toEqual({});
});
it("preserves an existing diffCollapsedFoldersByWorkspace map", () => {
const state = migratePanelState({ diffCollapsedFoldersByWorkspace: { ws: ["src/app"] } }, 12, {
isWeb: false,
});
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
});
});
describe("panel-store visibility selectors", () => {
@@ -245,6 +245,13 @@ export function migratePanelState(
) {
state.diffExpandedPathsByWorkspace = {};
}
if (
version < 12 ||
typeof state.diffCollapsedFoldersByWorkspace !== "object" ||
!state.diffCollapsedFoldersByWorkspace
) {
state.diffCollapsedFoldersByWorkspace = {};
}
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
+1 -1
View File
@@ -165,7 +165,7 @@ export function upsertHostConnectionInProfiles(input: {
input.connection,
]);
const nextLifecycle = prev.lifecycle;
const nextLabel = labelTrimmed || (prev.label === prev.serverId ? derivedLabel : prev.label);
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
const nextPreferredConnectionId =
prev.preferredConnectionId &&
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
@@ -15,6 +15,7 @@ import {
decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment,
encodeWorkspaceIdForPathSegment,
isSettingsSectionSlug,
normalizeHostSectionSlug,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
@@ -266,6 +267,12 @@ describe("host settings section slugs", () => {
});
});
describe("settings section slugs", () => {
it("no longer treats daemon as a valid app-level settings section", () => {
expect(isSettingsSectionSlug("daemon")).toBe(false);
});
});
describe("resolveKnownHostRoute", () => {
it("renders when the route host is still saved", () => {
expect(
-1
View File
@@ -482,7 +482,6 @@ export function resolveKnownHostRoute(input: {
export const SETTINGS_SECTION_SLUGS = [
"general",
"daemon",
"appearance",
"shortcuts",
"integrations",
@@ -1,6 +1,7 @@
export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
onError?(error: Error): void;
}
+22 -1
View File
@@ -116,6 +116,21 @@ export function createAudioEngine(
callbacks.onVolumeLevel(level);
},
);
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
if (event.data !== "blocked") {
return;
}
const wasCaptureActive = refs.captureActive;
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
if (wasCaptureActive) {
callbacks.onInterruption?.();
}
},
);
async function ensureInitialized(): Promise<void> {
if (refs.initialized) {
@@ -234,6 +249,7 @@ export function createAudioEngine(
}
microphoneSubscription.remove();
volumeSubscription.remove();
interruptionSubscription.remove();
},
async startCapture() {
@@ -244,7 +260,12 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
native.toggleRecording(true);
const isRecording = native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
);
}
refs.captureActive = true;
} catch (error) {
const wrapped = error instanceof Error ? error : new Error(String(error));
@@ -423,6 +423,22 @@ describe("voice runtime", () => {
expect(runtime.shouldPlayVoiceAudio("server-1")).toBe(false);
});
it("rolls daemon voice mode back when capture fails after enabling it", async () => {
const adapter = createSessionAdapter();
const engine = createAudioEngineMock();
vi.mocked(engine.startCapture).mockRejectedValue(new Error("audio focus unavailable"));
const { runtime } = createRuntime({ engine });
runtime.registerSession(adapter);
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
"audio focus unavailable",
);
expect(adapter.setVoiceMode).toHaveBeenNthCalledWith(1, true, "agent-1");
expect(adapter.setVoiceMode).toHaveBeenNthCalledWith(2, false);
expect(runtime.getSnapshot().phase).toBe("disabled");
});
it("returns an explicit not-ready error when the adapter is missing", async () => {
const { runtime } = createRuntime();
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
+5
View File
@@ -730,6 +730,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
const previousServerId = state.snapshot.activeServerId;
const previousAgentId = state.snapshot.activeAgentId;
const generation = state.generation + 1;
let enabledCurrentVoiceMode = false;
state.generation = generation;
state.transportReady = false;
patchSnapshot((prev) => ({
@@ -759,6 +760,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
await deps.engine.initialize();
await session.adapter.setVoiceMode(true, agentId);
enabledCurrentVoiceMode = true;
await deps.engine.startCapture();
if (state.generation !== generation) {
return;
@@ -776,6 +778,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
isMuted: deps.engine.isMuted(),
}));
} catch (error) {
if (enabledCurrentVoiceMode) {
await session.adapter.setVoiceMode(false).catch(() => undefined);
}
await performLocalStop();
throw error;
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.5",
"@getpaseo/protocol": "0.1.104-beta.5",
"@getpaseo/server": "0.1.104-beta.5",
"@getpaseo/client": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/server": "0.1.104",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.5",
"@getpaseo/relay": "0.1.104-beta.5",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -83,7 +83,9 @@ class AudioEngine (context: Context) {
private fun initializeAudio(context:Context) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
requestAudioFocus()
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
}
// Route audio to external device if connected, otherwise route to speaker
updateAudioRouting()
@@ -168,7 +170,12 @@ class AudioEngine (context: Context) {
}
@SuppressLint("NewApi")
private fun requestAudioFocus() {
private fun requestAudioFocus(): Boolean {
audioFocusRequest?.let { request ->
audioManager.abandonAudioFocusRequest(request)
audioFocusRequest = null
}
val focusRequest =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
@@ -180,9 +187,19 @@ class AudioEngine (context: Context) {
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d("AudioEngine", "Audio focus gained")
}
AudioManager.AUDIOFOCUS_LOSS -> {
Log.d("AudioEngine", "Audio focus lost")
onAudioInterruptionCallback?.let { it("blocked") }
handleAudioFocusBlocked()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Log.d("AudioEngine", "Audio focus lost transiently")
handleAudioFocusBlocked()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Log.d("AudioEngine", "Audio focus duck requested")
}
}
}
@@ -191,14 +208,40 @@ class AudioEngine (context: Context) {
audioFocusRequest = focusRequest
val result = audioManager.requestAudioFocus(focusRequest)
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
throw RuntimeException("Audio focus request failed")
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
return true
}
Log.w("AudioEngine", "Audio focus request was not granted: $result")
return false
}
private fun handleAudioFocusBlocked() {
if (isRecording) {
stopRecording()
}
audioFocusRequest?.let { request ->
audioManager.abandonAudioFocusRequest(request)
audioFocusRequest = null
}
if (::audioTrack.isInitialized) {
audioTrack.pause()
}
audioSampleQueue.clear()
isPlaying = false
isRecordingBeforePause = false
onOutputVolumeCallback?.invoke(0.0F)
onAudioInterruptionCallback?.let { it("blocked") }
}
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
private fun startRecording(){
private fun startRecording(): Boolean {
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return false
}
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
@@ -231,6 +274,7 @@ class AudioEngine (context: Context) {
audioRecord.startRecording()
isRecording = true
startMicSampleTap()
return true
}
private fun startMicSampleTap(){
@@ -274,7 +318,7 @@ class AudioEngine (context: Context) {
if (value == isRecording) return isRecording
if (value) {
startRecording()
return startRecording()
} else {
stopRecording()
}
@@ -353,8 +397,15 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
fun resumeRecordingAndPlayer() {
requestAudioFocus()
isRecording = toggleRecording(isRecordingBeforePause)
if (!requestAudioFocus()) {
handleAudioFocusBlocked()
return
}
val wasRecordingBeforePause = isRecordingBeforePause
isRecording = toggleRecording(wasRecordingBeforePause)
if (wasRecordingBeforePause && !isRecording) {
return
}
audioTrack.play()
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -44,6 +44,7 @@
"start": "node dist/scripts/supervisor-entrypoint.js",
"typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
@@ -65,10 +66,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.5",
"@getpaseo/highlight": "0.1.104-beta.5",
"@getpaseo/protocol": "0.1.104-beta.5",
"@getpaseo/relay": "0.1.104-beta.5",
"@getpaseo/client": "0.1.104",
"@getpaseo/highlight": "0.1.104",
"@getpaseo/protocol": "0.1.104",
"@getpaseo/relay": "0.1.104",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -0,0 +1,384 @@
import { Buffer } from "node:buffer";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import pino from "pino";
import { createAgentMcpServer } from "../src/server/agent/mcp-server.js";
import type { AgentManager, ManagedAgent } from "../src/server/agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "../src/server/agent/agent-storage.js";
import type { AgentProvider, ProviderSnapshotEntry } from "../src/server/agent/agent-sdk-types.js";
import type { ProviderSnapshotManager } from "../src/server/agent/provider-snapshot-manager.js";
import type { BrowserToolsBroker } from "../src/server/browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../src/server/browser-tools/errors.js";
type CatalogScope = "agent" | "top-level";
interface CliOptions {
format: "markdown" | "json";
scope: CatalogScope;
top: number;
}
interface FieldSize {
field: string;
bytes: number;
estimatedTokens: number;
}
interface ToolSize {
name: string;
bytes: number;
estimatedTokens: number;
fields: FieldSize[];
}
interface CatalogMeasurement {
label: string;
browserToolsEnabled: boolean;
toolCount: number;
bytes: number;
estimatedTokens: number;
withoutOutputSchemas: {
bytes: number;
estimatedTokens: number;
savedBytes: number;
savedEstimatedTokens: number;
};
fields: FieldSize[];
tools: ToolSize[];
}
const DEFAULT_TOP_COUNT = 15;
const MEASUREMENT_AGENT_ID = "agent-tools-measurement";
const MEASUREMENT_CWD = "/tmp/paseo-agent-tools-measurement";
const MEASUREMENT_WORKSPACE_ID = "workspace_agent_tools_measurement";
const FIELD_NAMES = [
"name",
"title",
"description",
"inputSchema",
"outputSchema",
"annotations",
"execution",
"_meta",
] as const;
function parseCliOptions(args: string[]): CliOptions {
let format: CliOptions["format"] = "markdown";
let scope: CatalogScope = "agent";
let top = DEFAULT_TOP_COUNT;
for (const arg of args) {
if (arg === "--json") {
format = "json";
continue;
}
if (arg === "--markdown") {
format = "markdown";
continue;
}
if (arg.startsWith("--scope=")) {
const value = arg.slice("--scope=".length);
if (value !== "agent" && value !== "top-level") {
throw new Error("--scope must be agent or top-level");
}
scope = value;
continue;
}
if (arg.startsWith("--top=")) {
const value = Number.parseInt(arg.slice("--top=".length), 10);
if (!Number.isInteger(value) || value < 1) {
throw new Error("--top must be a positive integer");
}
top = value;
continue;
}
if (arg === "--help") {
printHelp();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return { format, scope, top };
}
function printHelp(): void {
process.stdout.write(`Usage: npm run measure:agent-tools -- [options]
Measures the MCP tools/list payload for Paseo agent tools.
Options:
--scope=agent|top-level Catalog shape to measure. Defaults to agent.
--top=N Number of largest tools to show. Defaults to ${DEFAULT_TOP_COUNT}.
--json Emit machine-readable JSON.
--markdown Emit Markdown. This is the default.
`);
}
function estimateTokens(text: string): number {
return Math.ceil(Buffer.byteLength(text, "utf8") / 4);
}
function measureJson(value: unknown): { bytes: number; estimatedTokens: number } {
const text = JSON.stringify(value);
return {
bytes: Buffer.byteLength(text, "utf8"),
estimatedTokens: estimateTokens(text),
};
}
function measureField(field: string, value: unknown): FieldSize {
return {
field,
...measureJson({ [field]: value }),
};
}
function stripOutputSchemas(tools: Tool[]): Tool[] {
return tools.map((tool) => {
const { outputSchema: _outputSchema, ...rest } = tool;
return rest;
});
}
function measureTool(tool: Tool): ToolSize {
const fields = FIELD_NAMES.flatMap((field) =>
Object.hasOwn(tool, field) ? [measureField(field, tool[field])] : [],
);
return {
name: tool.name,
...measureJson(tool),
fields,
};
}
function sumFieldSizes(tools: ToolSize[]): FieldSize[] {
const totals = new Map<string, { bytes: number; estimatedTokens: number }>();
for (const tool of tools) {
for (const field of tool.fields) {
const current = totals.get(field.field) ?? { bytes: 0, estimatedTokens: 0 };
totals.set(field.field, {
bytes: current.bytes + field.bytes,
estimatedTokens: current.estimatedTokens + field.estimatedTokens,
});
}
}
return [...totals.entries()]
.map(([field, size]) => ({
field,
bytes: size.bytes,
estimatedTokens: size.estimatedTokens,
}))
.sort((a, b) => b.bytes - a.bytes);
}
async function measureCatalog(params: {
label: string;
browserToolsEnabled: boolean;
scope: CatalogScope;
}): Promise<CatalogMeasurement> {
const server = await createAgentMcpServer({
agentManager: new MeasurementAgentManager() as unknown as AgentManager,
agentStorage: new MeasurementAgentStorage() as unknown as AgentStorage,
providerSnapshotManager:
new MeasurementProviderSnapshotManager() as unknown as ProviderSnapshotManager,
browserToolsEnabled: params.browserToolsEnabled,
browserToolsBroker: new MeasurementBrowserToolsBroker() as unknown as BrowserToolsBroker,
callerAgentId: params.scope === "agent" ? MEASUREMENT_AGENT_ID : undefined,
logger: pino({ level: "silent" }),
});
const client = new Client({ name: "paseo-agent-tools-measurement", version: "0.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const result = await client.listTools();
const size = measureJson(result.tools);
const strippedSize = measureJson(stripOutputSchemas(result.tools));
const tools = result.tools.map(measureTool).sort((a, b) => b.bytes - a.bytes);
return {
label: params.label,
browserToolsEnabled: params.browserToolsEnabled,
toolCount: result.tools.length,
...size,
withoutOutputSchemas: {
...strippedSize,
savedBytes: size.bytes - strippedSize.bytes,
savedEstimatedTokens: size.estimatedTokens - strippedSize.estimatedTokens,
},
fields: sumFieldSizes(tools),
tools,
};
} finally {
await client.close();
await server.close();
}
}
function formatInteger(value: number): string {
return value.toLocaleString("en-US");
}
function formatMarkdown(measurements: CatalogMeasurement[], top: number): string {
const [withoutBrowser, withBrowser] = measurements;
const delta =
withoutBrowser && withBrowser
? {
tools: withBrowser.toolCount - withoutBrowser.toolCount,
bytes: withBrowser.bytes - withoutBrowser.bytes,
estimatedTokens: withBrowser.estimatedTokens - withoutBrowser.estimatedTokens,
}
: null;
const lines: string[] = [];
lines.push("# Paseo Agent Tool Catalog Context");
lines.push("");
lines.push("Token counts are estimates from compact JSON bytes / 4.");
lines.push("");
lines.push("| Catalog | Tools | Bytes | Estimated tokens | Output-schema savings |");
lines.push("| --- | ---: | ---: | ---: | ---: |");
for (const measurement of measurements) {
lines.push(
`| ${measurement.label} | ${formatInteger(measurement.toolCount)} | ${formatInteger(
measurement.bytes,
)} | ${formatInteger(measurement.estimatedTokens)} | ${formatInteger(
measurement.withoutOutputSchemas.savedEstimatedTokens,
)} est. tokens |`,
);
}
if (delta) {
lines.push(
`| Browser-tool delta | +${formatInteger(delta.tools)} | +${formatInteger(
delta.bytes,
)} | +${formatInteger(delta.estimatedTokens)} | |`,
);
}
for (const measurement of measurements) {
lines.push("");
lines.push(`## ${measurement.label}`);
lines.push("");
lines.push("### Field totals");
lines.push("");
lines.push("| Field | Bytes | Estimated tokens |");
lines.push("| --- | ---: | ---: |");
for (const field of measurement.fields) {
lines.push(
`| ${field.field} | ${formatInteger(field.bytes)} | ${formatInteger(
field.estimatedTokens,
)} |`,
);
}
lines.push("");
lines.push(`### Largest ${Math.min(top, measurement.tools.length)} tools`);
lines.push("");
lines.push("| Tool | Bytes | Estimated tokens | Description | Input schema | Output schema |");
lines.push("| --- | ---: | ---: | ---: | ---: | ---: |");
for (const tool of measurement.tools.slice(0, top)) {
const fields = new Map(tool.fields.map((field) => [field.field, field.estimatedTokens]));
lines.push(
`| ${tool.name} | ${formatInteger(tool.bytes)} | ${formatInteger(
tool.estimatedTokens,
)} | ${formatInteger(fields.get("description") ?? 0)} | ${formatInteger(
fields.get("inputSchema") ?? 0,
)} | ${formatInteger(fields.get("outputSchema") ?? 0)} |`,
);
}
}
lines.push("");
return `${lines.join("\n")}\n`;
}
class MeasurementAgentManager {
private readonly agent = {
id: MEASUREMENT_AGENT_ID,
cwd: MEASUREMENT_CWD,
workspaceId: MEASUREMENT_WORKSPACE_ID,
provider: "codex",
currentModeId: "default",
config: {
provider: "codex",
cwd: MEASUREMENT_CWD,
},
};
public getAgent(agentId: string): ManagedAgent | null {
return agentId === MEASUREMENT_AGENT_ID ? (this.agent as unknown as ManagedAgent) : null;
}
public listAgents(): ManagedAgent[] {
return [];
}
}
class MeasurementAgentStorage {
public async list(): Promise<StoredAgentRecord[]> {
return [];
}
}
class MeasurementProviderSnapshotManager {
public listRegisteredProviderIds(): AgentProvider[] {
return [];
}
public hasProvider(): boolean {
return false;
}
public getProviderLabel(provider: AgentProvider): string {
return provider;
}
public async listProviders(): Promise<ProviderSnapshotEntry[]> {
return [];
}
public async getProvider(): Promise<ProviderSnapshotEntry> {
throw new Error("Provider catalog is not used by the measurement script");
}
public async listModels(): Promise<[]> {
return [];
}
public async listModes(): Promise<[]> {
return [];
}
}
class MeasurementBrowserToolsBroker {
public async execute(): Promise<BrowserToolsResponsePayload> {
throw new Error("Browser tools are not executed by the measurement script");
}
}
async function main(): Promise<void> {
const options = parseCliOptions(process.argv.slice(2));
const measurements = await Promise.all([
measureCatalog({
label: "Core tools",
browserToolsEnabled: false,
scope: options.scope,
}),
measureCatalog({
label: "Core + browser tools",
browserToolsEnabled: true,
scope: options.scope,
}),
]);
if (options.format === "json") {
process.stdout.write(`${JSON.stringify({ scope: options.scope, measurements }, null, 2)}\n`);
return;
}
process.stdout.write(formatMarkdown(measurements, options.top));
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
@@ -146,24 +146,21 @@ function createMcpRecordingAgentClients(recorder: LaunchRecorder) {
};
}
async function waitForAgentCompletion(options: {
async function assertAgentNotRunning(options: {
client: McpClient;
agentId: string;
}): Promise<void> {
const waitResult = await options.client.callTool({
name: "wait_for_agent",
const statusResult = await options.client.callTool({
name: "get_agent_status",
args: { agentId: options.agentId },
});
const payload = getStructuredContent(waitResult);
const payload = getStructuredContent(statusResult);
if (!payload) {
throw new Error("wait_for_agent returned no structured payload");
}
if (payload.permission) {
throw new Error(`Unexpected permission while waiting: ${JSON.stringify(payload.permission)}`);
throw new Error("get_agent_status returned no structured payload");
}
const status = payload.status;
if (status === "running" || status === "initializing") {
throw new Error(`Agent still running after wait_for_agent (status=${status})`);
throw new Error(`Agent still running after blocking create_agent (status=${status})`);
}
}
@@ -218,7 +215,7 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion({ client, agentId: agentId! });
await assertAgentNotRunning({ client, agentId: agentId! });
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
@@ -703,7 +700,7 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion({ client, agentId: agentId! });
await assertAgentNotRunning({ client, agentId: agentId! });
const statusResult = await client.callTool({
name: "get_agent_status",
args: { agentId },
@@ -6,12 +6,10 @@ import { realpathSync, rmSync } from "node:fs";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
import Ajv from "ajv";
import { z } from "zod";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { createPaseoToolCatalog } from "./tools/paseo-tools.js";
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
@@ -83,7 +81,6 @@ interface LooseContentBlock {
interface RegisteredMcpTool {
inputSchema: LooseInputSchema;
outputSchema?: unknown;
callback?: (
input: unknown,
extra?: unknown,
@@ -136,24 +133,20 @@ async function invokeToolWithParsedInput(
return tool.handler(parsed.data);
}
function expectOutputSchemaAccepts(tool: RegisteredMcpTool, data: unknown): void {
expect(tool.outputSchema).toBeDefined();
const jsonSchema = z.toJSONSchema(tool.outputSchema as z.ZodType, {
target: "draft-07",
unrepresentable: "any",
io: "input",
});
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(jsonSchema);
expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true);
}
function agentsOf(response: {
structuredContent: LooseStructuredContent;
}): Array<Record<string, unknown>> {
return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents);
}
function expectSingleTextContent(response: { content?: LooseContentBlock[] }): string {
const content = response.content ?? [];
expect(content).toHaveLength(1);
const block = content[0];
expect(block?.type).toBe("text");
return z.string().min(1).parse(block?.text);
}
async function waitForWorkspaceTitle(
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
workspaceId: string,
@@ -753,7 +746,7 @@ function createPaseoWorktreeForMcpTest(options: {
describe("browser MCP tools", () => {
const logger = createTestLogger();
it("calls registered tools through the MCP SDK with listed output schemas", async () => {
it("omits output schemas from tools/list and keeps tool call content model-visible", async () => {
const agentManager = new BoundaryAgentManagerFake();
const agentStorage = new BoundaryAgentStorageFake();
const broker = new FakeBrowserToolsBroker({
@@ -802,17 +795,15 @@ describe("browser MCP tools", () => {
expect(listAgentsResult.structuredContent).toEqual({
agents: [],
});
expectSingleTextContent(browserResult);
expect(expectSingleTextContent(listAgentsResult)).toContain('"agents": []');
const listedTools = await client.listTools();
const toolsByName = new Map(listedTools.tools.map((tool) => [tool.name, tool]));
const catalog = createPaseoToolCatalog(serverOptions);
for (const tool of catalog.tools.values()) {
if (tool.outputSchema !== undefined) {
expect(toolsByName.get(tool.name)?.outputSchema, `${tool.name} outputSchema`).toEqual(
expect.objectContaining({ type: "object" }),
);
}
expect(listedTools.tools.map((tool) => tool.name)).toEqual(
expect.arrayContaining(["browser_list_tabs", "list_agents"]),
);
for (const tool of listedTools.tools) {
expect(tool, `${tool.name} outputSchema`).not.toHaveProperty("outputSchema");
}
} finally {
await client.close();
@@ -1308,7 +1299,7 @@ describe("create_agent MCP tool", () => {
);
});
it("advertises create_agent output schema that accepts full provider modes", async () => {
it("returns create_agent structured content with full provider modes", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "mode-agent",
@@ -1344,7 +1335,24 @@ describe("create_agent MCP tool", () => {
background: true,
});
expectOutputSchemaAccepts(tool, response.structuredContent);
expect(response.structuredContent).toEqual(
expect.objectContaining({
agentId: "mode-agent",
type: "codex",
status: "idle",
cwd: REPO_CWD,
currentModeId: "build",
availableModes: [
{
id: "build",
label: "Build",
description: null,
icon: "hammer",
colorTier: "dangerous",
},
],
}),
);
});
it("requires provider as provider/model and rejects the old model field", async () => {
@@ -2904,7 +2912,7 @@ describe("create_agent MCP tool", () => {
});
expect(response.structuredContent.guidance).toBe(
"You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
"You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
);
});
@@ -3364,7 +3372,7 @@ describe("send_agent_prompt MCP tool", () => {
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
expect(response.structuredContent.guidance).toBe(
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
);
});
@@ -3816,7 +3824,7 @@ describe("create_schedule MCP tool", () => {
);
});
it("advertises create_schedule output schema that accepts inherited feature values", async () => {
it("returns create_schedule structured content with inherited feature values", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
@@ -3854,7 +3862,6 @@ describe("create_schedule MCP tool", () => {
type: "new-agent",
config: { featureValues: { auto_accept: true } },
});
expectOutputSchemaAccepts(tool, response.structuredContent);
});
it("passes timezone through cron create_schedule input", async () => {
@@ -4872,7 +4879,6 @@ describe("agent snapshot MCP serialization", () => {
`get_agent_status response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`,
);
}
expectOutputSchemaAccepts(tool, response.structuredContent);
expect(response.structuredContent.status).toBe("idle");
expect(snapshot).toEqual(
expect.objectContaining({
@@ -5200,7 +5206,7 @@ describe("agent snapshot MCP serialization", () => {
]);
});
it("emits list_agents payloads that satisfy the declared output schema", async () => {
it("emits list_agents payloads that satisfy the agent list schema", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]);
@@ -5231,7 +5237,7 @@ describe("agent snapshot MCP serialization", () => {
}
});
it("emits list_pending_permissions payloads that satisfy the declared output schema", async () => {
it("emits list_pending_permissions payloads that satisfy the permission schema", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listAgents.mockReturnValue([
createManagedAgent({
@@ -5288,7 +5294,6 @@ describe("agent snapshot MCP serialization", () => {
},
},
]);
expectOutputSchemaAccepts(tool, response.structuredContent);
});
it("loads archived agents before reading get_agent_activity", async () => {
@@ -85,7 +85,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
title: tool.title,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema,
},
async (args: unknown, context?: McpToolContext) =>
toMcpToolResult(await catalog.executeTool(tool.name, args, { signal: context?.signal })),
@@ -145,7 +145,7 @@ export async function waitForAgentWithTimeout(
});
const recentActivity = curateAgentActivity(recent.items);
const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000);
const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - it is still running. Call get_agent_status to check on it, or continue with other work if you will receive a finish notification.\n\nRecent activity:\n${recentActivity}`;
return {
status: snapshot?.lifecycle ?? "idle",
permission: null,
@@ -61,16 +61,29 @@ function replyToCommands(
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
try {
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: false,
error: message,
})}\n`,
);
}
}
});
}
@@ -257,4 +270,102 @@ describe("PiCliRuntime", () => {
expect(child.killedSignals).toContain("SIGTERM");
});
test("falls back to get_state when get_session_stats is unsupported", async () => {
const child = createPiChild();
let commandSequence: string[] = [];
replyToCommands(child, (command) => {
commandSequence.push(String(command.type));
if (command.type === "get_session_stats") {
// Simulate older OMP binary that doesn't support this RPC
throw new Error(`Unknown command: ${command.type}`);
}
// get_state returns contextUsage for fallback
return {
sessionId: "pi-session-1",
thinkingLevel: "medium" as const,
isStreaming: false,
isCompacting: false,
steeringMode: "one-at-a-time" as const,
followUpMode: "one-at-a-time" as const,
interruptMode: "immediate" as const,
messageCount: 0,
queuedMessageCount: 0,
todoPhases: [],
contextUsage: { tokens: 1_100, contextWindow: 200_000 },
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const stats = await session.getSessionStats();
expect(stats.contextUsage).toEqual({
tokens: 1_100,
contextWindow: 200_000,
});
expect(commandSequence).toEqual(["get_session_stats", "get_state"]);
});
test("returns full stats from get_session_stats without falling back", async () => {
const child = createPiChild();
let fallbackCalled = false;
replyToCommands(child, (command) => {
if (command.type === "get_state") {
fallbackCalled = true;
}
return {
tokens: { input: 500, output: 300, cacheRead: 100 },
cost: 0.02,
contextUsage: { tokens: 800, contextWindow: 200_000 },
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const stats = await session.getSessionStats();
expect(stats).toMatchObject({
tokens: { input: 500, output: 300, cacheRead: 100 },
cost: 0.02,
contextUsage: { tokens: 800, contextWindow: 200_000 },
});
// Should NOT have called get_state as a fallback
expect(fallbackCalled).toBe(false);
});
test("does not fall back when get_session_stats returns cost:0", async () => {
const child = createPiChild();
let fallbackCalled = false;
replyToCommands(child, (command) => {
if (command.type === "get_state") {
fallbackCalled = true;
}
return {
tokens: { input: 200, output: 100 },
cost: 0,
contextUsage: { tokens: 500, contextWindow: 200_000 },
};
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const stats = await session.getSessionStats();
expect(stats.tokens).toEqual({ input: 200, output: 100 });
expect(stats.cost).toBe(0);
expect(stats.contextUsage).toEqual({ tokens: 500, contextWindow: 200_000 });
// Should NOT have called get_state as a fallback
expect(fallbackCalled).toBe(false);
});
test("returns empty object when both get_session_stats and get_state fail", async () => {
const child = createPiChild();
replyToCommands(child, (command) => {
throw new Error(`Unknown command: ${command.type}`);
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const stats = await session.getSessionStats();
// Neither RPC returned usable data — should resolve with empty object
expect(stats).toEqual({});
});
});
@@ -180,7 +180,35 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async getSessionStats(): Promise<PiSessionStats> {
return (await this.request({ type: "get_session_stats" })) as PiSessionStats;
// COMPAT(piGetStateFallback): added in v0.1.X — older Oh My Pi binaries
// lack the `get_session_stats` RPC command; fall back to extracting
// context window usage from `get_state`. Drop this gate when the floor
// daemon supports get_session_stats (added ~v0.1.97).
let stats: PiSessionStats | undefined;
try {
stats = (await this.request({ type: "get_session_stats" })) as PiSessionStats;
} catch {
// get_session_stats not supported by this binary — will try get_state below
}
if (stats?.tokens == null && stats?.cost == null && stats?.contextUsage == null) {
try {
const state = (await this.request({ type: "get_state" })) as Record<string, unknown>;
const ctx = state.contextUsage as
| { tokens?: number | null; contextWindow?: number | null }
| undefined;
if (ctx) {
return {
contextUsage: {
tokens: typeof ctx.tokens === "number" ? ctx.tokens : undefined,
contextWindow: typeof ctx.contextWindow === "number" ? ctx.contextWindow : undefined,
},
};
}
} catch {
// get_state also failed — nothing we can do
}
}
return stats ?? {};
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
@@ -3,7 +3,7 @@ import { ensureValidJson } from "../../json-utils.js";
import type { Logger } from "pino";
import type { AgentMode, AgentProvider } from "../agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "../agent-manager.js";
import type { AgentManager } from "../agent-manager.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -26,7 +26,6 @@ import {
killTerminalsForWorkspace,
type ArchiveDependencies,
} from "../../workspace-archive-service.js";
import { WaitForAgentTracker } from "../wait-for-agent-tracker.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { FirstAgentContext } from "../../messages.js";
@@ -436,7 +435,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
logger,
} = options;
const childLogger = logger.child({ module: "agent", component: "paseo-tool-catalog" });
const waitTracker = new WaitForAgentTracker(logger);
const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null;
const parseToolInput = async (tool: PaseoToolDefinition, input: unknown): Promise<unknown> => {
@@ -1145,7 +1143,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot;
const guidance =
callerAgentId && notifyOnFinish && initialPromptStarted
? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives."
? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives."
: undefined;
const response = {
content: [],
@@ -1409,83 +1407,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
}
registerTool(
"wait_for_agent",
{
title: "Wait for agent",
description:
"Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.",
inputSchema: {
agentId: z.string().describe("Agent identifier returned by the create_agent tool"),
},
outputSchema: {
agentId: z.string(),
status: AgentStatusEnum,
permission: AgentPermissionRequestPayloadSchema.nullable(),
lastMessage: z.string().nullable(),
},
},
async ({ agentId }, { signal }) => {
const abortController = new AbortController();
const cleanupFns: Array<() => void> = [];
const cleanup = () => {
while (cleanupFns.length) {
const fn = cleanupFns.pop();
try {
fn?.();
} catch {
// ignore cleanup errors
}
}
};
const forwardExternalAbort = () => {
if (!abortController.signal.aborted) {
const reason = signal?.reason ?? new Error("wait_for_agent aborted");
abortController.abort(reason);
}
};
if (signal) {
if (signal.aborted) {
forwardExternalAbort();
} else {
signal.addEventListener("abort", forwardExternalAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort));
}
}
const unregister = waitTracker.register(agentId, (reason) => {
if (!abortController.signal.aborted) {
abortController.abort(new Error(reason ?? "wait_for_agent cancelled"));
}
});
cleanupFns.push(unregister);
try {
const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, {
signal: abortController.signal,
});
const validJson = ensureValidJson({
agentId,
status: result.status,
permission: sanitizePermissionRequest(result.permission),
lastMessage: result.lastMessage,
});
const response = {
content: [],
structuredContent: validJson,
};
return response;
} finally {
cleanup();
}
},
);
registerTool(
"send_agent_prompt",
{
@@ -1508,9 +1429,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
background = Boolean(callerAgentId),
notifyOnFinish = Boolean(callerAgentId),
}) => {
if (agentManager.hasInFlightRun(agentId)) {
waitTracker.cancel(agentId, "Agent run interrupted by new prompt");
}
const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background);
await sendPromptToAgent({
@@ -1565,7 +1483,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
...(shouldNotifyOnFinish
? {
guidance:
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.",
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
}
: {}),
};
@@ -1705,9 +1623,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
{ agentManager, logger: childLogger },
agentId,
);
if (cancelled) {
waitTracker.cancel(agentId, "Agent run cancelled");
}
return {
content: [],
structuredContent: ensureValidJson({ success: cancelled }),
@@ -1737,7 +1652,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
agentId,
);
waitTracker.cancel(agentId, "Agent archived");
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
@@ -1759,7 +1673,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
async ({ agentId }) => {
await closeAgentCommand({ agentManager }, agentId);
waitTracker.cancel(agentId, "Agent terminated");
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
@@ -1,43 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
describe("WaitForAgentTracker", () => {
const logger = createTestLogger();
it("registers and cancels waiters per agent", () => {
const tracker = new WaitForAgentTracker(logger);
const cancelA = vi.fn();
const cancelB = vi.fn();
tracker.register("agent-a", cancelA);
tracker.register("agent-a", cancelB);
expect(tracker.cancel("agent-a", "test")).toBe(true);
expect(cancelA).toHaveBeenCalledWith("test");
expect(cancelB).toHaveBeenCalledWith("test");
// Subsequent cancels should no-op once cleared
expect(tracker.cancel("agent-a")).toBe(false);
// Unregister removes callback without triggering it
const cancelC = vi.fn();
const unregisterC = tracker.register("agent-b", cancelC);
unregisterC();
expect(tracker.cancel("agent-b")).toBe(false);
expect(cancelC).not.toHaveBeenCalled();
});
it("supports cancelling all waiters", () => {
const tracker = new WaitForAgentTracker(logger);
const cancelA = vi.fn();
const cancelB = vi.fn();
tracker.register("agent-a", cancelA);
tracker.register("agent-b", cancelB);
expect(tracker.cancelAll()).toBe(2);
expect(cancelA).toHaveBeenCalled();
expect(cancelB).toHaveBeenCalled();
});
});
@@ -1,62 +0,0 @@
import type { Logger } from "pino";
export type WaitForAgentCanceler = (agentId: string, reason?: string) => boolean;
/**
* Tracks long-running wait_for_agent tool calls so they can be cancelled
* explicitly (e.g., when a voice barge-in aborts the current turn).
*/
export class WaitForAgentTracker {
private waiters = new Map<string, Set<(reason?: string) => void>>();
private logger: Logger;
constructor(logger: Logger) {
this.logger = logger.child({ module: "agent", component: "wait-for-agent-tracker" });
}
register(agentId: string, cancel: (reason?: string) => void): () => void {
if (!this.waiters.has(agentId)) {
this.waiters.set(agentId, new Set());
}
const waitersForAgent = this.waiters.get(agentId)!;
waitersForAgent.add(cancel);
return () => {
const current = this.waiters.get(agentId);
if (!current) {
return;
}
current.delete(cancel);
if (current.size === 0) {
this.waiters.delete(agentId);
}
};
}
cancel(agentId: string, reason?: string): boolean {
const waitersForAgent = this.waiters.get(agentId);
if (!waitersForAgent || waitersForAgent.size === 0) {
return false;
}
this.waiters.delete(agentId);
for (const cancel of waitersForAgent) {
try {
cancel(reason);
} catch (error) {
this.logger.warn({ err: error, agentId }, "Cancel callback failed");
}
}
return true;
}
cancelAll(reason?: string): number {
let cancelled = 0;
for (const agentId of Array.from(this.waiters.keys())) {
if (this.cancel(agentId, reason)) {
cancelled += 1;
}
}
return cancelled;
}
}
@@ -35,38 +35,6 @@ const WORKSPACE_CONTEXT_MESSAGE =
const URL_WHITESPACE_PATTERN = /\s/;
const NON_HTTP_EXPLICIT_SCHEME_PATTERN = /^(?!https?:\/\/)[a-z][a-z0-9+.-]*:\/\//i;
const BrowserToolOutputSchema = {
ok: z.boolean(),
result: z.unknown().optional(),
error: z
.object({
code: z.string(),
message: z.string(),
retryable: z.boolean(),
})
.optional(),
dialogs: z
.array(
z.object({
type: z.enum(["alert", "confirm", "prompt", "beforeunload"]),
message: z.string(),
defaultValue: z.string().optional(),
action: z.enum(["accepted", "dismissed"]),
promptText: z.string().optional(),
timestamp: z.number(),
}),
)
.optional(),
context: z
.object({
agentId: z.string().optional(),
cwd: z.string().optional(),
workspaceId: z.string().optional(),
browserId: z.string().optional(),
})
.optional(),
};
const BrowserHttpUrlInputSchema = z
.string()
.trim()
@@ -103,7 +71,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
description:
"List open Paseo browser tabs for this agent's workspace across connected browser automation hosts. Use returned browserId values with tab-scoped tools.",
inputSchema: {},
outputSchema: BrowserToolOutputSchema,
},
async () => {
const context = resolveBrowserToolContext(options);
@@ -133,7 +100,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
inputSchema: {
url: BrowserHttpUrlInputSchema.optional(),
},
outputSchema: BrowserToolOutputSchema,
},
async ({ url }) => {
const context = resolveBrowserToolContext(options);
@@ -163,7 +129,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
inputSchema: {
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -196,7 +161,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
doubleClick: z.boolean().optional(),
modifiers: z.array(BrowserClickModifierInputSchema).optional(),
},
outputSchema: BrowserToolOutputSchema,
},
async ({ ref, browserId, button, doubleClick, modifiers }) => {
const context = resolveBrowserToolContext(options);
@@ -231,7 +195,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
value: z.string(),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ ref, value, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -260,7 +223,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
description:
"Wait until a Paseo browser tab contains text or reaches a URL fragment. Use browserId from browser_new_tab or browser_list_tabs; waits up to 5s by default on the browser host.",
inputSchema: BrowserWaitInputSchema,
outputSchema: BrowserToolOutputSchema,
},
async ({ text, url, timeoutMs, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -295,7 +257,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
ref: BrowserRefInputSchema.optional(),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ text, ref, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -328,7 +289,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
ref: BrowserRefInputSchema.optional(),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ key, ref, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -357,7 +317,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
description:
"Navigate a Paseo browser tab to a URL. Use browserId from browser_new_tab or browser_list_tabs; pass an http(s) URL or a scheme-less host URL, which is treated as http.",
inputSchema: { url: BrowserHttpUrlInputSchema, browserId: BrowserAutomationBrowserIdSchema },
outputSchema: BrowserToolOutputSchema,
},
async ({ url, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -407,7 +366,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
title: toolConfig.title,
description: toolConfig.description,
inputSchema: { browserId: BrowserAutomationBrowserIdSchema },
outputSchema: BrowserToolOutputSchema,
},
async ({ browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -438,7 +396,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
browserId: BrowserAutomationBrowserIdSchema,
fullPage: z.boolean().default(false),
},
outputSchema: BrowserToolOutputSchema,
},
async ({ browserId, fullPage }) => {
const context = resolveBrowserToolContext(options);
@@ -470,7 +427,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
filePaths: z.array(z.string().min(1)).min(1),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ ref, filePaths, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -507,7 +463,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
title: toolConfig.title,
description: toolConfig.description,
inputSchema: { ref: BrowserRefInputSchema, browserId: BrowserAutomationBrowserIdSchema },
outputSchema: BrowserToolOutputSchema,
},
async ({ ref, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -540,7 +495,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
value: z.string(),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ ref, value, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -573,7 +527,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
targetRef: BrowserRefInputSchema,
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ sourceRef, targetRef, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -605,7 +558,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
maxEntries: z.number().int().positive().max(200).optional(),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ maxEntries, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -637,7 +589,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
ref: BrowserRefInputSchema.optional(),
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ function: functionSource, ref, browserId }) => {
const context = resolveBrowserToolContext(options);
@@ -671,7 +622,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
deltaX: z.number(),
deltaY: z.number(),
},
outputSchema: BrowserToolOutputSchema,
},
async ({ browserId, ref, deltaX, deltaY }) => {
const context = resolveBrowserToolContext(options);
@@ -705,7 +655,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
width: z.number().int().positive(),
height: z.number().int().positive(),
},
outputSchema: BrowserToolOutputSchema,
},
async ({ browserId, width, height }) => {
const context = resolveBrowserToolContext(options);
@@ -736,7 +685,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void
inputSchema: {
browserId: BrowserAutomationBrowserIdSchema,
},
outputSchema: BrowserToolOutputSchema,
},
async ({ browserId }) => {
const context = resolveBrowserToolContext(options);
+28 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -49,4 +49,31 @@ describe("pid-lock ownership", () => {
await rm(paseoHome, { recursive: true, force: true });
}
});
test("reclaims a stale lock when the recorded pid belongs to a different process", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-reused-"));
const replacementOwnerPid = process.pid + 10_000;
try {
await writeFile(
join(paseoHome, "paseo.pid"),
JSON.stringify({
pid: process.pid,
startedAt: "2026-01-01T00:00:00.000Z",
hostname: "old-host",
uid: process.getuid?.() ?? 0,
listen: "127.0.0.1:6767",
desktopManaged: true,
}),
);
await acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid });
const lock = await getPidLockInfo(paseoHome);
expect(lock?.pid).toBe(replacementOwnerPid);
expect(lock?.listen).toBeNull();
} finally {
await rm(paseoHome, { recursive: true, force: true });
}
});
});
+129 -3
View File
@@ -1,5 +1,6 @@
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { open, readFile, unlink, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { hostname } from "node:os";
import { z } from "zod";
@@ -34,6 +35,11 @@ export class PidLockError extends Error {
}
}
const PROCESS_START_SKEW_TOLERANCE_MS = 60_000;
const PROCESS_LOCK_ACQUIRE_TOLERANCE_MS = 5 * 60_000;
const POSIX_PROCESS_TIME_ENV = { ...process.env, LC_ALL: "C", LANG: "C" };
let cachedClockTicksPerSecond: number | null = null;
function isPidRunning(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -43,6 +49,126 @@ function isPidRunning(pid: number): boolean {
}
}
function readClockTicksPerSecond(): number {
if (cachedClockTicksPerSecond !== null) {
return cachedClockTicksPerSecond;
}
try {
const output = execFileSync("getconf", ["CLK_TCK"], {
encoding: "utf8",
timeout: 1000,
}).trim();
const value = Number(output);
cachedClockTicksPerSecond = Number.isFinite(value) && value > 0 ? value : 100;
} catch {
cachedClockTicksPerSecond = 100;
}
return cachedClockTicksPerSecond;
}
function readLinuxProcessStartedAtMs(pid: number): number | null {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const closeParen = stat.lastIndexOf(")");
if (closeParen === -1) return null;
const fieldsAfterCommand = stat
.slice(closeParen + 2)
.trim()
.split(/\s+/);
const startTicks = Number(fieldsAfterCommand[19]);
if (!Number.isFinite(startTicks)) return null;
const bootTimeLine = readFileSync("/proc/stat", "utf8")
.split("\n")
.find((line) => line.startsWith("btime "));
const bootSeconds = Number(bootTimeLine?.trim().split(/\s+/)[1]);
if (!Number.isFinite(bootSeconds)) return null;
return bootSeconds * 1000 + (startTicks / readClockTicksPerSecond()) * 1000;
} catch {
return null;
}
}
function readPsProcessStartedAtMs(pid: number): number | null {
try {
const output = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
encoding: "utf8",
env: POSIX_PROCESS_TIME_ENV,
timeout: 1000,
}).trim();
const parsed = Date.parse(output);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
function readWindowsProcessStartedAtMs(pid: number): number | null {
try {
const command = [
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"`,
'if ($p) { $p.CreationDate.ToUniversalTime().ToString("o") }',
].join("; ");
const output = execFileSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", command],
{
encoding: "utf8",
timeout: 3000,
},
).trim();
const parsed = Date.parse(output);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
}
function readProcessStartedAtMs(pid: number): number | null {
if (process.platform === "linux") {
return readLinuxProcessStartedAtMs(pid) ?? readPsProcessStartedAtMs(pid);
}
if (process.platform === "win32") {
return readWindowsProcessStartedAtMs(pid);
}
return readPsProcessStartedAtMs(pid);
}
function lockMatchesLiveProcessIdentity(lock: PidLockInfo): boolean {
const lockStartedAtMs = Date.parse(lock.startedAt);
if (!Number.isFinite(lockStartedAtMs)) {
return false;
}
const processStartedAtMs = readProcessStartedAtMs(lock.pid);
if (processStartedAtMs === null) {
return true;
}
if (processStartedAtMs - lockStartedAtMs > PROCESS_START_SKEW_TOLERANCE_MS) {
return false;
}
if (lockStartedAtMs - processStartedAtMs > PROCESS_LOCK_ACQUIRE_TOLERANCE_MS) {
return false;
}
return true;
}
function isPidLockOwnerRunning(lock: PidLockInfo): boolean {
if (!isPidRunning(lock.pid)) {
return false;
}
// PIDs can be reused after an unclean daemon exit. Treat a live PID as the
// lock owner only when its process start time still lines up with the lock.
return lockMatchesLiveProcessIdentity(lock);
}
function getPidFilePath(paseoHome: string): string {
return join(paseoHome, "paseo.pid");
}
@@ -78,7 +204,7 @@ export async function acquirePidLock(
// Check if existing lock is stale
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
if (existingLock) {
if (isPidRunning(existingLock.pid)) {
if (isPidLockOwnerRunning(existingLock)) {
if (existingLock.pid === lockOwnerPid) {
return;
}
@@ -197,7 +323,7 @@ export async function isLocked(
if (!info) {
return { locked: false };
}
if (!isPidRunning(info.pid)) {
if (!isPidLockOwnerRunning(info)) {
return { locked: false, info };
}
return { locked: true, info };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.104-beta.5",
"version": "0.1.104",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -2,7 +2,7 @@
title: Browser tools
description: The browser_* MCP tools agents use to drive Paseo browser tabs.
nav: Tools reference
order: 36
order: 37
category: Browser
---
+21
View File
@@ -0,0 +1,21 @@
---
title: When to use the Paseo browser
description: When to reach for Paseo's built-in browser instead of Playwright or agent-browser.
nav: When to use
order: 36
category: Browser
---
# When to use
Paseo gives agents a [real browser inside Paseo](/docs/browser). Standalone tools like Playwright or agent-browser give an agent its own browser in a separate process. They overlap, so which one?
Use the Paseo browser when:
- **You're running agents remotely and want to watch.** You see the same browser the agent is driving, live in the Paseo desktop app — not a headless browser on a box you can't see.
- **You want the browser inside Paseo.** It's a tab in the same session, not a separate agent window to manage.
- **You don't want to set up extra tools.** It's built in — no separate MCP server or CLI to install and wire up.
Reach for Playwright or agent-browser when the browser work stands on its own — headless CI runs, an existing test suite, or automation that isn't tied to an agent session in Paseo.
The Paseo browser is [desktop only for now](/docs/browser).
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Browser automation
description: Give agents a real browser inside Paseo to open pages, click, type, and verify their work.
nav: Browser automation
nav: Overview
order: 35
category: Browser
---
+10
View File
@@ -91,6 +91,16 @@ paseo wait <id> --timeout 60 # 60 second timeout
Useful in scripts or when one agent needs to wait for another.
## Schedules
Run an agent on an interval or a cron. See [Schedules from the CLI](/docs/schedules-cli) for the full reference.
```bash
paseo schedule create --every 30m --cwd ~/dev/my-app "Continue the refactor and leave a note."
paseo schedule ls
paseo schedule pause <id>
```
## Permissions
Agents may request permission for certain actions. Manage these from the CLI:
+1 -2
View File
@@ -3,7 +3,7 @@ title: Paseo MCP
description: Paseo MCP tools injected into agents.
nav: Paseo MCP
order: 30
category: Automation
category: Orchestration
---
# Paseo MCP
@@ -19,7 +19,6 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
| Tool | Function |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. |
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
| `send_agent_prompt` | Send a task to a running agent. |
| `get_agent_status` | Return the latest snapshot for an agent. |
| `list_agents` | List recent agents as compact metadata. |
+21
View File
@@ -0,0 +1,21 @@
---
title: Schedules from chat
description: Ask an agent in chat to set up and manage a schedule for you.
nav: Chat
order: 26
category: Schedules
---
# Schedules from chat
You don't have to fill in a form. In any agent chat, just ask, and the agent sets up the schedule for you through [Paseo MCP](/docs/mcp).
Example prompts:
- "Every weekday at 9am, triage new GitHub issues and PRs and summarize what needs attention."
- "Check the release build every 5 minutes until it passes, and fix the cause if it fails."
- "Keep working on this refactor — wake yourself every 20 minutes and continue where you left off."
The agent picks the cadence, target, and prompt from what you asked, creates the schedule, and reports back. You can manage it the same way — "pause the triage schedule", "make the build check run every 2 minutes instead", "delete it" — or from the [Schedules view](/docs/schedules) and the [CLI](/docs/schedules-cli).
An agent scheduling itself to wake up later is a **heartbeat**. See [Paseo MCP](/docs/mcp) for the underlying tools.
+94
View File
@@ -0,0 +1,94 @@
---
title: Schedules from the CLI
description: Create and manage Paseo schedules with paseo schedule.
nav: CLI
order: 27
category: Schedules
---
# Schedules from the CLI
`paseo schedule` creates and manages [schedules](/docs/schedules) from your terminal, useful for headless boxes and scripts.
## Create
Overnight refactor on Codex:
```bash
paseo schedule create \
--every 30m \
--name overnight-refactor \
--provider codex/gpt-5.5 \
--cwd ~/dev/my-app \
--max-runs 16 \
--expires-in 10h \
"Continue the refactor. Run the focused checks. Leave a short status note."
```
Long build babysitter on Claude:
```bash
paseo schedule create \
--every 5m \
--name build-watch \
--provider claude/opus-4.7 \
--cwd ~/dev/my-app \
--max-runs 24 \
"Check the release build. If it failed, inspect logs, fix the cause, and rerun."
```
Daily GitHub triage on GLM through OpenCode:
```bash
paseo schedule create \
--cron "0 14 * * 1-5" \
--timezone UTC \
--run-now \
--name github-triage \
--provider opencode/openrouter/glm-5.1 \
--cwd ~/dev/my-app \
"Triage GitHub issues, PRs, and failing checks. Summarize what needs attention."
```
Morning triage at 9 AM in New York, including daylight saving time changes:
```bash
paseo schedule create \
--cron "0 9 * * 1-5" \
--timezone America/New_York \
--name morning-triage \
--provider codex/gpt-5.5 \
--cwd ~/dev/my-app \
"Review overnight CI failures and summarize anything urgent."
```
Heartbeat the current agent:
```bash
paseo schedule create \
--every 20m \
--target self \
--name heartbeat \
"Check the current task state and continue with the next useful step."
```
## Manage
```bash
paseo schedule ls
paseo schedule inspect <id>
paseo schedule logs <id>
paseo schedule pause <id>
paseo schedule resume <id>
paseo schedule run-once <id>
paseo schedule update <id> --every 10m --max-runs 6
paseo schedule delete <id>
```
## Cadence
Use `--every <duration>` for intervals and `--cron "<expr>"` for 5-field cron. Cron schedules default to UTC. Pass `--timezone <IANA>` to interpret cron fields in a local wall-clock time zone, for example `--timezone America/New_York`. The persisted `nextRunAt` is still a UTC instant, but it is computed from that local time zone so recurring jobs stay at the same local time across daylight saving time changes.
Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately.
When targeting a remote daemon with `--host`, pass `--cwd`; your local working directory may not exist on the remote machine.
+20 -100
View File
@@ -1,114 +1,34 @@
---
title: Schedules
description: Run Paseo agents on intervals or cron.
nav: Schedules
order: 31
category: Automation
description: Run Paseo agents on a schedule — every few minutes or on a cron.
nav: Overview
order: 25
category: Schedules
---
# Schedules
Schedules let agents come back later.
Think of a schedule as a standing instruction: at this cadence, run this prompt, in this repo, with this agent target.
A schedule runs an agent for you on a cadence: at this interval or cron time, run this prompt, in this repo, with this agent.
The target can be:
- A new agent each time, useful for fresh daily jobs and long-running watchers.
- An existing agent, useful when you want continuity.
- The same agent that created the schedule, useful for heartbeats from inside an agent.
- A new agent each run — fresh daily jobs and long-running watchers.
- An existing agent — when you want continuity.
- The agent that created the schedule — heartbeats from inside an agent.
Schedules can use interval cadence, like every 30 minutes, or cron cadence, like every weekday morning. Runs are recorded, can be inspected later, and can be paused, resumed, triggered once, updated, or deleted.
Cadence is either an interval, like every 30 minutes, or a cron expression, like every weekday morning. Every run is recorded, and you can pause, resume, run once, update, or delete a schedule at any time.
## Uses
## What it's for
- **Overnight refactoring loops:** wake an agent every 30 minutes to continue a scoped refactor, run checks, and leave notes.
- **Heartbeats:** have the same agent periodically reassess state and keep moving.
- **Long build babysitting:** check CI, EAS, Docker, or release builds until they pass.
- **Daily GitHub triage:** scan issues, PRs, notifications, and flaky checks every morning.
- **Maintenance sweeps:** refresh dependencies, audit docs, clean stale branches, or summarize repo health.
- **Overnight refactors:** wake an agent every 30 minutes to continue a scoped refactor, run checks, and leave notes.
- **Heartbeats:** have an agent periodically reassess state and keep moving.
- **Build babysitting:** check CI, EAS, Docker, or release builds until they pass.
- **Daily triage:** scan issues, PRs, and failing checks every morning.
- **Maintenance sweeps:** refresh dependencies, audit docs, or clean stale branches.
## Setup Examples
## Ways to create one
Overnight refactor on Codex:
```bash
paseo schedule create \
--every 30m \
--name overnight-refactor \
--provider codex/gpt-5.5 \
--cwd ~/dev/my-app \
--max-runs 16 \
--expires-in 10h \
"Continue the refactor. Run the focused checks. Leave a short status note."
```
Long build babysitter on Claude:
```bash
paseo schedule create \
--every 5m \
--name build-watch \
--provider claude/opus-4.7 \
--cwd ~/dev/my-app \
--max-runs 24 \
"Check the release build. If it failed, inspect logs, fix the cause, and rerun."
```
Daily GitHub triage on GLM through OpenCode:
```bash
paseo schedule create \
--cron "0 14 * * 1-5" \
--timezone UTC \
--run-now \
--name github-triage \
--provider opencode/openrouter/glm-5.1 \
--cwd ~/dev/my-app \
"Triage GitHub issues, PRs, and failing checks. Summarize what needs attention."
```
Morning triage at 9 AM in New York, including daylight saving time changes:
```bash
paseo schedule create \
--cron "0 9 * * 1-5" \
--timezone America/New_York \
--name morning-triage \
--provider codex/gpt-5.5 \
--cwd ~/dev/my-app \
"Review overnight CI failures and summarize anything urgent."
```
Heartbeat the current agent:
```bash
paseo schedule create \
--every 20m \
--target self \
--name heartbeat \
"Check the current task state and continue with the next useful step."
```
## Managing Schedules
```bash
paseo schedule ls
paseo schedule inspect <id>
paseo schedule logs <id>
paseo schedule pause <id>
paseo schedule resume <id>
paseo schedule run-once <id>
paseo schedule update <id> --every 10m --max-runs 6
paseo schedule delete <id>
```
Use `--every <duration>` for intervals and `--cron "<expr>"` for 5-field cron. Cron schedules default to UTC. Pass `--timezone <IANA>` to interpret cron fields in a local wall-clock time zone, for example `--timezone America/New_York`. The persisted `nextRunAt` is still a UTC instant, but it is computed from that local time zone so recurring jobs stay at the same local time across daylight saving time changes.
Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately.
When targeting a remote daemon with `--host`, pass `--cwd`; your local working directory may not exist on the remote machine.
## MCP
Agents can create and manage schedules through [Paseo MCP](/docs/mcp).
- **In the app** — open the Schedules view and create one with an agent, a cadence, a repo, and a prompt. This is the main way to create and manage schedules.
- **[From chat](/docs/schedules-chat)** — ask the agent in a chat and it sets the schedule up for you.
- **[From the CLI](/docs/schedules-cli)** — `paseo schedule create`, for headless boxes and scripts.
- **[Over MCP](/docs/mcp)** — agents create and manage schedules programmatically.
+1 -1
View File
@@ -3,7 +3,7 @@ title: Orchestration skills
description: "Paseo orchestration skills: teach coding agents to spawn, coordinate, and manage other agents using slash commands."
nav: Skills
order: 32
category: Automation
category: Orchestration
---
# Orchestration skills
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Troubleshooting
description: Why Paseo can't find a provider you've installed, and how to fix the PATH and environment mismatches behind most setup issues.
nav: Troubleshooting
nav: Common problems
order: 50
category: Troubleshooting
---
+1 -1
View File
@@ -106,7 +106,7 @@ If the file is missing, use sensible defaults and tell the user once.
Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows.
For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own.
For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own.
Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.