From b12daabe35b5963f2bb6d75012f28671404eae83 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 24 Aug 2026 20:45:49 +0200 Subject: [PATCH] perf(app): row-grained replica cache persistence (#3755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hub): guide starter setup (#3651) * feat(hub): guide setup with provider IDs * fix(hub): preserve login setup state * feat(hub): select starter agent runtime Use the connected daemon's provider snapshot so generated Hub bundles only contain complete provider, model, and mode combinations. * fix(hub): stage starter runtime choices Keep providers with valid modes selectable when the daemon has no default mode, so users explicitly choose the complete runtime. * fix(hub): scope runtime choices to workspace Provider capabilities can vary by repository, so request the snapshot for the bundle directory that guided setup will deploy. * fix(server): stop short start gates from undercutting provider startup budgets (#3621) * fix(server): stop short start gates from undercutting provider startup budgets Agent creation still failed on contended cold starts after #3578, because two shorter gates on the same path fire before the event-stream readiness budget can apply. A traced creation reached session.create twelve seconds in, past the 10s session.create gate, and the agent was then killed by a bare "timeout" at fifteen seconds from AGENT_RUN_START_TIMEOUT_MS while the SSE connect was still running. A run-start bound of 15s wraps a path that is deliberately allowed 30s, so the outer gate always wins and the inner budget is unreachable. Give session.create the shared OPENCODE_SERVER_STARTUP_TIMEOUT_MS budget, since creating the first session for a directory is part of OpenCode coming up, and derive its error message from the constant instead of hardcoding "10s". Raise AGENT_RUN_START_TIMEOUT_MS to 60s with a comment recording that it must stay above the slowest provider startup budget; it stays a literal because that module is provider-agnostic. Related to #3571. * test(server): drive the run start budget through a real AgentManager The run start boundary tests replaced AgentManager.waitForAgentRunStart with a bare timer, so they never exercised the production run-state transition or the agent_state subscription the real wait depends on. That path could regress while the tests stayed green. Build the scenario on a real AgentManager with a provider session that holds its turn start open for a configurable span, so pendingRun.started, the lifecycle change to "running", and the agent_state notification are what the wait actually observes. Budgets stay independent literals and the settled-before/after assertions are unchanged. * fix(server): describe agent run start timeouts --------- Co-authored-by: Mohamed Boudra * fix(relay): validate handshake keys (#3669) * fix(files): keep large source previews responsive (#3665) Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation. * fix(hub): make guided setup choices actionable (#3677) Show a concise login success state, scope starter choices to available Hub connections, wait for daemon capabilities, and avoid partial starter bundles when setup cannot proceed. * Keep released agent SDK versions reproducible (#3678) Pin the validated provider and protocol SDK dependency pair so fresh installs cannot silently float after a Paseo release. Refresh the Nix npm dependency hash for the resulting lockfile. * feat(app): refine agent profile creation (#3680) Keep profiles as the richer primitive while restoring the familiar star cue and clearer guidance for agent selection. Account for borders in shared field geometry so controls preserve their declared heights across form consumers. * fix(app): restore native composer auto-growth (#3681) Native multiline inputs were always scrollable, which kept iOS content-size measurement pinned to one line. Keep native sizing intrinsic below the cap and enable internal scrolling only after the composer reaches it. * docs(hub): document zero-friction onboarding (#3657) Lead with the browser-to-CLI onboarding journey, document the generated starter bundle, and align guided setup with available Hub connections and daemon-reported runtime defaults. * Surface workspace changes from agent panes (#3682) * feat(app): refine workspace pane actions Prioritize Changes when available, keep Files useful before a selection, and reserve desktop diff clicks for selection so comments require the explicit affordance. Move the Changes shortcut to Shift+G to avoid consuming a likely copy variant. * feat(app): improve access to workspace changes * Expand workspace panel actions and keyboard navigation (#3685) * fix(app): keep file navigation in place Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements. * feat(app): expand workspace panel actions Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab. * Restore compact and stable chat timelines (#3716) Restore compact message and footer spacing, reserve independent composer-pill clearance for transcript content and floating controls, and keep visible web tool-call expansion from shifting the reading position. * perf(app): defer replica persistence until user inactivity Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal. * Give every pane a browser-style New tab (#3715) * feat(app): replace empty panes with New tabs Give every retained pane an independently identified New tab, replace it in place when a destination is chosen, and preserve split close and move invariants. Keep existing workspace startup and compact explorer routing explicit, with focused unit and browser regression coverage. * test(app): count content tabs in subagent integration * fix(app): preserve workspace shortcut ownership * fix(app): keep compact model options scrollable The compact model sheet had no sheet-aware scroll owner when profiles filled its viewport, leaving the controls below them unreachable. Keep the root provider view inside Gorhom's scroll container so native sheet gestures work on both platforms. * fix(app): order new tab launcher actions by workflow * fix(app): show the New agent shortcut The command center resolved New agent through the New tab binding, so it advertised Cmd+T despite creating an agent. Resolve the target-agent binding instead and cover the mapping. * fix(app): prioritize side panel launcher actions Lead with supporting workspace tools when the launcher is in the Side panel, while ordinary panes continue to lead with agent creation. * fix(app): align terminal profiles header * docs: add 0.5.0-beta.5 changelog * test(app): align new tab launcher expectations * chore(release): cut 0.5.0-beta.5 * fix: update lockfile signatures and Nix hash [skip ci] * fix(app): make compact git actions menu-only Keep compact toolbars narrow and require users to choose potentially consequential Git and workspace actions from the menu. * fix(app): keep pinned project actions reachable (#3722) * fix(app): keep pinned project actions reachable Preserve an empty project shell after its final workspace is hoisted into the pinned section so project-scoped workspace creation remains available. Co-authored-by: Agent Op * test(app): verify pinned project workspace context --------- Co-authored-by: Agent Op Co-authored-by: Mohamed Boudra * fix(app): stop a string SVG transform from crashing the app (#3711) * fix(app): stop a string SVG transform from crashing the app A root is a real React Native view, so react-native-svg rewrites a *string* `transform` into RN style syntax in `extractTransformSvgView`, using a PEG parser whose grammar only accepts SVG transform functions. Upstream lets that parser's SyntaxError escape, so any string it does not understand takes the whole app down during render: SyntaxError: Expected transform functions but "n" found. "found": "n", "location": { "start": { "offset": 0 } } `none` reproduces that message character for character; so does any other CSS value (`rotate(-90deg)`, `translate(10px, 0)`). Every sibling extractor in that file already catches the same error and degrades, so the asymmetry is the bug: a string that is merely ignored on a crashes on an . We feed that path untrusted input. The daemon scans a project for favicon.svg / icon.svg / logo.svg (packages/server/src/utils/project-icon.ts) and ships it to the client, where ProjectIconImage hands it to SvgCss. Running the library's own XML parser over `` yields exactly `transform: "none"` on the root, which is why the crash starts once a host connects and the project icons render, with no terminal involved. Note this is *not* reachable from an RN-style array transform: the extractor returns arrays untouched and only parses strings, so `style={{ transform: [{ rotate: "-90deg" }] }}` (the context window meter and the checks ring) was never a factor. Patch the three builds a consumer can resolve - Metro takes the package's `react-native` field (src/*.ts), Node and Vitest take main/module - and register react-native-svg with the postinstall patch runner so the patch actually applies. The new test pins all three. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EDwfsMWoSFfPm2cFXe5noQ * test(app): cover parsed root SVG transforms Exercise the dependency patch with root props produced by the library's XML and CSS parsers, including embedded style rules that can arrive through project icons. Keep dependency-patch documentation focused on the reusable install and resolution constraints. --------- Co-authored-by: Claude Co-authored-by: Mohamed Boudra * fix(pairing): warn that pairing links grant daemon access (#3734) A pairing offer remains valid across daemon restarts, so remove the incorrect rotation guidance and warn users where the durable credential is displayed. * fix(app): preserve timeline retry backoff (#3736) Equivalent visibility publications were acting as implicit retry commands, so unrelated tab and layout changes could restart failed timeline work. Keep visibility identity stable and reserve immediate retries for explicit user action. * Open the tab chooser beside the pointer (#3735) * feat(app): open the tab chooser beside the pointer Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift. * test(app): follow the anchored tab chooser The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs. * fix(app): restore composer height lifecycle (#3740) Native Fabric can omit the initial content-size callback and retain stale size after deletion. Keep the common composer surface free of platform branches while native and web adapters own their measurement mechanisms. * fix(server): stop quota fetcher from writing provider credentials (#3442) * fix(server): recover already-settled interrupts (#3742) * fix(server): recover already-settled interrupts Provider state can drift from Paseo’s tracked run. Treat an authoritative already-idle interrupt as settled and retire the captured run token so retrying can start new work without weakening ambiguous-cancellation protection. * fix(codex): fence interruption during turn startup Reserve foreground-start ownership before asynchronous provider setup. An interrupt can now cancel preflight before turn/start or wait for an issued start to become interruptible, preventing acknowledged cancellation from leaving unmanaged work. * docs(changelog): prepare 0.5.0 stable release * chore(release): cut 0.5.0 * fix: update lockfile signatures and Nix hash [skip ci] * fix(app): stabilize native composer resizing JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web. * docs(changelog): prepare 0.5.1 stable release * chore(release): cut 0.5.1 * fix: update lockfile signatures and Nix hash [skip ci] * perf(app): move replica cache validation to restore Add an observational live measurement harness for the row-store migration baseline. Trust already-shaped cache values on persist while retaining full schema validation at the restore boundary. * feat(app): add replica cache row store Ship the platform storage engines behind a dark interface so the cache migration can wire them separately. Both engines share a contract, including atomic writes and destructive schema-version resets. * feat(app): switch replica cache to row storage Persist only changed replica entities so steady-state writes scale with the update rather than the complete cached world. Restore remains an all-or-nothing validated boundary, with whole-host byte-budget eviction. * fix(app): harden replica row persistence Serialize pending batches at write execution time so retries cannot overwrite newer rows or resurrect removed hosts. Reject oversized hosts before storage writes and preserve unchanged directory identities so routine reconciliation stays row-grained. --------- Co-authored-by: BrianAguilarWasco <146237330+BrianAguilarWasco@users.noreply.github.com> Co-authored-by: paseo-ai[bot] <266920839+paseo-ai[bot]@users.noreply.github.com> Co-authored-by: Yuri Gui Co-authored-by: Agent Op Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com> Co-authored-by: Claude Co-authored-by: Dan Berindei --- CHANGELOG.md | 135 ++-- docs/agent-lifecycle.md | 11 +- docs/architecture.md | 15 +- docs/development.md | 10 + docs/providers.md | 4 + docs/side-panel.md | 6 +- docs/timeline-sync.md | 2 +- nix/npm-deps.hash | 2 +- package-lock.json | 97 ++- package.json | 2 +- packages/app/e2e/browser/changes-pane.spec.ts | 43 +- .../changes-shortcut-placement.spec.ts | 58 ++ .../e2e/browser/composer-diff-stat.spec.ts | 132 ++++ .../e2e/browser/empty-pane-keyboard.spec.ts | 80 ++ .../browser/explorer-pane-placement.spec.ts | 34 +- .../browser/explorer-surface-upgrade.spec.ts | 6 +- packages/app/e2e/browser/file-editing.spec.ts | 86 ++- .../app/e2e/browser/file-transition.spec.ts | 19 + .../e2e/browser/file-tree-rail-toggle.spec.ts | 27 + packages/app/e2e/browser/launcher-tab.spec.ts | 154 ++-- .../app/e2e/browser/new-tab-startup.spec.ts | 49 ++ .../browser/replica-cache-measurement.spec.ts | 50 ++ .../browser/replica-cache-performance.spec.ts | 2 +- packages/app/e2e/browser/side-panel.spec.ts | 73 +- .../sidebar-workspace-pin-shortcut.spec.ts | 23 + .../e2e/browser/terminal-split-resize.spec.ts | 2 +- .../e2e/browser/terminal-stuck-size.spec.ts | 10 +- .../workspace-agent-title-handoff.spec.ts | 2 +- .../browser/workspace-pane-remount.spec.ts | 4 +- .../workspace-terminal-tab-rename.spec.ts | 11 +- packages/app/e2e/support/global-setup.ts | 6 + .../app/e2e/support/helpers/agent-profiles.ts | 15 +- packages/app/e2e/support/helpers/launcher.ts | 23 +- .../app/e2e/support/helpers/mock-agent.ts | 7 +- .../app/e2e/support/helpers/pair-device.ts | 5 + .../helpers/replica-cache-measurement.ts | 312 ++++++++ .../support/helpers/replica-cache-storage.ts | 212 ++++-- .../app/e2e/support/helpers/seed-client.ts | 1 + .../app/e2e/support/helpers/workspace-tabs.ts | 21 +- packages/app/package.json | 5 +- .../internal/agent-profile-glyph.tsx | 4 +- .../src/agent-stream/footer-spacing.test.ts | 139 ++++ packages/app/src/agent-stream/layout.test.ts | 2 +- packages/app/src/agent-stream/layout.ts | 16 +- packages/app/src/agent-stream/spacing.test.ts | 12 + packages/app/src/agent-stream/spacing.ts | 8 +- .../app/src/agent-stream/strategy-web.tsx | 18 +- .../app/src/agent-stream/turn-footer.test.tsx | 108 --- packages/app/src/agent-stream/turn-footer.tsx | 4 +- packages/app/src/agent-stream/view.tsx | 17 +- .../agent-stream/web-virtualization.test.ts | 50 ++ .../src/agent-stream/web-virtualization.ts | 15 + packages/app/src/app/_layout.tsx | 11 +- .../workspace-contributions.test.ts | 92 ++- .../command-center/workspace-contributions.ts | 433 ++++++++++- .../command-center/workspace-registration.tsx | 125 +++- .../workspace-shortcuts.test.ts | 13 + .../src/command-center/workspace-shortcuts.ts | 39 + .../app/src/components/file-explorer-pane.tsx | 1 - packages/app/src/components/model-browser.tsx | 16 +- .../app/src/components/split-container.tsx | 114 +-- .../src/components/svg-root-transform.test.ts | 70 ++ .../components/ui/control-geometry.test.ts | 27 +- .../app/src/components/ui/control-geometry.ts | 23 +- packages/app/src/components/ui/form-field.tsx | 2 +- packages/app/src/components/ui/shortcut.tsx | 2 +- .../app/src/composer/diff-stat-pill.test.tsx | 69 ++ packages/app/src/composer/diff-stat-pill.tsx | 59 ++ .../app/src/composer/input/height-mirror.d.ts | 1 - .../composer/input/height-mirror.native.ts | 14 - .../src/composer/input/height-mirror.web.ts | 121 --- packages/app/src/composer/input/height.d.ts | 1 + .../app/src/composer/input/height.native.ts | 18 + .../app/src/composer/input/height.types.ts | 15 + packages/app/src/composer/input/height.web.ts | 132 ++++ packages/app/src/composer/input/input.tsx | 105 +-- packages/app/src/composer/pill-styles.ts | 6 + packages/app/src/composer/tracks.test.tsx | 131 ---- .../app/src/composer/workspace-diff-stat.ts | 24 + packages/app/src/contexts/session-context.tsx | 8 +- .../components/pair-device-section.tsx | 1 + packages/app/src/file-pane/live-file/hook.ts | 9 +- packages/app/src/file-pane/pane.tsx | 239 ++---- .../src/file-pane/source/presentation.test.ts | 14 + .../app/src/file-pane/source/presentation.ts | 17 + .../app/src/file-pane/source/view.native.tsx | 141 ++++ packages/app/src/file-pane/source/view.tsx | 1 + .../app/src/file-pane/source/view.web.tsx | 131 ++++ packages/app/src/git/actions-split-button.tsx | 76 +- .../app/src/git/diff-document/surface.web.tsx | 9 +- packages/app/src/git/diff-pane.tsx | 2 +- .../src/hooks/client-activity-tracker.test.ts | 14 + .../app/src/hooks/client-activity-tracker.ts | 4 +- packages/app/src/hooks/use-client-activity.ts | 3 + .../app/src/hooks/use-sidebar-pins.test.ts | 4 +- packages/app/src/hooks/use-sidebar-pins.ts | 6 - packages/app/src/i18n/resources/ar.ts | 13 +- packages/app/src/i18n/resources/en.ts | 14 +- packages/app/src/i18n/resources/es.ts | 14 +- packages/app/src/i18n/resources/fr.ts | 14 +- packages/app/src/i18n/resources/ja.ts | 14 +- packages/app/src/i18n/resources/ko.ts | 14 +- packages/app/src/i18n/resources/pt-BR.ts | 14 +- packages/app/src/i18n/resources/ru.ts | 14 +- packages/app/src/i18n/resources/zh-CN.ts | 12 +- .../keyboard/keyboard-action-dispatcher.ts | 26 + .../src/keyboard/keyboard-shortcuts.test.ts | 8 +- .../app/src/keyboard/keyboard-shortcuts.ts | 6 +- packages/app/src/panels/agent-panel.tsx | 53 +- packages/app/src/panels/agent-tracks.tsx | 38 +- packages/app/src/panels/files-panel.tsx | 57 +- packages/app/src/panels/new-tab-panel.tsx | 330 ++++++++ packages/app/src/panels/pane-context.tsx | 2 + packages/app/src/panels/register-panels.ts | 2 + .../app/src/projects/icon-cache-storage.ts | 4 + .../src/projects/icon-cache-storage.web.ts | 46 ++ packages/app/src/projects/icon-cache.ts | 4 +- packages/app/src/review/surface.tsx | 8 +- .../directory-sync/workspace-replica.test.ts | 46 ++ packages/app/src/runtime/host-runtime.test.ts | 39 +- packages/app/src/runtime/host-runtime.ts | 10 +- .../src/runtime/replica-cache/diff.test.ts | 63 ++ .../app/src/runtime/replica-cache/diff.ts | 77 ++ .../src/runtime/replica-cache/index.test.ts | 631 +++++++++++++--- .../app/src/runtime/replica-cache/index.ts | 703 ++++++++++++++---- .../runtime/replica-cache/legacy-cleanup.ts | 7 + .../replica-cache/legacy-cleanup.web.test.ts | 48 ++ .../replica-cache/legacy-cleanup.web.ts | 14 + .../replica-cache/row-store-factory.native.ts | 1 + .../replica-cache/row-store-factory.ts | 1 + .../replica-cache/row-store-factory.web.ts | 1 + .../runtime/replica-cache/row-store-schema.ts | 3 + .../runtime/replica-cache/row-store-sqlite.ts | 151 ++++ .../replica-cache/row-store.contract.ts | 179 +++++ .../replica-cache/row-store.native.test.ts | 54 ++ .../runtime/replica-cache/row-store.native.ts | 49 ++ .../src/runtime/replica-cache/row-store.ts | 32 + .../replica-cache/row-store.web.test.ts | 20 + .../runtime/replica-cache/row-store.web.ts | 186 +++++ .../app/src/runtime/replica-cache/storage.ts | 4 - .../src/runtime/replica-cache/storage.web.ts | 70 -- .../terminals/use-workspace-terminals.ts | 20 +- .../screens/workspace/visible-agent-ids.ts | 18 + .../workspace/workspace-desktop-tabs-row.tsx | 394 +--------- .../workspace-empty-draft-seed.test.ts | 62 -- .../workspace/workspace-empty-draft-seed.ts | 24 - .../workspace/workspace-new-tab-menu.tsx | 117 +++ .../workspace/workspace-pane-content.test.tsx | 21 +- .../workspace/workspace-pane-content.tsx | 5 + .../workspace/workspace-pane-empty-state.tsx | 216 ------ .../screens/workspace/workspace-screen.tsx | 614 ++++++++++----- .../screens/workspace/workspace-tab-menu.ts | 5 +- .../screens/workspace/workspace-tab-model.ts | 5 - packages/app/src/stores/session-store.ts | 38 +- .../src/stores/workspace-layout-actions.ts | 191 ++++- .../src/stores/workspace-layout-store.test.ts | 330 ++++++-- .../app/src/stores/workspace-layout-store.ts | 32 +- .../workspace-subagents-integration.test.ts | 16 +- packages/app/src/styles/markdown-styles.ts | 2 +- .../src/timeline/viewed-timeline-sync.test.ts | 34 + .../app/src/timeline/viewed-timeline-sync.ts | 8 - .../app/src/utils/agent-directory-sync.ts | 18 +- .../app/src/workspace-tabs/identity.test.ts | 12 + packages/app/src/workspace-tabs/identity.ts | 6 + .../app/src/workspace-tabs/launcher/index.tsx | 209 ++++++ .../launcher/internal/catalog.test.ts | 26 + .../launcher/internal/catalog.ts | 23 + packages/app/src/workspace-tabs/model.ts | 1 + packages/app/src/workspace-tabs/new-tab.ts | 17 + .../app/src/workspace-tabs/side-panel.test.ts | 40 +- packages/app/src/workspace-tabs/side-panel.ts | 83 +++ packages/cli/package.json | 8 +- packages/cli/src/commands/hub/client.test.ts | 46 ++ .../cli/src/commands/hub/commands.test.ts | 62 ++ .../cli/src/commands/hub/daemon-client.ts | 8 + .../cli/src/commands/hub/hub-client/index.ts | 15 + .../hub/hub-client/internal/contracts.ts | 20 + packages/cli/src/commands/hub/index.ts | 4 +- .../cli/src/commands/hub/init-flow.test.ts | 445 +++++++++++ .../cli/src/commands/hub/init-plan.test.ts | 36 +- packages/cli/src/commands/hub/init-plan.ts | 15 +- packages/cli/src/commands/hub/init.ts | 456 +++++++++--- packages/cli/src/commands/hub/login.ts | 10 + .../hub/starter-agent-runtime.test.ts | 131 ++++ .../src/commands/hub/starter-agent-runtime.ts | 88 +++ .../src/commands/hub/starter-trigger.test.ts | 63 ++ .../cli/src/commands/hub/starter-trigger.ts | 40 + packages/cli/src/output/pairing.test.ts | 3 + packages/cli/src/output/pairing.ts | 2 +- packages/client/package.json | 6 +- packages/client/src/daemon-client.test.ts | 55 ++ packages/client/src/daemon-client.ts | 44 +- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 6 +- packages/protocol/package.json | 2 +- packages/protocol/src/messages.ts | 1 + packages/relay/package.json | 2 +- packages/relay/src/crypto.test.ts | 48 ++ packages/relay/src/crypto.ts | 27 +- packages/server/package.json | 16 +- .../src/server/agent/agent-manager.test.ts | 58 ++ .../server/src/server/agent/agent-manager.ts | 70 +- .../src/server/agent/agent-prompt.test.ts | 242 ++++++ .../server/src/server/agent/agent-prompt.ts | 25 +- .../src/server/agent/agent-sdk-types.ts | 5 + ...odex-app-server-agent.process-exit.test.ts | 56 ++ .../providers/codex-app-server-agent.test.ts | 82 +- .../agent/providers/codex-app-server-agent.ts | 75 +- .../agent/providers/opencode-agent.test.ts | 75 ++ .../server/agent/providers/opencode-agent.ts | 9 +- .../test-utils/test-opencode-harness.ts | 4 + .../daemon-e2e/relay-transport.e2e.test.ts | 176 ++++- .../files/workspace-files-session.test.ts | 24 + .../session/files/workspace-files-session.ts | 6 + .../quota-fetcher/providers/claude.ts | 71 +- .../services/quota-fetcher/providers/codex.ts | 76 +- .../services/quota-fetcher/providers/kimi.ts | 135 +--- .../services/quota-fetcher/service.test.ts | 278 ++----- packages/website/package.json | 2 +- patches/react-native-svg+15.15.3.patch | 61 ++ public-docs/cli.md | 9 +- public-docs/hub/activity.md | 2 +- public-docs/hub/configuration/index.md | 54 ++ public-docs/hub/daemons.md | 6 +- public-docs/hub/index.md | 8 +- public-docs/hub/quickstart.md | 70 +- public-docs/hub/triggers/discord.md | 8 +- public-docs/hub/triggers/slack.md | 12 +- public-docs/security.md | 2 - scripts/postinstall-patches.mjs | 4 + 232 files changed, 10258 insertions(+), 3287 deletions(-) create mode 100644 packages/app/e2e/browser/changes-shortcut-placement.spec.ts create mode 100644 packages/app/e2e/browser/composer-diff-stat.spec.ts create mode 100644 packages/app/e2e/browser/empty-pane-keyboard.spec.ts create mode 100644 packages/app/e2e/browser/new-tab-startup.spec.ts create mode 100644 packages/app/e2e/browser/replica-cache-measurement.spec.ts create mode 100644 packages/app/e2e/support/helpers/replica-cache-measurement.ts create mode 100644 packages/app/src/agent-stream/footer-spacing.test.ts delete mode 100644 packages/app/src/agent-stream/turn-footer.test.tsx create mode 100644 packages/app/src/command-center/workspace-shortcuts.test.ts create mode 100644 packages/app/src/command-center/workspace-shortcuts.ts create mode 100644 packages/app/src/components/svg-root-transform.test.ts create mode 100644 packages/app/src/composer/diff-stat-pill.test.tsx create mode 100644 packages/app/src/composer/diff-stat-pill.tsx delete mode 100644 packages/app/src/composer/input/height-mirror.d.ts delete mode 100644 packages/app/src/composer/input/height-mirror.native.ts delete mode 100644 packages/app/src/composer/input/height-mirror.web.ts create mode 100644 packages/app/src/composer/input/height.d.ts create mode 100644 packages/app/src/composer/input/height.native.ts create mode 100644 packages/app/src/composer/input/height.types.ts create mode 100644 packages/app/src/composer/input/height.web.ts delete mode 100644 packages/app/src/composer/tracks.test.tsx create mode 100644 packages/app/src/composer/workspace-diff-stat.ts create mode 100644 packages/app/src/file-pane/source/presentation.test.ts create mode 100644 packages/app/src/file-pane/source/presentation.ts create mode 100644 packages/app/src/file-pane/source/view.native.tsx create mode 100644 packages/app/src/file-pane/source/view.tsx create mode 100644 packages/app/src/file-pane/source/view.web.tsx create mode 100644 packages/app/src/panels/new-tab-panel.tsx create mode 100644 packages/app/src/projects/icon-cache-storage.ts create mode 100644 packages/app/src/projects/icon-cache-storage.web.ts create mode 100644 packages/app/src/runtime/replica-cache/diff.test.ts create mode 100644 packages/app/src/runtime/replica-cache/diff.ts create mode 100644 packages/app/src/runtime/replica-cache/legacy-cleanup.ts create mode 100644 packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts create mode 100644 packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store-factory.native.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store-factory.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store-factory.web.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store-schema.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store-sqlite.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store.contract.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store.native.test.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store.native.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store.web.test.ts create mode 100644 packages/app/src/runtime/replica-cache/row-store.web.ts delete mode 100644 packages/app/src/runtime/replica-cache/storage.ts delete mode 100644 packages/app/src/runtime/replica-cache/storage.web.ts delete mode 100644 packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts delete mode 100644 packages/app/src/screens/workspace/workspace-empty-draft-seed.ts create mode 100644 packages/app/src/screens/workspace/workspace-new-tab-menu.tsx delete mode 100644 packages/app/src/screens/workspace/workspace-pane-empty-state.tsx create mode 100644 packages/app/src/workspace-tabs/launcher/index.tsx create mode 100644 packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts create mode 100644 packages/app/src/workspace-tabs/launcher/internal/catalog.ts create mode 100644 packages/app/src/workspace-tabs/new-tab.ts create mode 100644 packages/cli/src/commands/hub/init-flow.test.ts create mode 100644 packages/cli/src/commands/hub/starter-agent-runtime.test.ts create mode 100644 packages/cli/src/commands/hub/starter-agent-runtime.ts create mode 100644 packages/cli/src/commands/hub/starter-trigger.test.ts create mode 100644 packages/cli/src/commands/hub/starter-trigger.ts create mode 100644 patches/react-native-svg+15.15.3.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index 19561b9a3f..91855bc7bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,98 +1,81 @@ # Changelog -## 0.5.0-beta.4 - 2026-08-21 +## 0.5.1 - 2026-08-23 + +### Fixed + +- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740)) + +## 0.5.0 - 2026-08-23 ### Added +- Added experimental trusted local plugins ([#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465)) - Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan)) -- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend)) +- Added a user-directed Side panel with independent tabs ([#3287](https://github.com/getpaseo/paseo/pull/3287), [#3605](https://github.com/getpaseo/paseo/pull/3605)) +- Added browser-style New tabs with an anchored pane-local chooser ([#3715](https://github.com/getpaseo/paseo/pull/3715), [#3735](https://github.com/getpaseo/paseo/pull/3735)) +- Added active-turn steering for Claude, Codex, and OpenCode ([#3394](https://github.com/getpaseo/paseo/pull/3394), [#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger)) +- Added workspace labels for sidebar organization and filtering ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter)) +- Added live workspace change counts above active agent composers ([#3682](https://github.com/getpaseo/paseo/pull/3682)) +- Added workspace panel, tab, and pane actions to the Command Center ([#3685](https://github.com/getpaseo/paseo/pull/3685)) +- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460)) +- Added `paseo reload` to apply runtime-safe `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365)) +- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451)) - Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter)) -- Added active-turn steering for OpenCode ([#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger)) -- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637)) -- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619)) +- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445)) +- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341)) +- Added one-click agent profile creation from the model chooser ([#3533](https://github.com/getpaseo/paseo/pull/3533)) +- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531), [#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky)) +- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend)) +- Added Nix syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy)) +- Added Svelte syntax highlighting ([#3487](https://github.com/getpaseo/paseo/pull/3487), [#3534](https://github.com/getpaseo/paseo/pull/3534) by [@fiorelorenzo](https://github.com/fiorelorenzo)) +- Added guided Hub setup that connects a daemon and deploys a compatible starter workflow ([#3651](https://github.com/getpaseo/paseo/pull/3651), [#3657](https://github.com/getpaseo/paseo/pull/3657), [#3677](https://github.com/getpaseo/paseo/pull/3677)) ### Improved -- Changed the Side panel into a user-directed surface with independent tabs and configurable supporting-tab routing ([#3605](https://github.com/getpaseo/paseo/pull/3605)) -- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644)) -- Reduced Android workspace-switch stalls after using several long chats ([#3610](https://github.com/getpaseo/paseo/pull/3610)) +- Restored cached projects, workspaces, agents, and timelines immediately while hosts reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329)) +- Reduced workspace-switch stalls on desktop and Android with long chats ([#3447](https://github.com/getpaseo/paseo/pull/3447), [#3610](https://github.com/getpaseo/paseo/pull/3610)) +- Kept composer typing within the frame budget on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450)) +- Kept large diffs responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422)) +- Kept large read-only source previews responsive with bounded rendering ([#3665](https://github.com/getpaseo/paseo/pull/3665)) +- Removed complete-transcript disk rewrites from long-running agent timelines ([#3647](https://github.com/getpaseo/paseo/pull/3647)) +- Added outcome summaries and failure-first grouping to pull request checks ([#3483](https://github.com/getpaseo/paseo/pull/3483)) +- Moved subagent and task trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482)) +- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424)) +- Opened summarized tool-call groups in live sheets on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619)) +- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637)) +- Updated Pi usage and context meters during active turns ([#3532](https://github.com/getpaseo/paseo/pull/3532)) +- Changed Archive finished to archive every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368)) - Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634)) +- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644)) +- Added password-equivalent warnings to pairing links in the desktop app and CLI ([#3734](https://github.com/getpaseo/paseo/pull/3734)) - Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz)) ### Fixed -- Fixed modified tab indicators overlapping truncated tab titles -- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter)) -- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636)) -- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639)) -- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit)) -- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334)) -- Fixed OpenCode turns failing when plugin-heavy startup takes more than ten seconds ([#3578](https://github.com/getpaseo/paseo/pull/3578) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco)) -- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591)) -- Fixed Android Studio showing a generic terminal icon in the desktop open-target menu ([#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky)) - -## 0.5.0-beta.3 - 2026-08-20 - -### Fixed - -- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564)) -- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger)) - -## 0.5.0-beta.2 - 2026-08-19 - -### Added - -- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky)) - -### Fixed - -- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published -- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins -- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux -- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one - -## 0.5.0-beta.1 - 2026-08-18 - -### Added - -- Add experimental support for plugins ([See docs](/docs/plugins), [#3222](https://github.com/getpaseo/paseo/pull/3222), [#3446](https://github.com/getpaseo/paseo/pull/3446), [#3465](https://github.com/getpaseo/paseo/pull/3465)) -- Added workspace labels to organize the sidebar ([#3510](https://github.com/getpaseo/paseo/pull/3510) by [@cleiter](https://github.com/cleiter)) -- Added steering for Codex and Claude — send into a running turn instead of interrupting it ([#3394](https://github.com/getpaseo/paseo/pull/3394)) -- Reworked the explorer into a persistent pane with its own tabs, so files, changes, and the pull request stay open next to the chat ([#3287](https://github.com/getpaseo/paseo/pull/3287)) -- Changed Cmd/Ctrl+T to open a menu so you can choose what the new tab opens -- Added `paseo project` to create, list, rename, and delete projects from the terminal ([#3460](https://github.com/getpaseo/paseo/pull/3460)) -- Added `paseo reload` to apply `config.json` changes without restarting the daemon ([#3365](https://github.com/getpaseo/paseo/pull/3365)) -- Added Paseo skill management to Host → Agents, including remote hosts ([#3451](https://github.com/getpaseo/paseo/pull/3451)) -- Added optional branch and project names on workspace rows ([#3445](https://github.com/getpaseo/paseo/pull/3445)) -- Added drag-and-drop reordering for pinned workspaces ([#3341](https://github.com/getpaseo/paseo/pull/3341)) -- Added Nix and Svelte syntax highlighting ([#3110](https://github.com/getpaseo/paseo/pull/3110) by [@Strainy](https://github.com/Strainy), [#3487](https://github.com/getpaseo/paseo/pull/3487) by [@fiorelorenzo](https://github.com/fiorelorenzo)) - -### Improved - -- Improved checks summary to the pull request pane, grouped by status with failures first ([#3483](https://github.com/getpaseo/paseo/pull/3483)) -- Projects, workspaces, and agents show up instantly when the app opens ([#3259](https://github.com/getpaseo/paseo/pull/3259), [#3329](https://github.com/getpaseo/paseo/pull/3329)) -- Offline hosts keep showing their projects and workspaces while they reconnect ([#3259](https://github.com/getpaseo/paseo/pull/3259)) -- Large diffs stay responsive while expanding, scrolling, and commenting ([#3422](https://github.com/getpaseo/paseo/pull/3422)) -- Switching workspaces with keyboard shortcuts no longer lags ([#3447](https://github.com/getpaseo/paseo/pull/3447)) -- Typing in the composer no longer lags on web and desktop ([#3450](https://github.com/getpaseo/paseo/pull/3450)) -- Moved the subagents and tasks trackers into pills above the composer ([#3482](https://github.com/getpaseo/paseo/pull/3482)) -- Simplified mobile agent configuration into one options sheet ([#3424](https://github.com/getpaseo/paseo/pull/3424)) -- Archive finished now archives every finished subagent in the track ([#3368](https://github.com/getpaseo/paseo/pull/3368)) -- The text size setting now scales the whole interface - -### Fixed - -- Fixed local plugin install on the packaged desktop app: esbuild can spawn from `app.asar.unpacked`, and the plugin subprocess loads `@paseo/plugin/server` instead of the React client barrel -- Fixed CJK IME composition being cancelled in text fields and the mobile terminal ([#3517](https://github.com/getpaseo/paseo/pull/3517), [#2811](https://github.com/getpaseo/paseo/pull/2811) by [@northsea4](https://github.com/northsea4), [#3462](https://github.com/getpaseo/paseo/pull/3462) by [@chulmin-dev](https://github.com/chulmin-dev), [#3343](https://github.com/getpaseo/paseo/pull/3343) by [@jimersylee](https://github.com/jimersylee), [#3391](https://github.com/getpaseo/paseo/pull/3391) by [@ZacharyZcR](https://github.com/ZacharyZcR), [@ljy900825-dev](https://github.com/ljy900825-dev)) -- Fixed OpenCode turns failing when its event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395)) +- Fixed CJK IME composition being cancelled in text fields and mobile terminals ([#2811](https://github.com/getpaseo/paseo/pull/2811), [#3343](https://github.com/getpaseo/paseo/pull/3343), [#3391](https://github.com/getpaseo/paseo/pull/3391), [#3462](https://github.com/getpaseo/paseo/pull/3462), [#3517](https://github.com/getpaseo/paseo/pull/3517) by [@northsea4](https://github.com/northsea4), [@jimersylee](https://github.com/jimersylee), [@ZacharyZcR](https://github.com/ZacharyZcR), [@chulmin-dev](https://github.com/chulmin-dev)) +- Fixed OpenCode turns failing when their event stream dropped ([#3395](https://github.com/getpaseo/paseo/pull/3395)) +- Fixed OpenCode agents timing out during slow or plugin-heavy startup ([#3578](https://github.com/getpaseo/paseo/pull/3578), [#3621](https://github.com/getpaseo/paseo/pull/3621) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco)) - Fixed an unrelated merged pull request archiving a workspace ([#3425](https://github.com/getpaseo/paseo/pull/3425)) - Fixed Annotate element and Screenshot element doing nothing on loaded desktop browser pages ([#3187](https://github.com/getpaseo/paseo/pull/3187) by [@dgk-dev](https://github.com/dgk-dev)) - Fixed repeated copy and fork footers after heartbeat runs ([#3484](https://github.com/getpaseo/paseo/pull/3484)) - Fixed Cursor plan usage on hosts signed in only through `cursor-agent` ([#3486](https://github.com/getpaseo/paseo/pull/3486) by [@Lite-G](https://github.com/Lite-G)) -- Fixed resumed subagents splitting into separate rows -- Fixed a detected pull request revealing the explorer pane and stealing focus, which then made new agents, terminals, and browser tabs open inside the explorer pane instead of the main one -- Fixed workspace pull request status being lost on reload -- Fixed hidden terminal tabs stopping output in the focused workspace +- Fixed mobile composers retaining sent text after submission ([#3564](https://github.com/getpaseo/paseo/pull/3564)) +- Fixed multiline composers retaining stale heights across workspace and draft lifecycles ([#3681](https://github.com/getpaseo/paseo/pull/3681), [#3740](https://github.com/getpaseo/paseo/pull/3740)) +- Fixed pull request checkout when the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger)) +- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter)) +- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636)) +- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639)) +- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit)) +- Fixed provider usage checks overwriting Claude or Codex credential files ([#3442](https://github.com/getpaseo/paseo/pull/3442) by [@danberindei](https://github.com/danberindei)) +- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334)) +- Fixed agents remaining impossible to stop after the provider had already settled their turn ([#3742](https://github.com/getpaseo/paseo/pull/3742)) +- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591)) +- Fixed project icons showing stale images in New Workspace ([#3600](https://github.com/getpaseo/paseo/pull/3600)) +- Fixed malformed SVG project icons crashing the iOS app ([#3711](https://github.com/getpaseo/paseo/pull/3711) by [@omercnet](https://github.com/omercnet)) +- Fixed pinning a project's only workspace hiding its New workspace action ([#3722](https://github.com/getpaseo/paseo/pull/3722) by [@yugui923](https://github.com/yugui923)) +- Fixed workspace layout changes bypassing timeline retry backoff ([#3736](https://github.com/getpaseo/paseo/pull/3736)) +- Fixed fresh installs resolving provider SDK versions that were not validated with the release ([#3678](https://github.com/getpaseo/paseo/pull/3678)) ## 0.4.0 - 2026-08-13 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 7e3d0913c3..d9f9710599 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -35,7 +35,16 @@ be in flight. ### Cancellation -Cancellation changes lifecycle state only after the provider acknowledges the interrupt or emits a terminal turn event. If the interrupt is rejected or times out, the agent remains `running` with its active foreground turn intact. Follow-up actions such as replacement, reload, rewind, and Stop must report that failure instead of accepting work they cannot perform. Synthesizing a local cancellation without provider acknowledgment creates a split-brain session: Paseo accepts a new prompt while the provider still owns the previous foreground turn. +Provider interruption is idempotent at the `AgentSession` boundary. It resolves when the prior +foreground turn can no longer run, including when the provider reports that it is already idle. It +rejects only when the provider may still own the turn. Provider adapters translate native errors +into that contract; lifecycle callers do not interpret provider-specific errors. + +After an acknowledged interrupt, the manager settles the captured run even when no terminal event +arrives or the run was still waiting for its provider turn id. The captured run token prevents an +older cancellation from settling a newer turn. If interruption is rejected or times out, the agent +keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure. +Accepting new work after an ambiguous interruption would create a split-brain session. ## Relationships diff --git a/docs/architecture.md b/docs/architecture.md index a871dc348a..6599fa673e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons. - Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path. - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state -- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false. +- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false. - `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. - `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. - `SessionContext` wraps the daemon client for the active session @@ -115,12 +115,15 @@ Cross-platform React Native app that connects to one or more daemons. The replica cache paints stale data immediately while the host connects. Directory cursors are reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers. -Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached +Pending permission requests are not restored from it. The row store is not encrypted, so the cached timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a -separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole -host snapshots in least-recently-written order; a single oversized host is omitted rather than -partially restored. Browser and Electron builds store it in IndexedDB. Native builds use -AsyncStorage, and Android reserves 64 MiB for that database. +separate product/security decision. Agents, workspaces, and projects have individual rows; the +focused timeline tail and directory checkpoint each have one row per host. Writes serialize only +rows whose in-memory identities changed. Restore validates every row before hydration; one invalid +row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row +payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is +an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use +IndexedDB. Native builds use expo-sqlite. The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an diff --git a/docs/development.md b/docs/development.md index f8834a341b..a093dfabc7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -502,6 +502,16 @@ For tighter loops, you can rebuild a single workspace: - Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`. - Changed app build dependencies: `npm run build:app-deps`. +## Dependency patches + +`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only +runs when its package is actually present, so add the package to that script's `patchedPackages` +list when you introduce a new patch — otherwise the file sits in `patches/` and never applies. +Regenerate a patch with `npx patch-package ` after editing `node_modules/`, and +patch every build the consumers use: Metro resolves the `react-native` field of a package +(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module` +(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed. + ## ACP provider catalog versions The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`, diff --git a/docs/providers.md b/docs/providers.md index cb685ad209..3ec5f710a6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -147,6 +147,10 @@ Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or ` Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file. +### Usage fetchers are read-only on credentials + +A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI. + --- ## ACP Provider Checklist diff --git a/docs/side-panel.md b/docs/side-panel.md index fdd7000ea1..2fc113ba07 100644 --- a/docs/side-panel.md +++ b/docs/side-panel.md @@ -83,7 +83,11 @@ Running and successful setup never seed tabs. persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions — the header toggle, a pane's `+` menu, the empty-state launcher — are never subject -to it. +to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are +supporting opens: they follow the preference and focus an existing Changes tab wherever +the user left it. When Changes is already visible in the Side panel, the diff-stat pill +hides the panel without closing the tab. A different visible Side panel tab switches to +Changes instead. ## The name diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 01efe84c14..d645ff6833 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -49,7 +49,7 @@ A failed catch-up or subscription reconcile retries on its own, doubling from 1s A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active writer, say — into a request and a log line every second on an idle app. The delay resets on success, reconnect, delivery-mode change, and visibility change, so recovery is still immediate once the -condition clears. +condition clears. Republishing the same visibility set is a no-op and never bypasses backoff. Background retries are silent. The retry the user presses in the sync-error callout is a fallible user action and owns its pending state: `retrying` is a status the sync model publishes, not a React diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index c552f43182..12f8957f93 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-TNwqDZ9gJP613iYmDzVfK5vfye5NBloKN5Jt9t7yhx8= +sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o= diff --git a/package-lock.json b/package-lock.json index c760aa5cc1..b111dafbf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.5.0-beta.4", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.5.0-beta.4", + "version": "0.5.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -15070,6 +15070,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/axios": { "version": "1.13.6", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", @@ -21336,6 +21342,20 @@ "node": ">=8.0" } }, + "node_modules/expo-sqlite": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz", + "integrity": "sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==", + "license": "MIT", + "dependencies": { + "await-lock": "^2.2.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-system-ui": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz", @@ -21781,6 +21801,16 @@ "license": "MIT", "optional": true }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -36140,7 +36170,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36200,6 +36230,7 @@ "expo-router": "~6.0.13", "expo-sharing": "^14.0.8", "expo-splash-screen": "~31.0.10", + "expo-sqlite": "~16.0.10", "expo-system-ui": "~6.0.7", "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", @@ -36241,6 +36272,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/markdown-it": "^14.1.2", + "@types/node": "~22.19.0", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", "@types/turndown": "^5.0.6", @@ -36254,6 +36286,7 @@ "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "expo-gradle-jvmargs": "^1.1.2", + "fake-indexeddb": "^6.2.5", "jsdom": "^20.0.3", "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", @@ -36791,6 +36824,16 @@ "node": ">=18" } }, + "packages/app/node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "packages/app/node_modules/@xterm/addon-clipboard": { "version": "0.3.0-beta.213", "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz", @@ -37192,12 +37235,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.5.0-beta.4", - "@getpaseo/protocol": "0.5.0-beta.4", - "@getpaseo/server": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", + "@getpaseo/protocol": "0.5.1", + "@getpaseo/server": "0.5.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37444,10 +37487,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { - "@getpaseo/protocol": "0.5.0-beta.4", - "@getpaseo/relay": "0.5.0-beta.4", + "@getpaseo/protocol": "0.5.1", + "@getpaseo/relay": "0.5.1", "zod": "^4.4.3" }, "devDependencies": { @@ -37458,7 +37501,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.5.0-beta.4", + "version": "0.5.1", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -37701,7 +37744,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.5.0-beta.4", + "version": "0.5.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38597,7 +38640,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38626,12 +38669,12 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38640,14 +38683,14 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", "react": "19.1.0", "zod": "^4.4.3" } }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "zod": "^4.4.3" }, @@ -38660,7 +38703,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38878,19 +38921,19 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.220", + "@anthropic-ai/claude-agent-sdk": "0.3.220", "@anthropic-ai/sdk": "^0.104.2", "@babel/parser": "^7.29.8", - "@getpaseo/client": "0.5.0-beta.4", - "@getpaseo/highlight": "0.5.0-beta.4", - "@getpaseo/plugin": "0.5.0-beta.4", - "@getpaseo/protocol": "0.5.0-beta.4", - "@getpaseo/relay": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", + "@getpaseo/highlight": "0.5.1", + "@getpaseo/plugin": "0.5.1", + "@getpaseo/protocol": "0.5.1", + "@getpaseo/relay": "0.5.1", "@isaacs/ttlcache": "^2.1.4", - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", "@xterm/headless": "^6.0.0", "ai": "5.0.78", @@ -39940,7 +39983,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.5.0-beta.4", + "version": "0.5.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index a8a90a99a1..a99b473471 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.5.0-beta.4", + "version": "0.5.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 4550f043ac..51503d751b 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -838,25 +838,36 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" }); }); -test("compact Changes keeps its options control touch-sized", async ({ page }) => { +test("compact Changes keeps its actions compact and menu-only", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await page.setViewportSize({ width: 480, height: 900 }); + const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true }); const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true }); - const [optionsBox, glyphBox] = await Promise.all([ + const [actionsBox, optionsBox, glyphBox] = await Promise.all([ + actions.boundingBox(), options.boundingBox(), options.locator("svg").boundingBox(), ]); - if (!optionsBox || !glyphBox) { - throw new Error("Compact Changes options geometry could not be measured"); + if (!actionsBox || !optionsBox || !glyphBox) { + throw new Error("Compact Changes toolbar geometry could not be measured"); } + expect(actionsBox.width).toBe(48); + expect(actionsBox.height).toBe(28); expect(optionsBox.width).toBe(32); expect(optionsBox.height).toBe(32); expect(glyphBox.width).toBe(18); expect(glyphBox.height).toBe(18); + await expect(actions).not.toContainText("Commit"); + await expect(actions.locator("svg")).toHaveCount(2); + await actions.click(); + await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible(); + await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit"); + await page.keyboard.press("Escape"); + await options.click(); const wrapLines = page.getByText("Wrap long lines", { exact: true }); await expect(wrapLines).toBeVisible(); @@ -996,6 +1007,23 @@ test("canvas diff creates, edits, and deletes an inline review without DOM code await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0); }); +test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }); + const focusedBackground = await changesTab.evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + + await startReviewOnFirstChangedLine(page); + await expect(page.getByTestId("inline-review-editor-input")).toBeFocused(); + await expect + .poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe(focusedBackground); +}); + test("split canvas creates a review on the changed side and keeps it in that column", async ({ page, }) => { @@ -1095,7 +1123,7 @@ test("canvas diff copies a dragged character selection without opening a review" await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); -test("the first click after a canvas selection only dismisses the selection", async ({ page }) => { +test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => { const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ"); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); @@ -1105,7 +1133,7 @@ test("the first click after a canvas selection only dismisses the selection", as await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); await clickFirstChangedLine(page); - await expect(page.getByTestId("inline-review-editor")).toBeVisible(); + await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); test("canvas diff replaces a selection with forward and backward drags", async ({ @@ -1537,7 +1565,8 @@ async function startReviewOnFirstChangedLine( if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x; - await page.mouse.click(columnLeft + 20, bodyBounds.y + lineHeight * 1.5); + await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5); + await page.getByRole("button", { name: "Add review comment" }).click(); await expect(page.getByTestId("inline-review-editor")).toBeVisible(); } diff --git a/packages/app/e2e/browser/changes-shortcut-placement.spec.ts b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts new file mode 100644 index 0000000000..13be178f11 --- /dev/null +++ b/packages/app/e2e/browser/changes-shortcut-placement.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; +const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`; + +function mainPane(page: Page) { + return page.getByTestId("workspace-pane-main").filter({ visible: true }); +} + +function sidePanel(page: Page) { + return page.getByTestId("workspace-side-panel").filter({ visible: true }); +} + +test("Changes shortcut follows the default Side panel placement", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" }); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + + await page.keyboard.press(CHANGES_SHORTCUT); + + await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); + +test("Changes shortcut uses the focused pane when Side panel placement is off", async ({ + page, +}) => { + await page.addInitScript((settingsKey) => { + localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false })); + }, APP_SETTINGS_KEY); + const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" }); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + + await page.keyboard.press(CHANGES_SHORTCUT); + + await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(sidePanel(page)).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts new file mode 100644 index 0000000000..003273bc6b --- /dev/null +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -0,0 +1,132 @@ +import { rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { openFilesPanel } from "../support/helpers/workspace-tabs"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; + +function visibleMainPane(page: Page) { + return page.getByTestId("workspace-pane-main").filter({ visible: true }); +} + +async function seedChangedAgent(repoPrefix: string) { + const workspace = await seedMockAgentWorkspace({ + repoPrefix, + title: "Composer diff stat", + repo: { withRemote: true }, + }); + try { + await rm(path.join(workspace.cwd, "remote.git"), { recursive: true }); + await writeFile( + path.join(workspace.cwd, "README.md"), + "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n", + ); + await workspace.client.checkoutRefresh(workspace.cwd); + await expect + .poll(async () => { + const workspaces = await workspace.client.fetchWorkspaces(); + return ( + workspaces.entries.find((entry) => entry.id === workspace.workspaceId)?.diffStat ?? null + ); + }) + .toEqual({ additions: 2, deletions: 0 }); + return workspace; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => { + const workspace = await seedChangedAgent("composer-diff-stat-side-"); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + const pill = page.getByTestId("composer-diff-stat-pill"); + await expect(pill).toBeVisible({ timeout: 30_000 }); + await expect(pill).toContainText("+2"); + await expect(pill).toContainText("-0"); + await pill.click(); + + const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true }); + await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 }); + + await test.step("switching from another Side panel view reveals Changes", async () => { + await openFilesPanel(page); + await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible(); + + await pill.click(); + await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible(); + }); + + await test.step("clicking visible Changes hides the panel without closing its tab", async () => { + await pill.click(); + await expect(sidePanel).toHaveCount(0); + await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1); + }); + + await test.step("clicking again restores Changes", async () => { + await pill.click(); + await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible(); + await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible(); + }); + } finally { + await workspace.cleanup(); + } +}); + +test("composer diff stat opens the compact explorer instead of a Changes tab", async ({ page }) => { + const workspace = await seedChangedAgent("composer-diff-stat-compact-"); + + try { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + await page.getByTestId("composer-diff-stat-pill").click(); + + await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); + +test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({ + page, +}) => { + await page.addInitScript((settingsKey) => { + localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false })); + }, APP_SETTINGS_KEY); + const workspace = await seedChangedAgent("composer-diff-stat-tab-"); + + try { + await page.setViewportSize({ width: 1400, height: 900 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + + await page.getByTestId("composer-diff-stat-pill").click(); + + const mainPane = visibleMainPane(page); + await expect(mainPane.getByTestId("workspace-tab-working_diff")).toBeVisible({ + timeout: 30_000, + }); + await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/empty-pane-keyboard.spec.ts b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts new file mode 100644 index 0000000000..1eb3d66e5a --- /dev/null +++ b/packages/app/e2e/browser/empty-pane-keyboard.spec.ts @@ -0,0 +1,80 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; + +function visibleNewTabPanel(page: Page): Locator { + return page.getByTestId("workspace-new-tab-panel").filter({ visible: true }); +} + +async function expectLauncherSelection(launcher: Locator, name: string): Promise { + await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused(); +} + +async function openSidePanelWithKeyboard(page: Page): Promise { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+E`); + const launcher = page + .getByTestId("workspace-side-panel") + .filter({ visible: true }) + .getByTestId("workspace-new-tab-panel"); + await expect(launcher).toBeVisible({ timeout: 30_000 }); + return launcher; +} + +test.describe("New tab keyboard launcher", () => { + test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" }); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + + const launcher = await openSidePanelWithKeyboard(page); + await expectLauncherSelection(launcher, "Changes"); + + await page.keyboard.press("ArrowDown"); + await expectLauncherSelection(launcher, "Files"); + await page.keyboard.press("Enter"); + + await expect( + page + .getByTestId("workspace-side-panel") + .filter({ visible: true }) + .getByTestId("workspace-tab-files"), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await workspace.cleanup(); + } + }); + + test("a new split pane focuses its launcher and supports arrow navigation", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" }); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await waitForWorkspaceTabsVisible(page); + await runWorkspaceActionFromCommandCenter(page, "Split pane right"); + + const launcher = visibleNewTabPanel(page); + await expect(launcher).toBeVisible({ timeout: 30_000 }); + await expectLauncherSelection(launcher, "Agent"); + + await page.keyboard.press("ArrowDown"); + await expectLauncherSelection(launcher, "Terminal"); + await page.keyboard.press("ArrowDown"); + await expectLauncherSelection(launcher, "Changes"); + await page.keyboard.press("ArrowDown"); + await expectLauncherSelection(launcher, "Files"); + await page.keyboard.press("Enter"); + + await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({ + timeout: 30_000, + }); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/explorer-pane-placement.spec.ts b/packages/app/e2e/browser/explorer-pane-placement.spec.ts index cdca2cf912..7dc42f7b31 100644 --- a/packages/app/e2e/browser/explorer-pane-placement.spec.ts +++ b/packages/app/e2e/browser/explorer-pane-placement.spec.ts @@ -2,9 +2,8 @@ // 1) with the explorer pane focused, a newly appearing agent must auto-open into the // MAIN pane in the background — the explorer pane is a background surface and must // never swallow entity tabs or lose the user's focus, and -// 2) splitting a tab out of a pane leaves that pane empty, and the app must stay -// interactive in the [agent | empty main | explorer] state (the reporter hit an -// app-wide lockup here with no way to close the empty pane). +// 2) splitting a tab out of a pane leaves a New tab behind, and the app must stay +// interactive in the [agent | New | explorer] state. import { expect, type Locator, type Page } from "@playwright/test"; import { test } from "../support/fixtures"; import { gotoWorkspace } from "../support/helpers/launcher"; @@ -53,19 +52,19 @@ async function closeSeededDraftInMainPane(page: Page): Promise { await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 }); } -function emptyPaneChild(page: Page): Locator { +function newTabPaneChild(page: Page): Locator { return page .getByTestId("split-group-child") - .filter({ has: page.getByTestId("workspace-pane-empty-state") }) + .filter({ has: page.getByTestId("workspace-new-tab-panel") }) .first(); } async function emptyPaneBox(page: Page) { - const paneChild = emptyPaneChild(page); + const paneChild = newTabPaneChild(page); await expect(paneChild).toBeVisible({ timeout: 15_000 }); const box = await paneChild.boundingBox(); expect(box).not.toBeNull(); - if (!box) throw new Error("Empty pane has no bounding box"); + if (!box) throw new Error("New tab pane has no bounding box"); return box; } @@ -97,7 +96,7 @@ async function dragChipTo( } test.describe("explorer pane tab placement", () => { - test("agents open in the main pane and empty-pane splits keep the app usable", async ({ + test("agents open in the main pane and New-tab splits keep the app usable", async ({ page, }, testInfo) => { const consoleErrors: string[] = []; @@ -171,9 +170,9 @@ test.describe("explorer pane tab placement", () => { body: await page.screenshot(), contentType: "image/png", }); - // The split must have taken: agent pane + empty main + explorer = 3 tab rows. + // The split must have taken: agent pane + New main + explorer = 3 tab rows. await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 }); - await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible(); + await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible(); }); await test.step("app must stay interactive after the split", async () => { @@ -186,17 +185,12 @@ test.describe("explorer pane tab placement", () => { await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 }); await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 }); - // Clicking inside the empty pane (focuses it) must not lock anything. Aim + // Clicking inside the New tab pane (focuses it) must not lock anything. Aim // above the vertically centred launcher, which would open a tab instead. - const emptyPane = emptyPaneChild(page); + const emptyPane = newTabPaneChild(page); await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 }); - // The empty pane's + menu must open and dismiss cleanly. - const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first(); - await emptyPaneTrigger.click({ timeout: 5_000 }); - await page.keyboard.press("Escape"); - - // A second drag must work: center-drop the agent tab back into the empty pane. + // A second drag must work: center-drop the agent tab back into the New pane. const target = await emptyPaneBox(page); await dragChipTo(page, chip, { x: target.x + target.width / 2, @@ -207,8 +201,8 @@ test.describe("explorer pane tab placement", () => { contentType: "image/png", }); - // The agent chip must have moved into the previously empty pane and stay clickable. - await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, { + // The agent chip must have replaced New in that pane and stay clickable. + await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(0, { timeout: 10_000, }); await agentTabChip(page, agentId) diff --git a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts index 29b6b5256b..0da548e08e 100644 --- a/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts +++ b/packages/app/e2e/browser/explorer-surface-upgrade.spec.ts @@ -71,7 +71,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => const toggle = visible(page, "workspace-explorer-toggle").first(); await toggle.click(); await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible(); + await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible(); await expect(dockedSidebar(page)).toHaveCount(0); await toggle.click(); @@ -108,7 +108,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await test.step("open the explorer so both sides are showing", async () => { await toggle.click(); await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible(); + await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible(); await expect(agentList).toHaveCount(1); }); @@ -122,7 +122,7 @@ test.describe("explorer surface after upgrading from the docked sidebar", () => await page.keyboard.press(`${modifier}+.`); await expect(agentList).toHaveCount(1, { timeout: 15_000 }); await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 }); - await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible(); + await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible(); }); } finally { await workspace.cleanup(); diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index c815b3a065..daedee41ad 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) { } test.describe("CodeMirror workspace file editing", () => { + test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({ + page, + }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "file-source-lockfile-", + title: "Large source", + initialPrompt: "Generate a title and a git branch name. Return JSON only.", + }); + const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`; + await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8"); + + try { + await openAgentRoute(page, session); + await openWorkspaceFile(page, "package-lock.json"); + + await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(editor(page)).toContainText('"package-0"'); + await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200); + } finally { + await session.cleanup(); + } + }); + + test("keeps the app interactive around a plain 11 MB source", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "file-source-plain-", + title: "Plain large source", + initialPrompt: "Generate a title and a git branch name. Return JSON only.", + }); + await writeFile( + path.join(session.cwd, "plain.txt"), + "plain source\n".repeat(1_050_000), + "utf8", + ); + + try { + await openAgentRoute(page, session); + await openWorkspaceFile(page, "plain.txt"); + await expect(page.getByTestId("file-source-editor")).toBeVisible(); + await expect(editor(page)).toContainText("plain source"); + await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200); + + await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click(); + await expect(page.getByTestId("message-input-root")).toBeVisible(); + await page.getByTestId("workspace-tab-file_plain.txt").first().click(); + await expect(page.getByTestId("file-source-editor")).toBeVisible(); + } finally { + await session.cleanup(); + } + }); + + test("refuses a file above the display budget and keeps its tab recoverable", async ({ + page, + }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "file-source-unsupported-", + title: "Unsupported large source", + initialPrompt: "Generate a title and a git branch name. Return JSON only.", + }); + await writeFile( + path.join(session.cwd, "too-large.txt"), + Buffer.alloc(51 * 1024 * 1024), + "utf8", + ); + + try { + await openAgentRoute(page, session); + await openWorkspaceFile(page, "too-large.txt"); + await expect(page.getByTestId("file-source-too-large")).toContainText( + "This file is too large to display", + ); + + await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click(); + await expect(page.getByTestId("message-input-root")).toBeVisible(); + await page.getByTestId("workspace-tab-file_too-large.txt").first().click(); + await expect(page.getByTestId("file-source-too-large")).toBeVisible(); + } finally { + await session.cleanup(); + } + }); + test("opens an assistant file link at its referenced line", async ({ page }) => { const target = "target.ts:42"; const session = await seedAgentWithFileLink({ @@ -413,10 +494,7 @@ test.describe("CodeMirror workspace file editing", () => { .filter({ visible: true }) .first() .click({ button: "right" }); - await page - .getByTestId("workspace-tab-context-file_draft.ts-close") - .filter({ visible: true }) - .click(); + await page.getByRole("menuitem", { name: "Close", exact: true }).click(); expect(closePrompt).toContain("Closing it will discard the draft."); await expect(page.getByTestId("file-source-editor")).toBeVisible(); diff --git a/packages/app/e2e/browser/file-transition.spec.ts b/packages/app/e2e/browser/file-transition.spec.ts index 8235ce0578..574f9baa82 100644 --- a/packages/app/e2e/browser/file-transition.spec.ts +++ b/packages/app/e2e/browser/file-transition.spec.ts @@ -39,6 +39,12 @@ async function assertHeldFileTransition( finalText: string, gate: Awaited>, ) { + await page.evaluate(() => { + const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]'); + ( + window as typeof window & { __fileTransitionOriginalChip?: Element | null } + ).__fileTransitionOriginalChip = originalChip; + }); await page.evaluate((tabTestId) => { const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = []; const sample = () => { @@ -69,6 +75,19 @@ async function assertHeldFileTransition( await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0); await expect(page.getByText("export const sourceA = true;")).toHaveCount(0); + const chipIdentity = await page.evaluate((tabTestId) => { + const windowWithOriginal = window as typeof window & { + __fileTransitionOriginalChip?: Element | null; + }; + const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null; + const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`); + return { + originalStillConnected: originalChip?.isConnected ?? false, + sameNode: originalChip === currentChip, + }; + }, `workspace-tab-file_${targetPath}`); + expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true }); + const heldTimeline = await page.evaluate( () => ( diff --git a/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts b/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts index d023d22191..14780334ed 100644 --- a/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts +++ b/packages/app/e2e/browser/file-tree-rail-toggle.spec.ts @@ -9,6 +9,18 @@ import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-cli let workspace: SeededWorkspace; +async function effectiveBackground(locator: import("@playwright/test").Locator): Promise { + return locator.evaluate((element) => { + let current: Element | null = element; + while (current) { + const color = getComputedStyle(current).backgroundColor; + if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color; + current = current.parentElement; + } + return "transparent"; + }); +} + test.beforeAll(async () => { workspace = await seedWorkspace({ repoPrefix: "file-tree-rail-toggle-", @@ -21,6 +33,21 @@ test.afterAll(async () => { }); test.describe("File panel tree rail", () => { + test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const rail = page.getByTestId("files-tree-rail").filter({ visible: true }); + await expect(rail).toBeVisible({ timeout: 30_000 }); + const content = rail.getByTestId("files-tree-rail-content"); + const tree = rail.getByTestId("files-tree-rail-tree"); + await expect(content).toHaveText("Choose a file"); + await expect(tree).toContainText("docs"); + expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe( + await effectiveBackground(content), + ); + }); + test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => { await gotoWorkspace(page, workspace.workspaceId); await openFileExplorer(page); diff --git a/packages/app/e2e/browser/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts index 59bf8d71b9..b4c150cd98 100644 --- a/packages/app/e2e/browser/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -1,11 +1,11 @@ import { test, expect } from "../support/fixtures"; +import type { Locator } from "@playwright/test"; import { gotoWorkspace, assertNewChatTileVisible, assertNewTabMenuTriggerVisible, assertSingleNewTabButton, openNewTabMenuWithShortcut, - pressDirectNewTabShortcut, clickNewChat, clickNewTerminal, countTabsOfKind, @@ -45,6 +45,16 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = { args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"], }; +async function tabTestIds(tabs: Locator): Promise<(string | null)[]> { + return tabs.evaluateAll((elements) => + elements.map((element) => element.getAttribute("data-testid")), + ); +} + +function tabIdentityKey(snapshot: Array<{ id: string }>): string { + return JSON.stringify(snapshot.map(({ id }) => id)); +} + test.beforeAll(async () => { workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" }); const created = await workspace.client.createWorkspace({ @@ -66,14 +76,12 @@ test.afterAll(async () => { // ═══════════════════════════════════════════════════════════════════════════ test.describe("Tab creation", () => { - test("Cmd+T keeps opening the New tab menu after workspace switches", async ({ page }) => { + test("Cmd+T keeps creating a New tab after workspace switches", async ({ page }) => { if (!secondWorkspaceId) { throw new Error("Secondary workspace was not created"); } const serverId = getServerId(); - const newTabMenuItem = page.getByRole("menuitem", { name: /^Agent/ }); - const switchWorkspaceRow = async (workspaceId: string) => { const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first(); await expect(row).toBeVisible({ timeout: 30_000 }); @@ -92,11 +100,12 @@ test.describe("Tab creation", () => { for (let i = 0; i < 8; i++) { await switchWorkspaceRow(sequence[i % sequence.length]); await pressNewTabShortcut(page); - await expect(newTabMenuItem, `New tab menu did not open after switch ${i}`).toBeVisible({ + await expect( + page.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + `New tab did not open after switch ${i}`, + ).toBeVisible({ timeout: 5_000, }); - await page.keyboard.press("Escape"); - await expect(newTabMenuItem).toBeHidden({ timeout: 5_000 }); } }); @@ -104,59 +113,76 @@ test.describe("Tab creation", () => { await gotoWorkspace(page, workspace.workspaceId); const workspaceUrl = page.url(); const modifier = process.platform === "darwin" ? "Meta" : "Control"; + const newTabCountBefore = await countTabsOfKind(page, "new_tab"); await page.keyboard.press(`${modifier}+Comma`); await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible(); await page.keyboard.press(`${modifier}+t`); - await expect(page.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0); await page.goto(workspaceUrl); await assertNewTabMenuTriggerVisible(page); - const countBefore = await countTabsOfKind(page, "draft"); - await pressDirectNewTabShortcut(page, "a"); - await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore + 1); + await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore); }); - test("Cmd+T opens the New tab menu without creating an agent", async ({ page }) => { + test("Cmd+T creates a New tab without creating an agent", async ({ page }) => { await gotoWorkspace(page, workspace.workspaceId); const countBefore = await countTabsOfKind(page, "draft"); await openNewTabMenuWithShortcut(page); await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore); - await expect(page.getByRole("button", { name: "New tab", expanded: true })).toBeVisible(); + await expect( + page.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + ).toBeVisible(); }); - test("opening two new tabs creates two draft tabs", async ({ page }) => { + test("clicking + opens its menu without creating a New tab", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + const countBefore = await countTabsOfKind(page, "new_tab"); + + await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click(); + + await expect( + page.getByTestId("workspace-new-tab-menu").filter({ visible: true }), + ).toBeVisible(); + await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore); + }); + + test("opening two New tabs creates two independent tab identities", async ({ page }) => { await gotoWorkspace(page, workspace.workspaceId); - const countBefore = await countTabsOfKind(page, "draft"); + const newTabs = page + .locator('[data-testid^="workspace-tab-tab_"]') + .filter({ hasText: "New tab" }); + const countBefore = await newTabs.count(); - await pressDirectNewTabShortcut(page, "a"); - await expect - .poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) - .toBe(countBefore + 1); - const countAfterFirst = await countTabsOfKind(page, "draft"); + await pressNewTabShortcut(page); + await expect(newTabs).toHaveCount(countBefore + 1); + const firstIds = await tabTestIds(newTabs); - await pressDirectNewTabShortcut(page, "a"); - await expect - .poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 }) - .toBe(countAfterFirst + 1); + await pressNewTabShortcut(page); + await expect(newTabs).toHaveCount(countBefore + 2); + const ids = await tabTestIds(newTabs); + expect(new Set(ids).size).toBe(ids.length); + expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1); }); - test("New tab menu exposes shortcuts and supports arrow navigation", async ({ page }) => { + test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({ + page, + }) => { await gotoWorkspace(page, workspace.workspaceId); await openNewTabMenuWithShortcut(page); - const agent = page.getByRole("menuitem", { name: /^Agent/ }); - const terminal = page.getByRole("menuitem", { name: /^Terminal/ }); - const changes = page.getByRole("menuitem", { name: /Changes/ }); - const files = page.getByRole("menuitem", { name: /Files/ }); + const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true }); + const agent = panel.getByRole("button", { name: /^Agent/ }); + const terminal = panel.getByRole("button", { name: /^Terminal/ }); + const changes = panel.getByRole("button", { name: /Changes/ }); + const files = panel.getByRole("button", { name: /Files/ }); const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/; await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`)); await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`)); - await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*C`)); + await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`)); await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`)); await expect(agent).toBeFocused(); @@ -164,12 +190,10 @@ test.describe("Tab creation", () => { await expect(terminal).toBeFocused(); await page.keyboard.press("ArrowUp"); await expect(agent).toBeFocused(); - await page.keyboard.press("Escape"); - await expect(page.getByRole("button", { name: "New tab" })).toBeFocused(); - await expect(page.getByRole("button", { name: "New tab" })).toHaveAttribute( - "aria-expanded", - "false", - ); + + await page.locator("body").click({ position: { x: 1, y: 1 } }); + await panel.click({ position: { x: 20, y: 20 } }); + await expect(agent).toBeFocused(); }); test("clicking new agent tab creates a draft tab", async ({ page }) => { @@ -205,8 +229,12 @@ test.describe("Tab creation", () => { try { await gotoWorkspace(page, workspace.workspaceId); - await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click(); - await page.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }).click(); + await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click(); + await page + .getByTestId("workspace-new-tab-menu") + .filter({ visible: true }) + .getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name }) + .click(); await expectTerminalOutputContains(page, "prompt-args: 0"); } finally { @@ -214,6 +242,20 @@ test.describe("Tab creation", () => { } }); + test("terminal profiles are grouped with a settings action", async ({ page }) => { + await gotoWorkspace(page, workspace.workspaceId); + await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click(); + + const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true }); + await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible(); + + const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles"); + await expect(editProfiles).toHaveAccessibleName("Edit profiles"); + + await editProfiles.click(); + await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/); + }); + test("tab bar shows action buttons per pane", async ({ page }) => { await gotoWorkspace(page, workspace.workspaceId); await assertSingleNewTabButton(page); @@ -226,15 +268,13 @@ test.describe("Tab creation", () => { const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(); const scrollArea = tabRow.getByTestId("workspace-tabs-scroll"); - const newTabButtonInScroll = await scrollArea - .getByTestId("workspace-new-tab-menu-trigger") - .count(); + const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count(); const scrollShades = await tabRow .locator('[data-testid^="workspace-tabs-scroll-shade-"]') .count(); expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true); - await expect(tabRow.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible(); + await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible(); }); }); @@ -333,28 +373,34 @@ test.describe("Terminal title propagation", () => { // ═══════════════════════════════════════════════════════════════════════════ test.describe("Tab transitions (no flash)", () => { - test("New agent tab transition has no blank intermediate tab state", async ({ page }) => { - await gotoWorkspace(page, workspace.workspaceId); + test("New agent tab transition has no blank intermediate tab state", async ({ + page, + withWorkspace, + }) => { + const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" }); + await isolatedWorkspace.navigateTo(); + await pressNewTabShortcut(page); + await expect( + page.getByTestId("workspace-new-tab-panel").filter({ visible: true }), + ).toBeVisible(); - // Sample tabs at high frequency across the transition - const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page)); + // Sample the single New → Agent replacement, not the separate action that + // creates the New tab in the first place. + const snapshots = await sampleTabsDuringTransition(page, async () => { + await page.getByTestId("workspace-new-tab-agent").filter({ visible: true }).first().click(); + }); // Every snapshot should have at least one tab — no blank/zero-tab frames for (const snapshot of snapshots) { expect(snapshot.length).toBeGreaterThanOrEqual(1); } - // Tab count should never spike excessively (no duplicate flash from add-then-remove). - // When running in-suite, previous tests may have created tabs on the shared workspace, - // so we allow +2 tolerance for accumulated state and React render batching. + // Replacement is atomic: the set of identities changes once, while its size stays fixed. const counts = snapshots.map((snapshot) => snapshot.length); - const maxCount = Math.max(...counts); const initialCount = counts[0] ?? 0; - expect(maxCount).toBeLessThanOrEqual(initialCount + 2); - expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot))).size).toBeLessThanOrEqual( - 2, - ); + expect(counts.every((count) => count === initialCount)).toBe(true); + expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2); await expectTabTitleFits(page, "New Agent", { min: 96, max: 160 }); }); diff --git a/packages/app/e2e/browser/new-tab-startup.spec.ts b/packages/app/e2e/browser/new-tab-startup.spec.ts new file mode 100644 index 0000000000..28d72d6dfb --- /dev/null +++ b/packages/app/e2e/browser/new-tab-startup.spec.ts @@ -0,0 +1,49 @@ +import { expect } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +test("a workspace with an existing agent never presents New during startup", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "new-tab-startup-agent-" }); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Existing startup agent", + modeId: "load-test", + model: "ten-second-stream", + initialPrompt: "ready", + }); + + try { + await page.addInitScript(() => { + Reflect.set(globalThis, "__paseoSawVisibleNewTab", false); + function recordVisibleNewTab() { + const panel = document.querySelector('[data-testid="workspace-new-tab-panel"]'); + if (panel?.getClientRects().length) { + Reflect.set(globalThis, "__paseoSawVisibleNewTab", true); + } + } + window.addEventListener("DOMContentLoaded", () => { + new MutationObserver(recordVisibleNewTab).observe(document.documentElement, { + childList: true, + subtree: true, + }); + recordVisibleNewTab(); + }); + }); + + await gotoWorkspace(page, workspace.workspaceId); + await expect( + page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }), + ).toHaveAttribute("aria-selected", "true", { timeout: 30_000 }); + await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toHaveCount( + 0, + ); + expect(await page.evaluate(() => Reflect.get(globalThis, "__paseoSawVisibleNewTab"))).toBe( + false, + ); + } finally { + await workspace.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/replica-cache-measurement.spec.ts b/packages/app/e2e/browser/replica-cache-measurement.spec.ts new file mode 100644 index 0000000000..cc77f2260c --- /dev/null +++ b/packages/app/e2e/browser/replica-cache-measurement.spec.ts @@ -0,0 +1,50 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { + beginReplicaCacheObservation, + finishReplicaCacheObservation, + installReplicaCacheMeasurement, + measureReplicaCacheRestore, + type ReplicaCacheMeasurementReport, +} from "../support/helpers/replica-cache-measurement"; + +const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1"; +const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL; +const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT; +const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000); +const HYDRATED_SELECTOR = + process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ?? + '[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]'; + +const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip; + +measurementDescribe("Replica cache live measurement", () => { + test("observes persistence and cached restore without mutating the daemon", async ({ + page, + }, testInfo) => { + test.setTimeout(OBSERVATION_MS + 90_000); + expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy(); + expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy(); + + await installReplicaCacheMeasurement(page); + await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle").catch(() => undefined); + + const observation = await beginReplicaCacheObservation(page); + await page.waitForTimeout(OBSERVATION_MS); + const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!); + const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR); + const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs }; + + await mkdir(path.dirname(REPORT_PATH!), { recursive: true }); + await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`); + await testInfo.attach("replica-cache-measurement", { + body: JSON.stringify(report, null, 2), + contentType: "application/json", + }); + console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`); + + expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS); + }); +}); diff --git a/packages/app/e2e/browser/replica-cache-performance.spec.ts b/packages/app/e2e/browser/replica-cache-performance.spec.ts index 11ac76bd73..2861e64878 100644 --- a/packages/app/e2e/browser/replica-cache-performance.spec.ts +++ b/packages/app/e2e/browser/replica-cache-performance.spec.ts @@ -14,7 +14,7 @@ import { const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1"; const MAX_STREAM_WRITES = 12; -const MAX_SERIALIZED_CHARS = 190_000; +const MAX_SERIALIZED_CHARS = 45_000; const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({ replicaCacheAgent: async ({ page: _page }, provide) => { diff --git a/packages/app/e2e/browser/side-panel.spec.ts b/packages/app/e2e/browser/side-panel.spec.ts index 64fbd7f7de..e46a709acd 100644 --- a/packages/app/e2e/browser/side-panel.spec.ts +++ b/packages/app/e2e/browser/side-panel.spec.ts @@ -18,18 +18,20 @@ function mainPane(page: Page): Locator { return page.getByTestId("workspace-pane-main").filter({ visible: true }); } -function emptyState(scope: Locator): Locator { - return scope.getByTestId("workspace-pane-empty-state"); +function newTabPanel(scope: Locator): Locator { + return scope.getByTestId("workspace-new-tab-panel"); +} + +async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> { + return launcher + .getByRole("button") + .evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label"))); } function sidePanelToggle(page: Page): Locator { return page.getByTestId("workspace-explorer-toggle").first(); } -function closePaneButton(page: Page): Locator { - return page.getByRole("button", { name: "Close pane" }); -} - /** Drags the pane's only tab into the left edge of its own pane, splitting it out. */ async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise { const paneBox = await mainPane(page).boundingBox(); @@ -61,13 +63,20 @@ test.describe("Side panel", () => { await test.step("revealing the Side panel seeds nothing", async () => { await sidePanelToggle(page).click(); await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 }); - await expect(emptyState(sidePanel(page))).toBeVisible(); + const launcher = newTabPanel(sidePanel(page)); + await expect(launcher).toBeVisible(); + expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([ + "Changes", + "Files", + "Terminal", + "Agent", + ]); await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0); await capture(page, testInfo, "01-side-panel-reveals-empty"); }); await test.step("Changes opens in the Side panel that offered it", async () => { - await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click(); + await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click(); await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({ timeout: 30_000, }); @@ -77,10 +86,6 @@ test.describe("Side panel", () => { await capture(page, testInfo, "02-changes-opens-in-side-panel"); }); - await test.step("a loaded pane offers no one-tap close", async () => { - await expect(closePaneButton(page)).toHaveCount(0); - }); - await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => { const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff"); await changesTab.hover(); @@ -93,7 +98,7 @@ test.describe("Side panel", () => { await sidePanelToggle(page).click(); await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 }); - await expect(emptyState(sidePanel(page))).toBeVisible(); + await expect(newTabPanel(sidePanel(page))).toBeVisible(); await capture(page, testInfo, "04-side-panel-revealed-empty"); }); } finally { @@ -123,23 +128,30 @@ test.describe("Side panel", () => { .first(); await expect(draftPane).toBeVisible({ timeout: 15_000 }); - const emptyMain = emptyState(mainPane(page)); + const emptyMain = newTabPanel(mainPane(page)); await expect(emptyMain).toBeVisible({ timeout: 15_000 }); await capture(page, testInfo, "06-empty-main-pane-launcher"); - await emptyMain.getByRole("button", { name: "Close pane" }).click(); + const newTabChip = mainPane(page) + .locator('[data-testid^="workspace-tab-tab_"]') + .filter({ hasText: "New tab" }); + await newTabChip.hover(); + await mainPane(page) + .locator('[data-testid^="workspace-new-tab-close-"]') + .filter({ visible: true }) + .click(); // Gone for good, unlike the Side panel beside it, which is still only hidden away. await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 }); await expect(sidePanel(page)).toBeVisible(); - await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0); + await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0); await capture(page, testInfo, "07-empty-main-pane-removed"); } finally { await workspace.cleanup(); } }); - test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => { + test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => { const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" }); try { @@ -150,7 +162,9 @@ test.describe("Side panel", () => { // holds that tab, so emptying Main does not look like an empty workspace and // nothing reseeds a draft into it. await ensureSidePanel(page); - await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click(); + await newTabPanel(sidePanel(page)) + .getByRole("button", { name: "Files", exact: true }) + .click(); await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({ timeout: 30_000, }); @@ -168,13 +182,20 @@ test.describe("Side panel", () => { .first() .click(); - // Main is now empty and the only pane the user can see. Taking it away would - // leave nowhere to look, so the launcher appears without a Close pane. - const lastPane = emptyState(mainPane(page)); + // Main now contains New and is the only pane the user can see. Closing its + // tab cannot remove the final visible pane. + const lastPane = newTabPanel(mainPane(page)); await expect(lastPane).toBeVisible({ timeout: 15_000 }); - await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible(); - await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0); - await expect(closePaneButton(page)).toHaveCount(0); + await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible(); + const finalNewTab = mainPane(page) + .locator('[data-testid^="workspace-tab-tab_"]') + .filter({ hasText: "New tab" }); + await finalNewTab.hover(); + await mainPane(page) + .locator('[data-testid^="workspace-new-tab-close-"]') + .filter({ visible: true }) + .click(); + await expect(lastPane).toBeVisible(); await capture(page, testInfo, "08-final-visible-pane-has-no-close"); } finally { await workspace.cleanup(); @@ -190,13 +211,13 @@ test.describe("Side panel", () => { await gotoWorkspace(page, workspace.workspaceId); await waitForWorkspaceTabsVisible(page); await ensureSidePanel(page); - await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click(); + await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click(); await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({ timeout: 30_000, }); await capture(page, testInfo, "08-changes-in-side-panel"); - await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click(); + await mainPane(page).getByTestId("workspace-new-tab-button").click(); await page .getByTestId("workspace-new-tab-menu-changes") .filter({ visible: true }) diff --git a/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts index 4feca297ea..a471980e09 100644 --- a/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts @@ -1,6 +1,10 @@ import { test, expect, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { + expectNewWorkspaceProjectSelected, + openNewWorkspaceComposer, +} from "../support/helpers/new-workspace"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; @@ -142,6 +146,25 @@ async function installPinRpcGate( } test.describe("Pin workspace shortcut", () => { + test("keeps project creation reachable after pinning its only workspace", async ({ page }) => { + const workspace = await seedWorkspace({ + repoPrefix: "pin-project-creation-", + title: "Pinned workspace", + }); + + try { + await gotoAppShell(page); + await openWorkspace(page, workspace.workspaceId); + await page.keyboard.press(PIN_SHORTCUT); + await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 }); + + await openNewWorkspaceComposer(page, workspace); + await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName); + } finally { + await workspace.cleanup(); + } + }); + test("pins the active workspace while its project section is collapsed", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-collapsed-" }); diff --git a/packages/app/e2e/browser/terminal-split-resize.spec.ts b/packages/app/e2e/browser/terminal-split-resize.spec.ts index ab48305505..b0fba714d7 100644 --- a/packages/app/e2e/browser/terminal-split-resize.spec.ts +++ b/packages/app/e2e/browser/terminal-split-resize.spec.ts @@ -81,7 +81,7 @@ async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness beforeXterm.rows, ); - // Split the pane downward. This focuses the new empty pane, so the terminal + // Split the pane downward. This focuses the new tab pane, so the terminal // pane is unfocused at the exact moment its container shrinks. await runWorkspaceActionFromCommandCenter(page, "Split pane down"); diff --git a/packages/app/e2e/browser/terminal-stuck-size.spec.ts b/packages/app/e2e/browser/terminal-stuck-size.spec.ts index ffca86b930..048b92dc09 100644 --- a/packages/app/e2e/browser/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/browser/terminal-stuck-size.spec.ts @@ -59,8 +59,12 @@ async function createTerminalViaMenu(page: Page): Promise { // Workspaces always render a hidden explorer companion pane alongside the // main pane, so an unscoped testid locator matches both; scope to the // visible one. - await page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).click(); - await page.getByTestId("workspace-new-tab-menu-terminal").click(); + await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click(); + await page + .getByTestId("workspace-new-tab-menu-terminal") + .filter({ visible: true }) + .first() + .click(); } async function listTerminalIds(harness: TerminalE2EHarness): Promise { @@ -128,7 +132,7 @@ test.describe("terminal PTY size claim under lost window focus", () => { await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId)); await expect( - page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }), + page.getByTestId("workspace-new-tab-button").filter({ visible: true }), ).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts index 5984b2be35..3bf78d998e 100644 --- a/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts @@ -133,7 +133,7 @@ test.describe("Workspace agent title handoff", () => { .toBe(promptTitle); await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 }); await agentTab.click({ button: "right" }); - await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({ + await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible({ timeout: 10_000, }); await page.keyboard.press("Escape"); diff --git a/packages/app/e2e/browser/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts index 1f0efec749..9d7f286307 100644 --- a/packages/app/e2e/browser/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -65,10 +65,10 @@ test.describe("Workspace pane mounting", () => { .first() .elementHandle(); expect(originalComposer).not.toBeNull(); - const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count(); + const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count(); await runWorkspaceActionFromCommandCenter(page, "Split pane right"); - await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount( + await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount( emptyPanesBeforeSplit + 1, { timeout: 30_000 }, ); diff --git a/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts index 33eba86764..80ba958837 100644 --- a/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts @@ -70,9 +70,9 @@ test.describe("Workspace terminal tab rename", () => { await expect(tab).toBeVisible({ timeout: 15_000 }); await tab.click({ button: "right" }); - const copyTerminalId = page.getByTestId( - `workspace-tab-context-terminal_${terminalId}-copy-terminal-id`, - ); + const copyTerminalId = page.getByRole("menuitem", { + name: /^Copy terminal id/, + }); await expect(copyTerminalId).toBeVisible({ timeout: 10_000 }); await copyTerminalId.click(); @@ -99,10 +99,7 @@ test.describe("Workspace terminal tab rename", () => { await expect(tab).toBeVisible({ timeout: 15_000 }); await tab.click({ button: "right" }); - await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ - timeout: 10_000, - }); - const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`); + const renameItem = page.getByRole("menuitem", { name: "Rename", exact: true }); await expect(renameItem).toBeVisible({ timeout: 10_000 }); await renameItem.click(); diff --git a/packages/app/e2e/support/global-setup.ts b/packages/app/e2e/support/global-setup.ts index 11f16ccc93..3d9de88e7e 100644 --- a/packages/app/e2e/support/global-setup.ts +++ b/packages/app/e2e/support/global-setup.ts @@ -196,6 +196,12 @@ async function loadHarnessEnvironment(repoRoot: string): Promise { } export default async function globalSetup() { + if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") { + if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) { + throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement"); + } + return; + } const repoRoot = path.resolve(__dirname, "../../../.."); await loadHarnessEnvironment(repoRoot); diff --git a/packages/app/e2e/support/helpers/agent-profiles.ts b/packages/app/e2e/support/helpers/agent-profiles.ts index 59deb88638..d9bd9c4044 100644 --- a/packages/app/e2e/support/helpers/agent-profiles.ts +++ b/packages/app/e2e/support/helpers/agent-profiles.ts @@ -275,7 +275,7 @@ async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promi await chooseSelectFieldOption(page, { field: "mode", option: draft.mode }); } if (draft.notes !== undefined) { - await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes); + await modal.getByRole("textbox", { name: "When to use", exact: true }).fill(draft.notes); } } @@ -329,11 +329,18 @@ export async function expectAgentProfileForm( const modal = editModal(page); await expect(modal).toBeVisible({ timeout: 30_000 }); await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name); + const notesHint = modal.getByTestId("agent-profile-notes-field-hint"); + await expect(notesHint).toHaveText( + "Helps agents choose this profile when starting another agent.", + ); + expect(await notesHint.evaluate((element) => getComputedStyle(element).webkitLineClamp)).not.toBe( + "1", + ); for (const [field, value] of Object.entries(expected)) { if (field === "notes") { - await expect( - modal.getByRole("textbox", { name: "Notes for agents", exact: true }), - ).toHaveValue(value); + await expect(modal.getByRole("textbox", { name: "When to use", exact: true })).toHaveValue( + value, + ); continue; } await expect( diff --git a/packages/app/e2e/support/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts index f1cdde984f..cf0c42c3fb 100644 --- a/packages/app/e2e/support/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -62,7 +62,7 @@ export async function getActiveTabTestId(page: Page): Promise { // ─── Tab actions ─────────────────────────────────────────────────────────── -/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open the focused pane's New tab menu. */ +/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to create a New tab in the focused pane. */ export async function pressNewTabShortcut(page: Page): Promise { const modifier = process.platform === "darwin" ? "Meta" : "Control"; await page.keyboard.press(`${modifier}+t`); @@ -70,7 +70,7 @@ export async function pressNewTabShortcut(page: Page): Promise { export async function openNewTabMenuWithShortcut(page: Page): Promise { await pressNewTabShortcut(page); - await expect(page.getByRole("menuitem", { name: /^Agent/ })).toBeVisible(); + await expect(page.getByTestId("workspace-new-tab-panel").filter({ visible: true })).toBeVisible(); } export async function pressDirectNewTabShortcut(page: Page, key: string): Promise { @@ -83,30 +83,27 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis /** Assert the inline plus button is visible in the tab bar. */ export async function assertNewChatTileVisible(page: Page): Promise { await expect( - page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(), + page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(), ).toBeVisible(); } -/** Assert the new-tab dropdown trigger is visible in the tab bar. */ +/** Assert the New tab button is visible in the tab bar. */ export async function assertNewTabMenuTriggerVisible(page: Page): Promise { await expect( - page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(), + page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(), ).toBeVisible(); } // ─── Tab creation actions ───────────────────────────────────────────────── -/** Open the new-tab menu and click "New agent" to create a draft/chat tab. */ +/** Choose Agent from the pane-local `+` menu. */ export async function clickNewChat(page: Page): Promise { await createAgentTabFromMenu(page); } -/** Open the new-tab menu and click "New terminal". */ +/** Choose Terminal from the pane-local `+` menu. */ export async function clickNewTerminal(page: Page): Promise { - const trigger = page - .getByTestId("workspace-new-tab-menu-trigger") - .filter({ visible: true }) - .first(); + const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(); await expect(trigger).toBeVisible({ timeout: 10_000 }); await trigger.click(); const item = page @@ -137,7 +134,7 @@ export async function waitForTabWithTitle( /** Assert the inline plus button is visible in the tab bar. */ export async function assertSingleNewTabButton(page: Page): Promise { - const buttons = page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }); + const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true }); const count = await buttons.count(); expect(count).toBeGreaterThanOrEqual(1); } @@ -224,7 +221,7 @@ export async function expectTabTitleFits( } export function terminalSurfaceLocator(page: Page) { - return page.locator('[data-testid="terminal-surface"]').first(); + return page.locator('[data-testid="terminal-surface"]').filter({ visible: true }).first(); } export async function expectAgentTabActive(page: Page, agentId: string): Promise { diff --git a/packages/app/e2e/support/helpers/mock-agent.ts b/packages/app/e2e/support/helpers/mock-agent.ts index 6e4a04812e..b6448c4b81 100644 --- a/packages/app/e2e/support/helpers/mock-agent.ts +++ b/packages/app/e2e/support/helpers/mock-agent.ts @@ -14,6 +14,7 @@ export interface MockAgentWorkspace { export interface MockAgentOptions { repoPrefix: string; title: string; + repo?: Parameters[0]["repo"]; port?: number; initialPrompt?: string; model?: string; @@ -29,7 +30,11 @@ export interface MockAgentOptions { export async function seedMockAgentWorkspace( options: MockAgentOptions, ): Promise { - const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: options.port }); + const workspace = await seedWorkspace({ + repoPrefix: options.repoPrefix, + repo: options.repo, + port: options.port, + }); try { const agent = await workspace.client.createAgent({ provider: "mock", diff --git a/packages/app/e2e/support/helpers/pair-device.ts b/packages/app/e2e/support/helpers/pair-device.ts index a97a5f51e9..d8908015b8 100644 --- a/packages/app/e2e/support/helpers/pair-device.ts +++ b/packages/app/e2e/support/helpers/pair-device.ts @@ -113,6 +113,11 @@ export async function enableRelayAndExpectOffer(page: Page): Promise { export async function expectPairingOffer(page: Page): Promise { await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/); + await expect( + page.getByRole("alert").filter({ + hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.", + }), + ).toBeVisible(); await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0); } diff --git a/packages/app/e2e/support/helpers/replica-cache-measurement.ts b/packages/app/e2e/support/helpers/replica-cache-measurement.ts new file mode 100644 index 0000000000..9876bf1d1e --- /dev/null +++ b/packages/app/e2e/support/helpers/replica-cache-measurement.ts @@ -0,0 +1,312 @@ +import type { CDPSession, Page } from "@playwright/test"; + +const REPLICA_CACHE_DATABASE = "paseo-replica-row-store"; +const REPLICA_CACHE_ROWS_STORE = "rows"; +const PERSIST_FRAME_NAMES = [ + "captureSessions", + "captureHost", + "queueReplicaChanges", + "apply", + "safeParse", + "stringify", + "setItem", + "measuredPut", +] as const; +const PERSIST_STACK_ANCHORS = new Set([ + "captureSessions", + "captureHost", + "queueReplicaChanges", + "persist", + "flushPending", + "measuredPut", +]); + +export interface ReplicaCacheMeasurementReport { + targetUrl: string; + observationMs: number; + persistCpuMs: number; + persistCpuFrames: Record; + flushes: number; + bytesWritten: { + total: number; + perFlush: number[]; + }; + redundantWrites: number; + storageDurationMs: number; + longTasks: { + count: number; + totalDurationMs: number; + maximumDurationMs: number; + durationsMs: number[]; + }; + restoreMs: number | null; +} + +interface BrowserMeasurementState { + flushes: Array<{ bytes: number; durationMs: number }>; + longTasks: number[]; + redundantWrites: number; +} + +interface ProfileNode { + id: number; + callFrame: { functionName: string; url: string }; + parent?: number; +} + +interface SamplingProfile { + nodes: ProfileNode[]; + samples?: number[]; + timeDeltas?: number[]; +} + +declare global { + interface Window { + __replicaCacheMeasurement?: BrowserMeasurementState; + __replicaCacheRestoreReadStartedAt?: number; + } +} + +export async function installReplicaCacheMeasurement(page: Page): Promise { + await page.addInitScript( + ({ databaseName, storeName }) => { + const encoder = new TextEncoder(); + const transactionWrites = new WeakMap< + IDBTransaction, + Array< + | { type: "put"; key: string; value: string; bytes: number; startedAt: number } + | { type: "delete"; key: string; bytes: 0; startedAt: number } + > + >(); + const lastValues = new Map(); + + const resolveStorageKey = ( + store: IDBObjectStore, + value: unknown, + explicitKey: IDBValidKey | undefined, + ): unknown => { + if (explicitKey !== undefined) return explicitKey; + if (value === null || typeof value !== "object") return null; + if (Array.isArray(store.keyPath)) { + return store.keyPath.map((part) => Reflect.get(value, part)); + } + return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null; + }; + + window.__replicaCacheMeasurement = { + flushes: [], + longTasks: [], + redundantWrites: 0, + }; + + const observeTransaction = (transaction: IDBTransaction) => { + let writes = transactionWrites.get(transaction); + if (writes) return writes; + writes = []; + transactionWrites.set(transaction, writes); + transaction.addEventListener("complete", () => { + const state = window.__replicaCacheMeasurement; + if (!state || writes!.length === 0) return; + let bytes = 0; + let startedAt = Number.POSITIVE_INFINITY; + for (const write of writes!) { + bytes += write.bytes; + startedAt = Math.min(startedAt, write.startedAt); + if (write.type === "put") { + if (lastValues.get(write.key) === write.value) state.redundantWrites += 1; + lastValues.set(write.key, write.value); + } else { + lastValues.delete(write.key); + } + } + state.flushes.push({ bytes, durationMs: performance.now() - startedAt }); + }); + return writes; + }; + + const originalPut = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) { + const request = + key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); + if (this.transaction.db.name !== databaseName || this.name !== storeName) return request; + const rowPayload = + value && typeof value === "object" ? Reflect.get(value, "payload") : undefined; + let serialized = JSON.stringify(value); + if (typeof value === "string") serialized = value; + if (typeof rowPayload === "string") serialized = rowPayload; + const storageKey = JSON.stringify(resolveStorageKey(this, value, key)); + observeTransaction(this.transaction).push({ + type: "put", + key: `${this.name}:${storageKey}`, + value: serialized, + bytes: encoder.encode(serialized).byteLength, + startedAt: performance.now(), + }); + return request; + }; + const originalDelete = IDBObjectStore.prototype.delete; + IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) { + const request = originalDelete.call(this, query); + if (this.transaction.db.name !== databaseName || this.name !== storeName) return request; + observeTransaction(this.transaction).push({ + type: "delete", + key: `${this.name}:${JSON.stringify(query)}`, + bytes: 0, + startedAt: performance.now(), + }); + return request; + }; + + const markRestoreRead = (store: IDBObjectStore) => { + if ( + store.transaction.db.name === databaseName && + store.name === storeName && + store.transaction.mode === "readonly" && + window.__replicaCacheRestoreReadStartedAt === undefined + ) { + window.__replicaCacheRestoreReadStartedAt = performance.now(); + } + }; + const originalGet = IDBObjectStore.prototype.get; + IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) { + markRestoreRead(this); + return originalGet.call(this, query); + }; + const originalGetAll = IDBObjectStore.prototype.getAll; + IDBObjectStore.prototype.getAll = function measuredGetAll( + query?: IDBValidKey | IDBKeyRange | null, + count?: number, + ) { + markRestoreRead(this); + return originalGetAll.call(this, query, count); + }; + const originalOpenCursor = IDBObjectStore.prototype.openCursor; + IDBObjectStore.prototype.openCursor = function measuredOpenCursor( + query?: IDBValidKey | IDBKeyRange | null, + direction?: IDBCursorDirection, + ) { + markRestoreRead(this); + return originalOpenCursor.call(this, query, direction); + }; + + if (typeof PerformanceObserver !== "undefined") { + const observer = new PerformanceObserver((entries) => { + const state = window.__replicaCacheMeasurement; + if (!state) return; + for (const entry of entries.getEntries()) state.longTasks.push(entry.duration); + }); + try { + observer.observe({ type: "longtask", buffered: true }); + } catch { + // Chromium exposes Long Tasks; leave an empty list on runtimes that do not. + } + } + }, + { databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE }, + ); +} + +export async function beginReplicaCacheObservation( + page: Page, +): Promise<{ cdp: CDPSession; startedAt: number }> { + await page.evaluate(() => { + const state = window.__replicaCacheMeasurement; + if (!state) throw new Error("Replica cache measurement is not installed"); + state.flushes = []; + state.longTasks = []; + state.redundantWrites = 0; + }); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); + await cdp.send("Profiler.start"); + return { cdp, startedAt: Date.now() }; +} + +export async function finishReplicaCacheObservation( + page: Page, + observation: { cdp: CDPSession; startedAt: number }, + targetUrl: string, +): Promise> { + const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile }; + await observation.cdp.detach(); + const browser = await page.evaluate(() => { + const state = window.__replicaCacheMeasurement; + if (!state) throw new Error("Replica cache measurement is not installed"); + return state; + }); + const cpu = summarizePersistCpu(result.profile); + const bytesPerFlush = browser.flushes.map((flush) => flush.bytes); + const longTaskDurations = browser.longTasks; + return { + targetUrl, + observationMs: Date.now() - observation.startedAt, + persistCpuMs: cpu.totalMs, + persistCpuFrames: cpu.byFrame, + flushes: browser.flushes.length, + bytesWritten: { + total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0), + perFlush: bytesPerFlush, + }, + redundantWrites: browser.redundantWrites, + storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0), + longTasks: { + count: longTaskDurations.length, + totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0), + maximumDurationMs: Math.max(0, ...longTaskDurations), + durationsMs: longTaskDurations, + }, + }; +} + +export async function measureReplicaCacheRestore( + page: Page, + targetUrl: string, + hydratedSelector: string, +): Promise { + await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => { + await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" }); + }); + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + const restoredAt = await page.waitForFunction( + (selector) => { + const startedAt = window.__replicaCacheRestoreReadStartedAt; + if (startedAt === undefined) return null; + const element = document.querySelector(selector); + if (!element || !(element instanceof HTMLElement) || element.offsetParent === null) + return null; + return performance.now(); + }, + hydratedSelector, + { timeout: 30_000 }, + ); + const restoredAtMs = await restoredAt.jsonValue(); + const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null); + return typeof restoredAtMs === "number" && startedAtMs !== null + ? restoredAtMs - startedAtMs + : null; +} + +function summarizePersistCpu(profile: SamplingProfile): { + totalMs: number; + byFrame: Record; +} { + const nodes = new Map(profile.nodes.map((node) => [node.id, node])); + const byFrame: Record = {}; + let totalMicroseconds = 0; + for (const [index, sampleId] of (profile.samples ?? []).entries()) { + const delta = profile.timeDeltas?.[index] ?? 0; + const stackFunctions = new Set(); + let node = nodes.get(sampleId); + while (node) { + stackFunctions.add(node.callFrame.functionName); + node = node.parent === undefined ? undefined : nodes.get(node.parent); + } + if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue; + const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name)); + if (matched.length === 0) continue; + totalMicroseconds += delta; + for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000; + } + return { totalMs: totalMicroseconds / 1_000, byFrame }; +} diff --git a/packages/app/e2e/support/helpers/replica-cache-storage.ts b/packages/app/e2e/support/helpers/replica-cache-storage.ts index 78df9b5389..dc06de8dd0 100644 --- a/packages/app/e2e/support/helpers/replica-cache-storage.ts +++ b/packages/app/e2e/support/helpers/replica-cache-storage.ts @@ -1,26 +1,49 @@ import type { Page } from "@playwright/test"; -const DATABASE_NAME = "paseo-replica-cache"; -const STORE_NAME = "key-value"; -const STORAGE_KEY = "@paseo:replica-cache"; +const DATABASE_NAME = "paseo-replica-row-store"; +const STORE_NAME = "rows"; +const SINGLETON_ID = "singleton"; + +type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint"; + +interface ReplicaRowRecord { + serverId: string; + kind: ReplicaRowKind; + id: string; + payload: string; +} + +interface ReplicaCacheHostRecord { + serverId: string; + agents: Array>; + workspaces: Array>; + projects: Array>; + timeline?: { + agentId?: string; + items?: Array>; + range?: { + endSeq?: number; + epoch?: string; + startSeq?: number; + } | null; + } | null; + directorySync?: unknown; +} export interface ReplicaCacheRecord { version?: number; - hosts?: Array<{ - timeline?: { - agentId?: string; - items?: Array>; - range?: { - endSeq?: number; - epoch?: string; - startSeq?: number; - } | null; - } | null; - }>; + hosts?: ReplicaCacheHostRecord[]; } interface ReplicaCacheWriteObserverState { - lastValue: string | null; + lastValues: Record; + redundantWrites: number; + serializedChars: number; + storageDurationMs: number; + writes: number; +} + +interface ReplicaCacheWriteObserverReport { redundantWrites: number; serializedChars: number; storageDurationMs: number; @@ -33,11 +56,10 @@ declare global { } } -async function readStoredValue(input: { +async function readRows(input: { databaseName: string; storeName: string; - storageKey: string; -}): Promise { +}): Promise { const database = await new Promise((resolve, reject) => { const request = indexedDB.open(input.databaseName, 1); request.addEventListener("success", () => resolve(request.result)); @@ -47,26 +69,103 @@ async function readStoredValue(input: { const request = database .transaction(input.storeName, "readonly") .objectStore(input.storeName) - .get(input.storageKey); - request.addEventListener("success", () => - resolve(typeof request.result === "string" ? request.result : null), - ); + .getAll(); + request.addEventListener("success", () => resolve(request.result)); request.addEventListener("error", () => reject(request.error)); }); } +function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord { + const hosts = new Map(); + for (const row of rows) { + const host = hosts.get(row.serverId) ?? { + serverId: row.serverId, + agents: [], + workspaces: [], + projects: [], + timeline: null, + }; + const payload = JSON.parse(row.payload) as Record; + switch (row.kind) { + case "agent": + host.agents.push(payload); + break; + case "workspace": + host.workspaces.push(payload); + break; + case "project": + host.projects.push(payload); + break; + case "timeline": + host.timeline = payload; + break; + case "checkpoint": + host.directorySync = payload; + break; + } + hosts.set(row.serverId, host); + } + return { version: 6, hosts: [...hosts.values()] }; +} + export async function readReplicaCache(page: Page): Promise { - const raw = await page.evaluate(readStoredValue, { + const rows = await page.evaluate(readRows, { databaseName: DATABASE_NAME, storeName: STORE_NAME, - storageKey: STORAGE_KEY, }); - return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null; + return rows.length > 0 ? assembleCache(rows) : null; } export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise { + const rows: ReplicaRowRecord[] = []; + for (const host of value.hosts ?? []) { + for (const agent of host.agents) { + const snapshot = agent.snapshot; + if (!snapshot || typeof snapshot !== "object") continue; + const id = Reflect.get(snapshot, "id"); + if (typeof id === "string") { + rows.push({ serverId: host.serverId, kind: "agent", id, payload: JSON.stringify(agent) }); + } + } + for (const workspace of host.workspaces) { + if (typeof workspace.id === "string") { + rows.push({ + serverId: host.serverId, + kind: "workspace", + id: workspace.id, + payload: JSON.stringify(workspace), + }); + } + } + for (const project of host.projects) { + if (typeof project.projectId === "string") { + rows.push({ + serverId: host.serverId, + kind: "project", + id: project.projectId, + payload: JSON.stringify(project), + }); + } + } + if (host.timeline) { + rows.push({ + serverId: host.serverId, + kind: "timeline", + id: SINGLETON_ID, + payload: JSON.stringify(host.timeline), + }); + } + if (host.directorySync !== undefined) { + rows.push({ + serverId: host.serverId, + kind: "checkpoint", + id: SINGLETON_ID, + payload: JSON.stringify(host.directorySync), + }); + } + } await page.evaluate( - async ({ databaseName, storeName, storageKey, raw }) => { + async ({ databaseName, storeName, nextRows }) => { const database = await new Promise((resolve, reject) => { const request = indexedDB.open(databaseName, 1); request.addEventListener("success", () => resolve(request.result)); @@ -74,26 +173,23 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): }); await new Promise((resolve, reject) => { const transaction = database.transaction(storeName, "readwrite"); - transaction.objectStore(storeName).put(raw, storageKey); + const store = transaction.objectStore(storeName); + store.clear(); + for (const row of nextRows) store.put(row); transaction.addEventListener("complete", () => resolve()); transaction.addEventListener("error", () => reject(transaction.error)); transaction.addEventListener("abort", () => reject(transaction.error)); }); }, - { - databaseName: DATABASE_NAME, - storeName: STORE_NAME, - storageKey: STORAGE_KEY, - raw: JSON.stringify(value), - }, + { databaseName: DATABASE_NAME, storeName: STORE_NAME, nextRows: rows }, ); } export async function observeReplicaCacheStorageWrites(page: Page): Promise { await page.addInitScript( - ({ databaseName, storeName, storageKey }) => { + ({ databaseName, storeName }) => { window.__replicaCacheWriteObserver = { - lastValue: null, + lastValues: {}, redundantWrites: 0, serializedChars: 0, storageDurationMs: 0, @@ -106,51 +202,71 @@ export async function observeReplicaCacheStorageWrites(page: Page): Promise { const state = window.__replicaCacheWriteObserver; if (!state) return; - if (state.lastValue === value) state.redundantWrites += 1; - state.lastValue = value; + if (state.lastValues[rowKey] === payload) state.redundantWrites += 1; + state.lastValues[rowKey] = payload; state.writes += 1; - state.serializedChars += value.length; + state.serializedChars += payload.length; state.storageDurationMs += performance.now() - startedAt; }); return request; }; }, - { databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY }, + { databaseName: DATABASE_NAME, storeName: STORE_NAME }, ); } export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise { - const lastValue = await page.evaluate(readStoredValue, { + const rows = await page.evaluate(readRows, { databaseName: DATABASE_NAME, storeName: STORE_NAME, - storageKey: STORAGE_KEY, }); - await page.evaluate((value) => { + const lastValues = Object.fromEntries( + rows.map((row) => [`${row.serverId}:${row.kind}:${row.id}`, row.payload]), + ); + await page.evaluate((values) => { window.__replicaCacheWriteObserver = { - lastValue: value, + lastValues: values, redundantWrites: 0, serializedChars: 0, storageDurationMs: 0, writes: 0, }; - }, lastValue); + }, lastValues); } export async function readReplicaCacheStorageWriteObserver( page: Page, -): Promise { +): Promise { return page.evaluate(() => { const state = window.__replicaCacheWriteObserver; if (!state) throw new Error("Replica cache write observer is not installed"); - return state; + return { + redundantWrites: state.redundantWrites, + serializedChars: state.serializedChars, + storageDurationMs: state.storageDurationMs, + writes: state.writes, + }; }); } diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index d2f02d8f41..69129ddcb4 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -12,6 +12,7 @@ export interface SeedWorkspaceDescriptor { projectDisplayName: string; projectRootPath: string; workspaceDirectory: string; + diffStat: { additions: number; deletions: number } | null; labels?: string[]; } diff --git a/packages/app/e2e/support/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts index 1ddee12829..cdebe3af3e 100644 --- a/packages/app/e2e/support/helpers/workspace-tabs.ts +++ b/packages/app/e2e/support/helpers/workspace-tabs.ts @@ -88,13 +88,16 @@ export async function ensureSidePanel(page: Page): Promise { */ async function openSidePanelView( page: Page, - view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number }, + view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number }, ): Promise { const panel = await ensureSidePanel(page); const tab = panel.getByTestId(view.tabTestId); if ((await tab.count()) === 0) { - await panel.getByTestId("workspace-new-tab-menu-trigger").click(); - await visibleTestId(page, view.menuTestId).first().click(); + await panel.getByTestId("workspace-new-tab-button").click(); + await page + .getByTestId(`workspace-new-tab-menu-${view.launchItemId}`) + .filter({ visible: true }) + .click(); } await selectWorkspaceTab(tab); await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({ @@ -105,7 +108,7 @@ async function openSidePanelView( export async function openChangesPanel(page: Page): Promise { await openSidePanelView(page, { tabTestId: "workspace-tab-working_diff", - menuTestId: "workspace-new-tab-menu-changes", + launchItemId: "changes", contentTestId: "working-diff-panel", }); } @@ -113,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise { export async function openFilesPanel(page: Page): Promise { await openSidePanelView(page, { tabTestId: "workspace-tab-files", - menuTestId: "workspace-new-tab-menu-files", + launchItemId: "files", contentTestId: "file-explorer-tree-scroll", }); } @@ -127,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise { } await openSidePanelView(page, { tabTestId: "workspace-tab-pull_request", - menuTestId: "workspace-new-tab-menu-pull-request", + launchItemId: "pull-request", contentTestId: "pr-pane", timeout: 15_000, }); @@ -137,14 +140,14 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise { await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({ timeout: 30_000, }); - await expect(visibleTestId(page, "workspace-new-tab-menu-trigger").first()).toBeVisible({ + await expect(visibleTestId(page, "workspace-new-tab-button").first()).toBeVisible({ timeout: 30_000, }); } -/** Open the `+` menu in the tab row and pick "New agent". */ +/** Open the pane-local `+` menu and pick Agent. */ export async function createAgentTabFromMenu(page: Page): Promise { - const trigger = visibleTestId(page, "workspace-new-tab-menu-trigger").first(); + const trigger = visibleTestId(page, "workspace-new-tab-button").first(); await expect(trigger).toBeVisible({ timeout: 10_000 }); await trigger.click(); const item = visibleTestId(page, "workspace-new-tab-menu-agent").first(); diff --git a/packages/app/package.json b/packages/app/package.json index 29a1a5d6f6..fedc79894b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.5.0-beta.4", + "version": "0.5.1", "private": true, "main": "index.ts", "scripts": { @@ -96,6 +96,7 @@ "expo-router": "~6.0.13", "expo-sharing": "^14.0.8", "expo-splash-screen": "~31.0.10", + "expo-sqlite": "~16.0.10", "expo-system-ui": "~6.0.7", "fast-deep-equal": "^3.1.3", "htmlparser2": "^12.0.0", @@ -137,6 +138,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/markdown-it": "^14.1.2", + "@types/node": "~22.19.0", "@types/qrcode": "^1.5.6", "@types/react": "~19.2.0", "@types/turndown": "^5.0.6", @@ -150,6 +152,7 @@ "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "expo-gradle-jvmargs": "^1.1.2", + "fake-indexeddb": "^6.2.5", "jsdom": "^20.0.3", "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", diff --git a/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx b/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx index e9795614e9..35ff3c0998 100644 --- a/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx +++ b/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx @@ -1,7 +1,6 @@ import { withUnistyles } from "react-native-unistyles"; import { BookOpen, - Bot, Boxes, Brain, Bug, @@ -27,6 +26,7 @@ import { Server, Shield, Sparkles, + Star, Terminal, TestTube, Wrench, @@ -42,7 +42,7 @@ import { } from "./profile-appearance"; /** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */ -const ThemedDefaultIcon = withUnistyles(Bot); +const ThemedDefaultIcon = withUnistyles(Star); /** * `withUnistyles` has to wrap each icon once at module scope, so the registry diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts new file mode 100644 index 0000000000..f18d7ea27f --- /dev/null +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { TurnTiming } from "@/timeline/turn-time"; +import type { StreamItem } from "@/types/stream"; +import { layoutStream } from "./layout"; +import type { StreamStrategy } from "./strategy"; + +function strategyFor(platform: "web" | "native"): StreamStrategy { + const inverted = platform === "native"; + const neighborStep = inverted ? 1 : -1; + const getHistoryBoundaryIndex = (items: StreamItem[]) => { + if (items.length === 0) return null; + return inverted ? 0 : items.length - 1; + }; + const getLiveHeadBoundaryIndex = (items: StreamItem[]) => { + if (items.length === 0) return null; + return inverted ? items.length - 1 : 0; + }; + return { + orderTail: (items) => (inverted ? [...items].toReversed() : items), + orderHead: (items) => (inverted ? [...items].toReversed() : items), + getNeighborIndex: (index, relation) => + relation === "above" ? index + neighborStep : index - neighborStep, + getNeighborItem: (items, index, relation) => { + const neighborIndex = relation === "above" ? index + neighborStep : index - neighborStep; + return items[neighborIndex]; + }, + getHistoryLiveBoundaryIndex: getHistoryBoundaryIndex, + getLiveHeadHistoryBoundaryIndex: getLiveHeadBoundaryIndex, + getLatestItemIndex: getHistoryBoundaryIndex, + getFrameChildOrder: () => (inverted ? "footer-then-content" : "content-then-footer"), + } as StreamStrategy; +} + +function userMessage(id: string): Extract { + return { + kind: "user_message", + id, + text: id, + timestamp: new Date("2026-08-22T10:00:00.000Z"), + }; +} + +function assistantMessage(id: string): Extract { + return { + kind: "assistant_message", + id, + text: id, + timestamp: new Date("2026-08-22T10:00:01.000Z"), + }; +} + +function assistantBlock( + id: string, + blockIndex: number, +): Extract { + return { + ...assistantMessage(id), + blockGroupId: "response", + blockIndex, + }; +} + +function timingFor(assistantId: string): Map { + return new Map([ + [ + assistantId, + { + startedAt: new Date("2026-08-22T10:00:00.000Z"), + completedAt: new Date("2026-08-22T10:00:02.000Z"), + durationMs: 2_000, + }, + ], + ]); +} + +function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) { + const strategy = strategyFor(input.platform); + const assistant = assistantMessage("assistant"); + return { + assistant, + layout: layoutStream({ + strategy, + isTurnActive: input.isTurnActive, + history: strategy.orderTail([userMessage("user"), assistant]), + liveHead: [], + timingByAssistantId: timingFor(assistant.id), + }), + }; +} + +describe("turn footer spacing", () => { + it.each(["web", "native"] as const)( + "compacts the last assistant against the completed footer on %s", + (platform) => { + const { assistant, layout } = layoutFor({ platform, isTurnActive: false }); + const assistantLayout = layout.history.find((item) => item.item.id === assistant.id); + + expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id); + expect(assistantLayout?.assistantSpacing).toBe("compactBottom"); + }, + ); + + it.each(["web", "native"] as const)( + "keeps the same compact edge while the footer is running on %s", + (platform) => { + const { assistant, layout } = layoutFor({ platform, isTurnActive: true }); + const assistantLayout = layout.history.find((item) => item.item.id === assistant.id); + + expect(layout.auxiliaryTurnFooter).toBeNull(); + expect(assistantLayout?.assistantSpacing).toBe("compactBottom"); + }, + ); + + it.each([ + ["web", false], + ["web", true], + ["native", false], + ["native", true], + ] as const)( + "compacts both edges of the final split paragraph on %s when active=%s", + (platform, isTurnActive) => { + const strategy = strategyFor(platform); + const first = assistantBlock("first", 0); + const last = assistantBlock("last", 1); + const layout = layoutStream({ + strategy, + isTurnActive, + history: strategy.orderTail([userMessage("user"), first]), + liveHead: strategy.orderHead([last]), + timingByAssistantId: timingFor(last.id), + }); + const firstLayout = layout.history.find((item) => item.item.id === first.id); + const lastLayout = layout.liveHead.find((item) => item.item.id === last.id); + + expect(firstLayout?.assistantSpacing).toBe("compactBottom"); + expect(lastLayout?.assistantSpacing).toBe("compactBoth"); + }, + ); +}); diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index 7d1a28c06a..9cc463590d 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -330,7 +330,7 @@ describe("layoutStream", () => { }); expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom"); - expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop"); + expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactBoth"); }); it.each(["web", "android"] as const)( diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index ca52dfbf85..bc140189c7 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -49,6 +49,7 @@ interface LayoutSegmentInput { items: StreamItem[]; timingByAssistantId: Map; auxiliaryTurnFooter: TurnFooterHost | null; + hasAuxiliaryFooter: boolean; frameOrder: StreamFrameChildOrder; boundaryIndex: number | null; boundaryAboveItem: StreamItem | null; @@ -249,11 +250,6 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { boundaryIndex: input.boundaryIndex, boundaryItem: input.boundaryBelowItem, }); - const assistantSpacing = getAssistantBlockSpacing({ - item, - aboveItem, - belowItem, - }); const completedFooter = resolveCompletedFooter({ strategy: input.strategy, items: input.items, @@ -265,6 +261,12 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, }); + const assistantSpacing = getAssistantBlockSpacing({ + item, + aboveItem, + belowItem, + hasFooterBelow: completedFooter !== null || (input.hasAuxiliaryFooter && belowItem === null), + }); return { item, @@ -294,6 +296,7 @@ const historyLayoutCache = new WeakMap { expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default"); }); + it("compacts the bottom edge when the turn footer follows the assistant block", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + expect( + getAssistantBlockSpacing({ + item: a, + aboveItem: null, + belowItem: null, + hasFooterBelow: true, + }), + ).toBe("compactBottom"); + }); + it("returns compactTop when the item above is in the same block group", () => { const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 }); const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 }); diff --git a/packages/app/src/agent-stream/spacing.ts b/packages/app/src/agent-stream/spacing.ts index 4d337debe3..87bdf75f88 100644 --- a/packages/app/src/agent-stream/spacing.ts +++ b/packages/app/src/agent-stream/spacing.ts @@ -17,12 +17,15 @@ export function getAssistantBlockSpacing(params: { item: StreamItem; aboveItem: StreamItem | null | undefined; belowItem: StreamItem | null | undefined; + hasFooterBelow?: boolean; }): "default" | "compactTop" | "compactBottom" | "compactBoth" { if (params.item.kind !== "assistant_message") { return "default"; } const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem }); - const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem }); + const compactBottom = + params.hasFooterBelow || + isSameAssistantBlockGroup({ item: params.item, other: params.belowItem }); if (compactTop && compactBottom) return "compactBoth"; if (compactTop) return "compactTop"; if (compactBottom) return "compactBottom"; @@ -44,6 +47,9 @@ export function getGapBetweenStreamItems( if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { return SPACING[1]; } + if (item.kind === "user_message" && belowItem.kind === "assistant_message") { + return 0; + } if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { return 0; } diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index aa5e67bd3a..4f19448a80 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -15,7 +15,10 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; -import { estimateStreamItemHeight } from "./web-virtualization"; +import { + estimateStreamItemHeight, + shouldAdjustScrollForVirtualRowResize, +} from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; import { @@ -370,14 +373,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool overscan: 8, }); useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { - if (historyStartPrependAnchorActiveRef.current) { - return false; - } + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + return shouldAdjustScrollForVirtualRowResize({ + isHistoryStartPrependActive: historyStartPrependAnchorActiveRef.current, + rowStart: item.start, + scrollOffset, + remainingDistanceFromBottom: remainingDistance, + bottomThreshold: AUTO_SCROLL_BOTTOM_THRESHOLD_PX, + }); }; return () => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; diff --git a/packages/app/src/agent-stream/turn-footer.test.tsx b/packages/app/src/agent-stream/turn-footer.test.tsx deleted file mode 100644 index ab33c03bff..0000000000 --- a/packages/app/src/agent-stream/turn-footer.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("react-native", () => ({ - Platform: { - OS: "web", - select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default, - }, - View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => ( -
{children}
- ), -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" - ? (factory as (theme: Record) => unknown)({ - spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, - colors: { - foregroundMuted: "#aaa", - palette: { amber: { 500: "#f0b429", 700: "#b7791f" } }, - }, - }) - : factory, - }, - useUnistyles: () => ({ rt: { breakpoint: "md" } }), - withUnistyles: (component: T) => component, -})); - -vi.mock("@/components/message", () => ({ - AssistantTurnFooter: () => null, - LiveElapsed: () => , - STREAM_METADATA_FONT_SIZE: 11, -})); - -vi.mock("@/components/assistant-fork-menu", () => ({ - AssistantForkMenu: () => + ); } diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts index 9323010559..7e64facbde 100644 --- a/packages/app/src/file-pane/live-file/hook.ts +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -1,6 +1,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { LiveFileModel, type LiveFileSession } from "./model"; +import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation"; +import { isWeb } from "@/constants/platform"; export function useLiveFile(input: { client: DaemonClient | null; @@ -18,7 +20,12 @@ export function useLiveFile(input: { return client.subscribeFile(target, onVersion); }, read(target) { - return client.readFile(target.cwd, target.path); + return client.readFile( + target.cwd, + target.path, + undefined, + SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain, + ); }, }; }, [input.client]); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 2d49eecac9..0cce3b82f4 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; -import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; -import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; -import { lineNumberGutterWidth } from "@/components/code-insets"; -import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { filePreviewRenderKind } from "@/components/file-pane-render-mode"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { getFileNameFromPath } from "@/attachments/utils"; @@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; +import { FileSourceView } from "./source/view"; import type { FileConflictAlertState } from "./conflict-alert"; import type { LiveFileModel } from "./live-file/model"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -interface CodeLineProps { - tokens: HighlightToken[]; - lineNumber: number; - gutterWidth: number; - highlighted: boolean; -} - interface FilePreviewBodyProps { preview: ExplorerFile | null; mode?: "preview" | "source"; @@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -interface FileLineSelection { - lineStart: number; - lineEnd: number; -} - function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; @@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string { return `${(size / (1024 * 1024)).toFixed(1)} MB`; } -function clampLineSelection(input: { - lineStart?: number; - lineEnd?: number; - lineCount: number; -}): FileLineSelection | null { - if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) { - return null; - } - const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount); - const rawLineEnd = - input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart; - const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount); - return { lineStart, lineEnd: Math.max(lineStart, lineEnd) }; -} - -const CodeLine = React.memo(function CodeLine({ - tokens, - lineNumber, - gutterWidth, - highlighted, -}: CodeLineProps) { - const gutterStyle = useMemo( - () => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })], - [gutterWidth], - ); - const lineStyle = useMemo( - () => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine], - [highlighted], - ); - const keyedTokens = useMemo( - () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), - [tokens], +function ReadonlySource({ + preview, + filename, + location, + navigationRevision, +}: { + preview: ExplorerFile; + filename: string; + location: WorkspaceFileLocation; + navigationRevision: number; +}) { + const theme = UnistylesRuntime.getTheme(); + const { t } = useTranslation(); + const visualTheme = useMemo( + () => ({ + colorScheme: theme.colorScheme, + background: theme.colors.surface0, + foreground: theme.colors.foreground, + cursor: theme.colors.terminal.cursor, + foregroundMuted: theme.colors.foregroundMuted, + border: theme.colors.border, + selection: theme.colors.terminal.selectionBackground, + monoFont: theme.fontFamily.mono, + codeFontSize: theme.fontSize.code, + syntax: theme.colors.syntax, + }), + [theme], ); return ( - - - - {String(lineNumber)} - - - - {keyedTokens.map(({ key, token }) => ( - - ))} - + + ); +} + +function TooLargeSource({ size }: { size?: number }) { + const { t } = useTranslation(); + return ( + + {t("panels.file.tooLargeToDisplay")} + {size ? {formatFileSize({ size })} : null} ); -}); - -interface CodeLineTokenProps { - token: HighlightToken; } -function CodeLineToken({ token }: CodeLineTokenProps) { - return {token.text}; -} - -const codeLineStyles = StyleSheet.create((theme) => ({ - line: { - flexDirection: "row", - }, - highlightedLine: { - backgroundColor: theme.colors.accentBorder, - }, - gutter: { - alignItems: "flex-end", - paddingRight: theme.spacing[3], - flexShrink: 0, - }, - gutterText: { - color: theme.colors.foreground, - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - lineHeight: theme.fontSize.code * 1.45, - opacity: 0.4, - userSelect: "none", - }, - lineText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - lineHeight: theme.fontSize.code * 1.45, - flex: 1, - }, -})); - function FilePreviewBody({ preview, mode, isLoading, - isMobile, + isMobile: _isMobile, location, navigationRevision, imagePreviewUri, }: FilePreviewBodyProps) { - const theme = UnistylesRuntime.getTheme(); const { t } = useTranslation(); const filePath = location.path; // A line target means the caller wants to land on that line, so fall back to @@ -197,48 +146,11 @@ function FilePreviewBody({ const previewScrollRef = useRef(null); - const highlightedLines = useMemo(() => { - if (!preview || preview.kind !== "text" || renderKind) { - return null; - } - - return highlightCode(preview.content ?? "", filePath); - }, [renderKind, preview, filePath]); - - const gutterWidth = useMemo(() => { - if (!highlightedLines) return 0; - return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code); - }, [highlightedLines, theme.fontSize.code]); - const lineHeight = theme.fontSize.code * 1.45; - const lineSelection = useMemo(() => { - if (!highlightedLines) { - return null; - } - return clampLineSelection({ - lineStart: location.lineStart, - lineEnd: location.lineEnd, - lineCount: highlightedLines.length, - }); - }, [highlightedLines, location.lineEnd, location.lineStart]); - const imageSource = useMemo( () => (imagePreviewUri ? { uri: imagePreviewUri } : null), [imagePreviewUri], ); - useEffect(() => { - if (!lineSelection) { - return; - } - const timeout = setTimeout(() => { - previewScrollRef.current?.scrollTo({ - y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight), - animated: false, - }); - }, 0); - return () => clearTimeout(timeout); - }, [lineHeight, lineSelection, navigationRevision]); - if (isLoading && !preview) { return ( @@ -280,51 +192,13 @@ function FilePreviewBody({ ); } - const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]]; - const keyedLines = lines.map((tokens, index) => ({ - key: `line-${index}`, - tokens, - lineNumber: index + 1, - })); - const codeLines = ( - - {keyedLines.map(({ key, tokens, lineNumber }) => ( - = (lineSelection?.lineStart ?? 0) && - lineNumber <= (lineSelection?.lineEnd ?? 0) - } - /> - ))} - - ); - return ( - - - {isMobile ? ( - {codeLines} - ) : ( - - {codeLines} - - )} - - + ); } @@ -557,6 +431,13 @@ function FilePanePresentation({ } if (errorMessage) { + if (errorMessage === "File is too large to display") { + return ( + + + + ); + } return ( diff --git a/packages/app/src/file-pane/source/presentation.test.ts b/packages/app/src/file-pane/source/presentation.test.ts new file mode 100644 index 0000000000..fd5578fd90 --- /dev/null +++ b/packages/app/src/file-pane/source/presentation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation"; + +describe("selectSourcePresentation", () => { + for (const platform of ["web", "native"] as const) { + const budgets = SOURCE_PRESENTATION_BUDGETS[platform]; + it(`keeps ${platform} threshold edges predictable`, () => { + expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted"); + expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain"); + expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain"); + expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported"); + }); + } +}); diff --git a/packages/app/src/file-pane/source/presentation.ts b/packages/app/src/file-pane/source/presentation.ts new file mode 100644 index 0000000000..992331a548 --- /dev/null +++ b/packages/app/src/file-pane/source/presentation.ts @@ -0,0 +1,17 @@ +export const SOURCE_PRESENTATION_BUDGETS = { + web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 }, + native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 }, +} as const; + +export type SourcePresentation = "highlighted" | "plain" | "unsupported"; +export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS; + +export function selectSourcePresentation(input: { + size: number; + platform: SourcePlatform; +}): SourcePresentation { + const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform]; + if (input.size <= budgets.highlighted) return "highlighted"; + if (input.size <= budgets.plain) return "plain"; + return "unsupported"; +} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx new file mode 100644 index 0000000000..de7c5eeae1 --- /dev/null +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef } from "react"; +import { FlatList, Text, View, type ListRenderItem } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import type { EditorVisualTheme } from "../editor/extensions.web"; +import { selectSourcePresentation } from "./presentation"; + +interface FileSourceViewProps { + content: string; + filename: string; + location: WorkspaceFileLocation; + navigationRevision: number; + size: number; + theme: EditorVisualTheme; + tooLargeMessage: string; +} + +interface SourceLine { + number: number; + tokens: HighlightToken[]; +} + +export function FileSourceView({ + content, + filename, + location, + navigationRevision, + size, + tooLargeMessage, +}: FileSourceViewProps) { + const presentation = selectSourcePresentation({ size, platform: "native" }); + if (presentation === "unsupported") { + return ( + + {tooLargeMessage} + + ); + } + return ( + + ); +} + +function VirtualizedSource({ + content, + filename, + location, + navigationRevision, + presentation, +}: Omit & { + presentation: "highlighted" | "plain"; +}) { + const listRef = useRef>(null); + const lines = useMemo(() => { + if (presentation === "highlighted") + return highlightCode(content, filename).map((tokens, index) => ({ + number: index + 1, + tokens, + })); + return content + .split("\n") + .map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })); + }, [content, filename, presentation]); + useEffect(() => { + if (!location.lineStart) return; + listRef.current?.scrollToIndex({ + index: Math.min(location.lineStart - 1, lines.length - 1), + animated: false, + viewPosition: 0.5, + }); + }, [lines.length, location.lineStart, navigationRevision]); + return ( + + ); +} + +function SourceLineView({ line }: { line: SourceLine }) { + return ( + + {line.number} + + {line.tokens.map((token) => ( + + {token.text} + + ))} + + + ); +} + +function sourceLineKey(line: SourceLine): string { + return String(line.number); +} + +function sourceLineLayout(_data: ArrayLike | null | undefined, index: number) { + return { length: 20, offset: index * 20, index }; +} + +const renderSourceLine: ListRenderItem = ({ item }) => ; + +const styles = StyleSheet.create((theme) => ({ + unsupported: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, + line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 }, + gutter: { + width: 56, + paddingRight: theme.spacing[3], + textAlign: "right", + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + }, + text: { + flex: 1, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + lineHeight: theme.fontSize.code * 1.45, + }, +})); diff --git a/packages/app/src/file-pane/source/view.tsx b/packages/app/src/file-pane/source/view.tsx new file mode 100644 index 0000000000..5d1b488e7f --- /dev/null +++ b/packages/app/src/file-pane/source/view.tsx @@ -0,0 +1 @@ +export { FileSourceView } from "./view.native"; diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx new file mode 100644 index 0000000000..3a38b5d073 --- /dev/null +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef } from "react"; +import { Compartment, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { getLanguageForFile } from "@getpaseo/highlight"; +import type { WorkspaceFileLocation } from "@/workspace/file-open"; +import type { EditorVisualTheme } from "../editor/extensions.web"; +import { editorTheme } from "../editor/extensions.web"; +import { selectSourcePresentation, type SourcePresentation } from "./presentation"; + +interface FileSourceViewProps { + content: string; + filename: string; + location: WorkspaceFileLocation; + navigationRevision: number; + size: number; + theme: EditorVisualTheme; + tooLargeMessage: string; +} + +const languageCompartment = new Compartment(); +const themeCompartment = new Compartment(); + +export function FileSourceView({ + content, + filename, + location, + navigationRevision, + size, + theme, + tooLargeMessage, +}: FileSourceViewProps) { + const presentation = selectSourcePresentation({ size, platform: "web" }); + if (presentation === "unsupported") { + return ( +
+ {tooLargeMessage} +
+ ); + } + return ( + + ); +} + +function ReadonlyCodeMirror({ + content, + filename, + location, + navigationRevision, + presentation, + theme, +}: Omit & { + presentation: Exclude; +}) { + const hostRef = useRef(null); + const viewRef = useRef(null); + const initial = useRef({ content, filename, presentation, theme }); + + useEffect(() => { + if (!hostRef.current) return; + const values = initial.current; + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: values.content, + extensions: [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + languageCompartment.of( + languageFor({ filename: values.filename, presentation: values.presentation }), + ), + themeCompartment.of(editorTheme(values.theme)), + ], + }), + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view || view.state.doc.toString() === content) return; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } }); + }, [content]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: [ + languageCompartment.reconfigure(languageFor({ filename, presentation })), + themeCompartment.reconfigure(editorTheme(theme)), + ], + }); + }, [filename, presentation, theme]); + + useEffect(() => { + const view = viewRef.current; + if (!view || !location.lineStart) return; + const line = Math.min(location.lineStart, view.state.doc.lines); + const from = view.state.doc.line(line).from; + view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) }); + }, [location.lineStart, navigationRevision]); + + return
; +} + +function languageFor(input: { + filename: string; + presentation: Exclude; +}) { + return input.presentation === "highlighted" + ? (getLanguageForFile(input.filename)?.extension ?? []) + : []; +} + +const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; +const UNSUPPORTED_STYLE = { + alignItems: "center", + display: "flex", + flex: 1, + justifyContent: "center", +} as const; diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 04f806e207..59df725ca7 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -2,7 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useCallback, useMemo } from "react"; import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronDown, MoreVertical } from "lucide-react-native"; +import { ChevronDown, GitBranch, MoreVertical } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { DropdownMenu, @@ -22,6 +22,7 @@ import { buttonControlHeight } from "@/components/ui/control-geometry"; interface GitActionsSplitButtonProps { gitActions: GitActions; hideLabels?: boolean; + menuOnly?: boolean; } interface GitActionMenuItemProps { @@ -74,7 +75,11 @@ function GitActionMenuItem({ ); } -export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) { +export function GitActionsSplitButton({ + gitActions, + hideLabels, + menuOnly = false, +}: GitActionsSplitButtonProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const runGitAction = useGitActionRunner(); @@ -115,6 +120,62 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli [theme.colors.surface2], ); + const menuOnlyTriggerStyle = useCallback( + ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ + styles.menuOnlyTrigger, + (hovered || pressed || open) && + inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }), + ], + [theme.colors.surface2], + ); + + const menuOnlyActions = useMemo( + () => [ + ...(gitActions.primary ? [gitActions.primary] : []), + ...gitActions.secondary, + ...gitActions.menu, + ], + [gitActions.menu, gitActions.primary, gitActions.secondary], + ); + + if (menuOnly) { + if (menuOnlyActions.length === 0) { + return null; + } + + return ( + + + + + + + {menuOnlyActions.map((action, index) => ( + 0} + closeOnSelect={ + action.status === "idle" && + action.id === "pr" && + action.label === action.pendingLabel && + action.label === action.successLabel + } + /> + ))} + + + ); + } + return ( {gitActions.primary ? ( @@ -224,6 +285,17 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", position: "relative", }, + menuOnlyTrigger: { + width: 48, + height: buttonControlHeight.xs, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + }, splitButtonPrimaryDisabled: { opacity: 0.6, }, diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index d7424ded73..807f914fba 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -495,7 +495,14 @@ export function DiffSurface(props: DiffSurfaceProps) { schedulePaint(); return; } - if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) { + if ( + event.pointerType === "touch" && + drag && + !moved && + hit?.kind === "cell" && + hit.target && + reviewActions + ) { selectionRef.current = null; reviewActions.onStartComment(hit.target); schedulePaint(); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 0e557516ef..e615d98861 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -415,7 +415,7 @@ function ChangesToolbar(props: ChangesToolbarProps) { onToggle={onToggleDesktopTree} /> ) : null} - {isMobile ? : null} + {isMobile ? : null} diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts index db6f2e5476..a919258cbd 100644 --- a/packages/app/src/hooks/client-activity-tracker.test.ts +++ b/packages/app/src/hooks/client-activity-tracker.test.ts @@ -68,12 +68,26 @@ function buildTracker( initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null, initialAppVisible: overrides.initialAppVisible ?? true, now: clock.now, + onUserActivity: overrides.onUserActivity ?? (() => undefined), onAppResumed: overrides.onAppResumed, }); return { tracker, client, clock }; } describe("client activity tracker", () => { + it("notifies local consumers about user activity", () => { + let notifications = 0; + const { tracker } = buildTracker({ + onUserActivity: () => { + notifications += 1; + }, + }); + + tracker.recordUserActivity(); + + expect(notifications).toBe(1); + }); + it("includes the latest user-activity time in the next heartbeat", () => { const { tracker, client, clock } = buildTracker(); diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts index d00b92c111..c33e83246a 100644 --- a/packages/app/src/hooks/client-activity-tracker.ts +++ b/packages/app/src/hooks/client-activity-tracker.ts @@ -23,6 +23,7 @@ export interface ClientActivityTrackerInput { initialFocusedTerminalId: string | null; initialAppVisible: boolean; now: () => number; + onUserActivity: () => void; onAppResumed?: (awayMs: number) => void; } @@ -39,7 +40,7 @@ export interface ClientActivityTracker { export function createClientActivityTracker( input: ClientActivityTrackerInput, ): ClientActivityTracker { - const { client, deviceType, now, onAppResumed } = input; + const { client, deviceType, now, onUserActivity, onAppResumed } = input; let lastActivityAtMs = now(); let appVisible = input.initialAppVisible; let appVisibilityChangedAtMs = now(); @@ -62,6 +63,7 @@ export function createClientActivityTracker( function recordUserActivity(): void { lastActivityAtMs = now(); + onUserActivity(); } function maybeSendImmediateHeartbeat(): void { diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 65d8bf4067..2307ec5cb3 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -15,6 +15,7 @@ interface ClientActivityOptions { client: DaemonClient; focusedAgentId: string | null; focusedTerminalId: string | null; + onUserActivity: () => void; onAppResumed?: (awayMs: number) => void; } @@ -28,6 +29,7 @@ export function useClientActivity({ client, focusedAgentId, focusedTerminalId, + onUserActivity, onAppResumed, }: ClientActivityOptions): void { const onAppResumedRef = useRef(onAppResumed); @@ -42,6 +44,7 @@ export function useClientActivity({ initialFocusedTerminalId: focusedTerminalId, initialAppVisible: AppState.currentState === "active", now: () => Date.now(), + onUserActivity, onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), }); } diff --git a/packages/app/src/hooks/use-sidebar-pins.test.ts b/packages/app/src/hooks/use-sidebar-pins.test.ts index f418027fca..3f56fb667c 100644 --- a/packages/app/src/hooks/use-sidebar-pins.test.ts +++ b/packages/app/src/hooks/use-sidebar-pins.test.ts @@ -30,7 +30,7 @@ function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): S } describe("splitPinnedSidebarGroups", () => { - it("drops the empty shell when every chat of a project is pinned", () => { + it("keeps the project shell reachable when every chat is pinned", () => { const only = placement("w1"); const projects = [project("p1", [only])]; const result = splitPinnedSidebarGroups({ @@ -42,7 +42,7 @@ describe("splitPinnedSidebarGroups", () => { pinnedWorkspaceOrder: [], }); expect(result.pinnedChats).toHaveLength(1); - expect(result.unpinnedProjects).toHaveLength(0); + expect(result.unpinnedProjects).toEqual([{ ...projects[0], workspaces: [] }]); }); it("keeps a genuinely empty project so its new-workspace row stays reachable", () => { diff --git a/packages/app/src/hooks/use-sidebar-pins.ts b/packages/app/src/hooks/use-sidebar-pins.ts index fade0507d4..2b6e2f53a7 100644 --- a/packages/app/src/hooks/use-sidebar-pins.ts +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -121,12 +121,6 @@ export function splitPinnedSidebarGroups(input: { remainingWorkspaces.push(workspace); } } - // Every chat got hoisted into the Pinned section: drop the empty shell instead of - // leaving a duplicate project header below. A genuinely empty project (no chats to - // begin with) is kept so its "new workspace" row stays reachable. - if (remainingWorkspaces.length === 0 && project.workspaces.length > 0) { - continue; - } unpinnedProjects.push( remainingWorkspaces.length === project.workspaces.length ? project diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ad75e84be3..8a5becb6fe 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -64,6 +64,9 @@ export const ar: TranslationResources = { workspaces: "مساحات العمل", agents: "الوكلاء", newAgent: "وكيل جديد", + open: "فتح {{name}}", + openInSidePanel: "فتح {{name}} في اللوحة الجانبية", + openInFocusedPane: "فتح {{name}} في الجزء النشط", addProject: "إضافة مشروع", home: "بيت", groupByProject: "تجميع حسب المشروع", @@ -602,7 +605,7 @@ export const ar: TranslationResources = { files: "الملفات", pullRequest: "طلب السحب", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "افتح اللوحة الجانبية", @@ -1645,6 +1648,8 @@ export const ar: TranslationResources = { updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.", unavailable: "عرض الاقتران غير متاح.", hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.", + securityWarning: + "تعامل مع رابط الاقتران هذا ككلمة مرور. يمكن لأي شخص يملكه الوصول إلى هذا البرنامج الخفي.", qrUnavailable: "رمز QR غير متاح.", qrAccessibility: "رمز QR للإقران", retry: "أعد المحاولة", @@ -1718,6 +1723,7 @@ export const ar: TranslationResources = { loading: "جارٍ تحميل الملف...", noPreview: "لا تتوفر معاينة", binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", + tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه", failedToLoad: "فشل تحميل الملف", failedToLoadPreview: "فشل تحميل معاينة الملف", editor: { @@ -1746,6 +1752,7 @@ export const ar: TranslationResources = { label: "الملفات", subtitle: "ملفات مساحة العمل", tooltip: "تصفح ملفات مساحة العمل", + chooseFile: "اختر ملفًا", }, pullRequest: { label: "طلب السحب", @@ -2360,9 +2367,9 @@ export const ar: TranslationResources = { featuresLabel: "السمات", featureCount: "{{count}} سمة", featureCountOne: "{{count}} سمة", - notesLabel: "ملاحظات للوكلاء", + notesLabel: "متى يُستخدم", notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.", - notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.', + notesHint: "يساعد الوكلاء على اختيار ملف التعريف هذا عند بدء وكيل آخر.", save: "حفظ", saving: "جارٍ الحفظ...", remove: "إزالة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 30c7686146..b200dabf3f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -61,6 +61,9 @@ export const en = { workspaces: "Workspaces", agents: "Agents", newAgent: "New agent", + open: "Open {{name}}", + openInSidePanel: "Open {{name}} in Side panel", + openInFocusedPane: "Open {{name}} in focused pane", addProject: "Add project", home: "Home", groupByProject: "Group by project", @@ -600,7 +603,7 @@ export const en = { files: "Files", pullRequest: "Pull request", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "Open side panel", @@ -1655,6 +1658,8 @@ export const en = { updateRequired: "Update the host to enable relay from Paseo Desktop.", unavailable: "Pairing offer unavailable.", hint: "Scan this QR code with Paseo on your phone, or copy the link below.", + securityWarning: + "Treat this pairing link like a password. Anyone with it can access this daemon.", qrUnavailable: "QR code unavailable.", qrAccessibility: "Pairing QR code", retry: "Retry", @@ -1728,6 +1733,7 @@ export const en = { loading: "Loading file...", noPreview: "No preview available", binaryPreviewUnavailable: "Binary preview unavailable", + tooLargeToDisplay: "This file is too large to display", failedToLoad: "Failed to load file", failedToLoadPreview: "Failed to load file preview", editor: { @@ -1756,6 +1762,7 @@ export const en = { label: "Files", subtitle: "Workspace files", tooltip: "Browse workspace files", + chooseFile: "Choose a file", }, pullRequest: { label: "Pull request", @@ -2427,10 +2434,9 @@ export const en = { featuresLabel: "Features", featureCount: "{{count}} features", featureCountOne: "{{count}} feature", - notesLabel: "Notes for agents", + notesLabel: "When to use", notesPlaceholder: "Use for UI work — components, layout and design tokens.", - notesHint: - "Returned by the list_profiles MCP tool. Write it as an instruction to another agent.", + notesHint: "Helps agents choose this profile when starting another agent.", save: "Save", saving: "Saving...", remove: "Remove", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1d7df6561c..6a93e3eaa3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -64,6 +64,9 @@ export const es: TranslationResources = { workspaces: "Espacios de trabajo", agents: "Agentes", newAgent: "Nuevo agente", + open: "Abrir {{name}}", + openInSidePanel: "Abrir {{name}} en el panel lateral", + openInFocusedPane: "Abrir {{name}} en el panel enfocado", addProject: "Agregar proyecto", home: "Hogar", groupByProject: "Agrupar por proyecto", @@ -607,7 +610,7 @@ export const es: TranslationResources = { files: "Archivos", pullRequest: "Solicitud de extracción", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "Abrir panel lateral", @@ -1691,6 +1694,8 @@ export const es: TranslationResources = { updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.", unavailable: "Oferta de maridaje no disponible.", hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.", + securityWarning: + "Trata este enlace de emparejamiento como una contraseña. Cualquiera que lo tenga puede acceder a este daemon.", qrUnavailable: "CódigoQRno disponible.", qrAccessibility: "Código QR de emparejamiento", retry: "Rever", @@ -1764,6 +1769,7 @@ export const es: TranslationResources = { loading: "Cargando archivo...", noPreview: "No hay vista previa disponible", binaryPreviewUnavailable: "Vista previa binaria no disponible", + tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo", failedToLoad: "No se pudo cargar el archivo", failedToLoadPreview: "No se pudo cargar la vista previa del archivo", editor: { @@ -1792,6 +1798,7 @@ export const es: TranslationResources = { label: "Archivos", subtitle: "Archivos del espacio de trabajo", tooltip: "Explorar archivos del espacio de trabajo", + chooseFile: "Elige un archivo", }, pullRequest: { label: "Solicitud de extracción", @@ -2414,10 +2421,9 @@ export const es: TranslationResources = { featuresLabel: "Características", featureCount: "{{count}} características", featureCountOne: "{{count}} característica", - notesLabel: "Notas para los agentes", + notesLabel: "Cuándo usarlo", notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.", - notesHint: - 'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.', + notesHint: "Ayuda a los agentes a elegir este perfil al iniciar otro agente.", save: "Guardar", saving: "Guardando...", remove: "Eliminar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d543d34c03..71162b212e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -65,6 +65,9 @@ export const fr: TranslationResources = { workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", + open: "Ouvrir {{name}}", + openInSidePanel: "Ouvrir {{name}} dans le panneau latéral", + openInFocusedPane: "Ouvrir {{name}} dans le volet actif", addProject: "Ajouter un projet", home: "Maison", groupByProject: "Grouper par projet", @@ -607,7 +610,7 @@ export const fr: TranslationResources = { files: "Fichiers", pullRequest: "Demande de fusion", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "Ouvrir le panneau latéral", @@ -1695,6 +1698,8 @@ export const fr: TranslationResources = { updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.", unavailable: "Offre de jumelage indisponible.", hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", + securityWarning: + "Traitez ce lien d’association comme un mot de passe. Toute personne qui le possède peut accéder à ce daemon.", qrUnavailable: "CodeQRindisponible.", qrAccessibility: "Code QR de couplage", retry: "Réessayer", @@ -1768,6 +1773,7 @@ export const fr: TranslationResources = { loading: "Chargement du fichier...", noPreview: "Aucun aperçu disponible", binaryPreviewUnavailable: "Aperçu binaire indisponible", + tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché", failedToLoad: "Échec du chargement du fichier", failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", editor: { @@ -1796,6 +1802,7 @@ export const fr: TranslationResources = { label: "Fichiers", subtitle: "Fichiers de l’espace de travail", tooltip: "Parcourir les fichiers de l’espace de travail", + chooseFile: "Choisissez un fichier", }, pullRequest: { label: "Demande de fusion", @@ -2421,11 +2428,10 @@ export const fr: TranslationResources = { featuresLabel: "Caractéristiques", featureCount: "{{count}} caractéristiques", featureCountOne: "{{count}} caractéristique", - notesLabel: "Notes pour les agents", + notesLabel: "Quand l’utiliser", notesPlaceholder: "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", - notesHint: - "Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.", + notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.", save: "Enregistrer", saving: "Enregistrement...", remove: "Supprimer", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 870bd2b582..81deffe0cb 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -64,6 +64,9 @@ export const ja: TranslationResources = { workspaces: "ワークスペース", agents: "エージェント", newAgent: "新しいエージェント", + open: "{{name}}を開く", + openInSidePanel: "{{name}}をサイドパネルで開く", + openInFocusedPane: "{{name}}をフォーカス中のペインで開く", addProject: "プロジェクトを追加", home: "ホーム", groupByProject: "プロジェクトでグループ化", @@ -607,7 +610,7 @@ export const ja: TranslationResources = { files: "ファイル", pullRequest: "プルリクエスト", terminalProfilesMenu: "ターミナルプロファイル", - editTerminalProfiles: "プロファイルを編集…", + editTerminalProfiles: "プロファイルを編集", }, sidePanel: { open: "サイドパネルを開く", @@ -1662,6 +1665,8 @@ export const ja: TranslationResources = { updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。", unavailable: "ペアリングオファーが利用できません。", hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。", + securityWarning: + "このペアリングリンクはパスワードと同様に扱ってください。リンクを知っている人は誰でもこのデーモンにアクセスできます。", qrUnavailable: "QRコードが利用できません。", qrAccessibility: "ペアリング QR コード", retry: "再試行", @@ -1735,6 +1740,7 @@ export const ja: TranslationResources = { loading: "ファイルを読み込み中...", noPreview: "プレビューが利用できません", binaryPreviewUnavailable: "バイナリプレビューが利用できません", + tooLargeToDisplay: "このファイルは大きすぎて表示できません", failedToLoad: "ファイルの読み込みに失敗しました", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", editor: { @@ -1763,6 +1769,7 @@ export const ja: TranslationResources = { label: "ファイル", subtitle: "ワークスペースのファイル", tooltip: "ワークスペースのファイルを参照", + chooseFile: "ファイルを選択", }, pullRequest: { label: "プルリクエスト", @@ -2383,10 +2390,9 @@ export const ja: TranslationResources = { featuresLabel: "機能", featureCount: "{{count}}個の機能", featureCountOne: "{{count}}個の機能", - notesLabel: "エージェントへのメモ", + notesLabel: "使用する場面", notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。", - notesHint: - "list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。", + notesHint: "別のエージェントを起動するとき、このプロファイルを選ぶために使用されます。", save: "保存", saving: "保存中...", remove: "削除", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 1644232103..8db17f1883 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -64,6 +64,9 @@ export const ko: TranslationResources = { workspaces: "워크스페이스", agents: "에이전트", newAgent: "새 에이전트", + open: "{{name}} 열기", + openInSidePanel: "사이드 패널에서 {{name}} 열기", + openInFocusedPane: "포커스된 창에서 {{name}} 열기", addProject: "프로젝트 추가", home: "홈", groupByProject: "프로젝트별 그룹화", @@ -604,7 +607,7 @@ export const ko: TranslationResources = { files: "파일", pullRequest: "풀 리퀘스트", terminalProfilesMenu: "터미널 프로필", - editTerminalProfiles: "프로필 편집…", + editTerminalProfiles: "프로필 편집", }, sidePanel: { open: "사이드 패널 열기", @@ -1655,6 +1658,8 @@ export const ko: TranslationResources = { updateRequired: "Paseo 데스크톱에서 릴레이를 활성화하려면 호스트를 업데이트하세요.", unavailable: "페어링 정보를 사용할 수 없습니다.", hint: "휴대폰의 Paseo로 이 QR 코드를 스캔하거나 아래 링크를 복사하세요.", + securityWarning: + "이 페어링 링크는 비밀번호처럼 취급하세요. 링크를 가진 사람은 누구나 이 데몬에 접근할 수 있습니다.", qrUnavailable: "QR 코드를 사용할 수 없습니다.", qrAccessibility: "페어링 QR 코드", retry: "다시 시도", @@ -1728,6 +1733,7 @@ export const ko: TranslationResources = { loading: "파일 불러오는 중...", noPreview: "사용 가능한 미리보기가 없습니다", binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다", + tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다", failedToLoad: "파일을 불러오지 못했습니다", failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다", editor: { @@ -1756,6 +1762,7 @@ export const ko: TranslationResources = { label: "파일", subtitle: "워크스페이스 파일", tooltip: "워크스페이스 파일 탐색", + chooseFile: "파일 선택", }, pullRequest: { label: "풀 리퀘스트", @@ -2372,10 +2379,9 @@ export const ko: TranslationResources = { featuresLabel: "기능", featureCount: "기능 {{count}}개", featureCountOne: "기능 {{count}}개", - notesLabel: "에이전트를 위한 메모", + notesLabel: "사용 시점", notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.", - notesHint: - "list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.", + notesHint: "에이전트가 다른 에이전트를 시작할 때 이 프로필을 선택하는 데 사용됩니다.", save: "저장", saving: "저장하는 중...", remove: "제거", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index dc6c9f7d55..d97ffe86ff 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -64,6 +64,9 @@ export const ptBR: TranslationResources = { workspaces: "Espaços de trabalho", agents: "Agentes", newAgent: "Novo agente", + open: "Abrir {{name}}", + openInSidePanel: "Abrir {{name}} no painel lateral", + openInFocusedPane: "Abrir {{name}} no painel em foco", addProject: "Adicionar projeto", home: "Início", groupByProject: "Agrupar por projeto", @@ -606,7 +609,7 @@ export const ptBR: TranslationResources = { files: "Arquivos", pullRequest: "Pull request", terminalProfilesMenu: "Perfis de terminal", - editTerminalProfiles: "Editar perfis…", + editTerminalProfiles: "Editar perfis", }, sidePanel: { open: "Abrir painel lateral", @@ -1677,6 +1680,8 @@ export const ptBR: TranslationResources = { updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.", unavailable: "Oferta de pareamento indisponível.", hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.", + securityWarning: + "Trate este link de pareamento como uma senha. Qualquer pessoa com o link pode acessar este daemon.", qrUnavailable: "QR code indisponível.", qrAccessibility: "Código QR de pareamento", retry: "Tentar novamente", @@ -1750,6 +1755,7 @@ export const ptBR: TranslationResources = { loading: "Carregando arquivo...", noPreview: "Nenhuma prévia disponível", binaryPreviewUnavailable: "Prévia binária indisponível", + tooLargeToDisplay: "Este arquivo é grande demais para exibir", failedToLoad: "Falha ao carregar arquivo", failedToLoadPreview: "Falha ao carregar prévia do arquivo", editor: { @@ -1778,6 +1784,7 @@ export const ptBR: TranslationResources = { label: "Arquivos", subtitle: "Arquivos do espaço de trabalho", tooltip: "Explorar arquivos do espaço de trabalho", + chooseFile: "Escolha um arquivo", }, pullRequest: { label: "Pull request", @@ -2399,10 +2406,9 @@ export const ptBR: TranslationResources = { featuresLabel: "Recursos", featureCount: "{{count}} recursos", featureCountOne: "{{count}} recurso", - notesLabel: "Notas para os agentes", + notesLabel: "Quando usar", notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.", - notesHint: - 'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.', + notesHint: "Ajuda os agentes a escolher este perfil ao iniciar outro agente.", save: "Salvar", saving: "Salvando...", remove: "Remover", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e716282487..f668ccd6a3 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -64,6 +64,9 @@ export const ru: TranslationResources = { workspaces: "Рабочие пространства", agents: "Агенты", newAgent: "Новый агент", + open: "Открыть {{name}}", + openInSidePanel: "Открыть {{name}} в боковой панели", + openInFocusedPane: "Открыть {{name}} в активной области", addProject: "Добавить проект", home: "Главная", groupByProject: "Группировать по проекту", @@ -607,7 +610,7 @@ export const ru: TranslationResources = { files: "Файлы", pullRequest: "PR", terminalProfilesMenu: "Профили терминала", - editTerminalProfiles: "Изменить профили…", + editTerminalProfiles: "Изменить профили", }, sidePanel: { open: "Открыть боковую панель", @@ -1675,6 +1678,8 @@ export const ru: TranslationResources = { updateRequired: "Обновите хост, чтобы включить ретранслятор из Paseo Desktop.", unavailable: "Данные для сопряжения недоступны.", hint: "Отсканируйте этот QR-код с помощью Paseo на телефоне или скопируйте ссылку ниже.", + securityWarning: + "Обращайтесь с этой ссылкой для сопряжения как с паролем. Любой, у кого она есть, может получить доступ к этому демону.", qrUnavailable: "QR-код недоступен.", qrAccessibility: "QR-код сопряжения", retry: "Повторить попытку", @@ -1748,6 +1753,7 @@ export const ru: TranslationResources = { loading: "Загрузка файла...", noPreview: "Предварительный просмотр недоступен", binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", + tooLargeToDisplay: "Этот файл слишком велик для отображения", failedToLoad: "Не удалось загрузить файл", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", editor: { @@ -1777,6 +1783,7 @@ export const ru: TranslationResources = { label: "Файлы", subtitle: "Файлы рабочего пространства", tooltip: "Просмотр файлов рабочего пространства", + chooseFile: "Выберите файл", }, pullRequest: { label: "PR", @@ -2404,10 +2411,9 @@ export const ru: TranslationResources = { featuresLabel: "Функции", featureCount: "Функции: {{count}}", featureCountOne: "{{count}} функция", - notesLabel: "Заметки для агентов", + notesLabel: "Когда использовать", notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.", - notesHint: - "MCP-инструмент «list_profiles» возвращает эти заметки. Сформулируйте их как инструкцию для другого агента.", + notesHint: "Помогает агентам выбрать этот профиль при запуске другого агента.", save: "Сохранить", saving: "Сохранение...", remove: "Удалить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 46bedc69a6..cc641c469d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -64,6 +64,9 @@ export const zhCN: TranslationResources = { workspaces: "工作区", agents: "Agents", newAgent: "新建 Agent", + open: "打开{{name}}", + openInSidePanel: "在侧边面板中打开{{name}}", + openInFocusedPane: "在当前窗格中打开{{name}}", addProject: "添加 project", home: "首页", groupByProject: "按项目分组", @@ -602,7 +605,7 @@ export const zhCN: TranslationResources = { files: "文件", pullRequest: "拉取请求", terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles…", + editTerminalProfiles: "Edit profiles", }, sidePanel: { open: "打开侧边面板", @@ -1625,6 +1628,7 @@ export const zhCN: TranslationResources = { updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。", unavailable: "配对 offer 不可用。", hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。", + securityWarning: "请像保管密码一样保管此配对链接。任何获得此链接的人都可以访问此守护进程。", qrUnavailable: "二维码不可用。", qrAccessibility: "配对二维码", retry: "重试", @@ -1698,6 +1702,7 @@ export const zhCN: TranslationResources = { loading: "正在加载文件...", noPreview: "没有可用预览", binaryPreviewUnavailable: "二进制预览不可用", + tooLargeToDisplay: "此文件过大,无法显示", failedToLoad: "加载文件失败", failedToLoadPreview: "加载文件预览失败", editor: { @@ -1726,6 +1731,7 @@ export const zhCN: TranslationResources = { label: "文件", subtitle: "工作区文件", tooltip: "浏览工作区文件", + chooseFile: "选择文件", }, pullRequest: { label: "拉取请求", @@ -2334,9 +2340,9 @@ export const zhCN: TranslationResources = { featuresLabel: "Features", featureCount: "{{count}} 个 Feature", featureCountOne: "{{count}} 个 Feature", - notesLabel: "给 Agent 的说明", + notesLabel: "适用场景", notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。", - notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。", + notesHint: "帮助 Agent 在启动另一个 Agent 时选择此配置。", save: "保存", saving: "保存中...", remove: "移除", diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index cf0ff22673..376fc17559 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -1,5 +1,8 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; +export type WorkspacePanelTarget = "changes" | "files" | "pull-request"; +export type WorkspacePanelPlacement = "supporting" | "side-panel" | "focused-pane"; + export type KeyboardActionId = | "agent.interrupt" | "message-input.focus" @@ -19,6 +22,15 @@ export type KeyboardActionId = | "workspace.tab.close-current" | "workspace.tab.navigate-index" | "workspace.tab.navigate-relative" + | "workspace.tab.open" + | "workspace.tab.rename-current" + | "workspace.tab.reload-current" + | "workspace.tab.copy-resume-command" + | "workspace.tab.copy-id" + | "workspace.tab.copy-file-path" + | "workspace.tab.close-left" + | "workspace.tab.close-right" + | "workspace.tab.close-others" | "workspace.pane.split.right" | "workspace.pane.split.down" | "workspace.pane.focus.left" @@ -61,6 +73,20 @@ export type KeyboardActionDefinition = | { id: "workspace.tab.close-current"; scope: KeyboardActionScope } | { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number } | { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 } + | { + id: "workspace.tab.open"; + scope: KeyboardActionScope; + target: WorkspacePanelTarget; + placement: WorkspacePanelPlacement; + } + | { id: "workspace.tab.rename-current"; scope: KeyboardActionScope } + | { id: "workspace.tab.reload-current"; scope: KeyboardActionScope } + | { id: "workspace.tab.copy-resume-command"; scope: KeyboardActionScope } + | { id: "workspace.tab.copy-id"; scope: KeyboardActionScope } + | { id: "workspace.tab.copy-file-path"; scope: KeyboardActionScope } + | { id: "workspace.tab.close-left"; scope: KeyboardActionScope } + | { id: "workspace.tab.close-right"; scope: KeyboardActionScope } + | { id: "workspace.tab.close-others"; scope: KeyboardActionScope } | { id: "workspace.pane.split.right"; scope: KeyboardActionScope } | { id: "workspace.pane.split.down"; scope: KeyboardActionScope } | { id: "workspace.pane.focus.left"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index e5f755f07e..b0dd8670d9 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1158,7 +1158,7 @@ describe("direct new-tab target shortcuts", () => { const targetCases = [ ["a", "KeyA", "workspace.tab.target.agent"], ["b", "KeyB", "workspace.tab.target.browser"], - ["c", "KeyC", "workspace.tab.target.changes"], + ["g", "KeyG", "workspace.tab.target.changes"], ["e", "KeyE", "workspace.tab.target.files"], ] as const; @@ -1192,9 +1192,9 @@ describe("direct new-tab target shortcuts", () => { it("uses the existing override map for target matching and display", () => { const bindingId = "workspace-tab-target-agent-ctrl-shift-a-non-mac"; - const overrides = { [bindingId]: "Ctrl+Shift+G" }; + const overrides = { [bindingId]: "Ctrl+Shift+H" }; const rebound = resolveShortcut({ - event: { key: "g", code: "KeyG", ctrlKey: true, shiftKey: true }, + event: { key: "h", code: "KeyH", ctrlKey: true, shiftKey: true }, context: { ...desktopNonMac, focusScope: "other" }, bindings: buildEffectiveBindings(overrides), }); @@ -1208,6 +1208,6 @@ describe("direct new-tab target shortcuts", () => { expect(original.match).toBeNull(); expect( resolveShortcutKeysForAction("workspace-tab-target-agent", overrides, desktopNonMac), - ).toEqual([["ctrl", "shift", "G"]]); + ).toEqual([["ctrl", "shift", "H"]]); }); }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index cebdebf56a..d2b2c31daa 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -454,9 +454,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, { + // Keep the binding id stable so saved overrides from the former Cmd+Shift+C default survive. id: "workspace-tab-target-changes-cmd-shift-c-mac", action: "workspace.tab.target.changes", - combo: "Cmd+Shift+C", + combo: "Cmd+Shift+G", when: { mac: true, commandCenter: false }, help: { id: "workspace-tab-target-changes", @@ -465,9 +466,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, { + // Keep the binding id stable so saved overrides from the former Ctrl+Shift+C default survive. id: "workspace-tab-target-changes-ctrl-shift-c-non-mac", action: "workspace.tab.target.changes", - combo: "Ctrl+Shift+C", + combo: "Ctrl+Shift+G", when: { mac: false, commandCenter: false, terminal: false }, help: { id: "workspace-tab-target-changes", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 49e0473414..8019bf1694 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,7 +25,11 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { Composer } from "@/composer"; -import { resolveComposerTrackTailClearance } from "@/composer/pill-styles"; +import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; +import { + resolveComposerTrackControlClearance, + resolveComposerTrackTailClearance, +} from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; @@ -748,6 +752,7 @@ function AgentPanelBody({ return ( ; @@ -1168,6 +1175,7 @@ function ChatAgentContent({ return ( void; }) { const { t } = useTranslation(); - const isCompactFormFactor = useIsCompactFormFactor(); - const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1259,13 +1267,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ rows: subagentRows, }); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; - const showAgentTracks = - hasActiveComposer && - hasAgentTracks({ - subagentRows, - tasks, - archiveFinishedStatus: archiveFinishedSubagents.status, - }); + const hasVisibleAgentTracks = hasAgentTracks({ + subagentRows, + tasks, + archiveFinishedStatus: archiveFinishedSubagents.status, + }); const rawAgentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, @@ -1336,18 +1342,22 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ - {showAgentTracks ? ( + {hasActiveComposer ? ( ; serverId: string; + workspaceId: string; agentId?: string; agent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; - bottomOverlayTailClearance: number; + hasActiveComposer: boolean; + hasVisibleAgentTracks: boolean; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { + const isCompactFormFactor = useIsCompactFormFactor(); + const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); + const hasVisibleComposerTracks = + hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat); + const bottomOverlayTailClearance = hasVisibleComposerTracks + ? resolveComposerTrackTailClearance(isCompactFormFactor) + : 0; + const bottomOverlayControlClearance = hasVisibleComposerTracks + ? resolveComposerTrackControlClearance(isCompactFormFactor) + : 0; const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); @@ -1491,6 +1515,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} bottomOverlayTailClearance={bottomOverlayTailClearance} + bottomOverlayControlClearance={bottomOverlayControlClearance} toast={toast} pendingMessageSubmissions={pendingMessageSubmissions} turnPresentation={turnPresentation} diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3f9b8172ec..9b5e473368 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,4 +1,6 @@ import { memo, useCallback, type ReactElement } from "react"; +import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; +import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; @@ -15,29 +17,34 @@ import { SubagentsTrack } from "@/subagents/track"; import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import { openSupportingTab } from "@/workspace-tabs/side-panel"; +import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-panel"; /** - * The pane's trackers — its subagents and its task list — as a row of pills above the composer. + * The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above + * the composer. * * The row shares the composer's keyboard transform and owns the space between itself and the - * transcript. Its data remains agent state: a subagent row opens a tab and the task list reads - * the agent's stream. + * transcript. Each pill owns its action while tab placement stays behind the workspace boundary. */ export const AgentTracks = memo(function AgentTracks({ serverId, + workspaceId, + cwd, subagentRows, tasks, archiveFinishedStatus, onArchiveFinished, }: { serverId: string; + workspaceId: string; + cwd: string; subagentRows: SubagentRow[]; tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; onArchiveFinished: () => void; }): ReactElement | null { - const { workspaceId, tabId, openTab } = usePaneContext(); + const { tabId, openTab } = usePaneContext(); + const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId); const isCompact = useIsCompactFormFactor(); const canSplit = supportsDesktopPaneSplits() && !isCompact; const openInSidePanelByDefault = useSettings( @@ -87,13 +94,26 @@ export const AgentTracks = memo(function AgentTracks({ }, [canSplit, isCompact, openInSidePanelByDefault, openTab, tabId, workspaceKey], ); + const handleOpenChanges = useCallback(() => { + if (!workspaceKey) { + return; + } + toggleSupportingTab({ + isCompact, + workspaceKey, + checkout: { serverId, cwd, isGit: true }, + target: { kind: "working_diff" }, + openInSidePanelByDefault, + }); + }, [cwd, isCompact, openInSidePanelByDefault, serverId, workspaceKey]); - if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) { + if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) { return null; } return ( + - + ); }); diff --git a/packages/app/src/panels/files-panel.tsx b/packages/app/src/panels/files-panel.tsx index bfeb93a292..07d17818ed 100644 --- a/packages/app/src/panels/files-panel.tsx +++ b/packages/app/src/panels/files-panel.tsx @@ -1,6 +1,6 @@ import { Text, View } from "react-native"; import { FolderTree } from "lucide-react-native"; -import { withUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; @@ -9,14 +9,11 @@ import { usePaneContext } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useAddFileToChat } from "@/panels/use-add-file-to-chat"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { TreeRail } from "@/components/tree-rail"; +import { useIsCompactFormFactor } from "@/constants/layout"; const ThemedFolderTree = withUnistyles(FolderTree); -const CENTERED_PADDED_STYLE = { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: 16, -} as const; +const FILE_TREE_WIDTH = 220; function useFilesPanelDescriptor() { const { t } = useTranslation(); @@ -35,6 +32,7 @@ function FilesPanel() { const { serverId, workspaceId, target, retargetCurrentTab } = usePaneContext(); const workspaceRoot = useWorkspaceDirectory(serverId, workspaceId); const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId }); + const isCompact = useIsCompactFormFactor(); invariant(target.kind === "files", "FilesPanel requires files target"); const onOpenFile = useCallback( (path: string) => retargetCurrentTab({ kind: "file", path }), @@ -42,19 +40,35 @@ function FilesPanel() { ); if (!workspaceRoot) { return ( - + {t("panels.file.directoryMissing")} ); } + if (isCompact) { + return ( + + ); + } return ( - + + + {t("panels.files.chooseFile")} + + + ); } @@ -64,3 +78,16 @@ export const filesPanelRegistration: PanelRegistration<"files"> = { component: FilesPanel, useDescriptor: useFilesPanelDescriptor, }; + +const styles = StyleSheet.create((theme) => ({ + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/panels/new-tab-panel.tsx b/packages/app/src/panels/new-tab-panel.tsx new file mode 100644 index 0000000000..5faed42ae9 --- /dev/null +++ b/packages/app/src/panels/new-tab-panel.tsx @@ -0,0 +1,330 @@ +import { memo, useCallback, useEffect, useMemo, useRef, type ReactElement } from "react"; +import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Pencil, Plus, type LucideIcon } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; +import { Shortcut } from "@/components/ui/shortcut"; +import { isWeb } from "@/constants/platform"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import type { PanelRegistration } from "@/panels/panel-registry"; +import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme"; +import { + useWorkspaceTabLaunchCatalog, + type WorkspaceTabLaunchItem, +} from "@/workspace-tabs/launcher"; + +const ThemedPlus = withUnistyles(Plus); +const ThemedPencil = withUnistyles(Pencil); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const extraMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundExtraMuted, +}); +/** Every launcher row leads with the same glyph box so labels share one rail. */ +const LAUNCHER_ICON_SIZE = ICON_SIZE.md; +const LAUNCHER_MAX_WIDTH = 380; +const EDIT_PROFILES_HIT_SIZE = ICON_SIZE.xs + SPACING[2]; +const ROW_DATA_SET = { newTabLauncherRow: "true" }; +const ROW_SELECTOR = '[data-new-tab-launcher-row="true"]'; +function LauncherIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { + return ; +} + +const ThemedLauncherIcon = withUnistyles(LauncherIcon); + +function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null { + const keys = useShortcutKeys(actionId); + return keys ? : null; +} + +function rowStyle({ + pressed, + hovered = false, + focused = false, +}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) { + return [ + styles.row, + (hovered || focused) && styles.rowHovered, + focused && styles.rowFocused, + pressed && styles.rowPressed, + ]; +} + +function editProfilesStyle({ pressed, hovered }: PressableStateCallbackType) { + return [styles.editProfiles, (hovered || pressed) && styles.editProfilesHovered]; +} + +function EditProfilesButton({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + + + + + ); +} + +function LauncherRow({ item }: { item: WorkspaceTabLaunchItem }) { + const { tabId } = usePaneContext(); + const handlePress = useCallback(() => { + item.launch({ kind: "replace", tabId }); + }, [item, tabId]); + + return ( + + {item.Icon ? : null} + {item.terminalIconKey ? ( + + ) : null} + + {item.label} + + {item.shortcutActionId ? : null} + + ); +} + +function useNewTabDescriptor() { + const { t } = useTranslation(); + const label = t("workspace.tabs.actions.newTab"); + return { + label, + subtitle: label, + tooltip: label, + titleState: "ready" as const, + icon: ThemedPlus, + statusBucket: null, + }; +} + +const NewTabPanel = memo(function NewTabPanel(): ReactElement { + const { isSidePanel, serverId, tabId } = usePaneContext(); + const { isInteractive, focusPane } = usePaneFocus(); + const containerRef = useRef(null); + const groups = useWorkspaceTabLaunchCatalog({ + serverId, + purpose: isSidePanel ? "supporting" : "primary", + }); + const itemsById = useMemo( + () => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])), + [groups], + ); + + useEffect(() => { + if (!isWeb || !isInteractive) return; + const container: unknown = containerRef.current; + if (!(container instanceof HTMLElement)) return; + const webContainer = container as HTMLElement; + + function rows() { + return Array.from(webContainer.querySelectorAll(ROW_SELECTOR)) as HTMLElement[]; + } + function focusFirstRow() { + rows()[0]?.focus({ preventScroll: true }); + } + function handlePointerDown(event: PointerEvent) { + focusPane(); + if (!(event.target instanceof Element) || !event.target.closest(ROW_SELECTOR)) { + requestAnimationFrame(focusFirstRow); + } + } + function handleKeyDown(event: KeyboardEvent) { + const availableRows = rows(); + const currentIndex = availableRows.findIndex((row) => row === document.activeElement); + let direction = 0; + if (event.key === "ArrowDown") { + direction = 1; + } else if (event.key === "ArrowUp") { + direction = -1; + } + if (direction !== 0 && availableRows.length > 0) { + event.preventDefault(); + let start = currentIndex; + if (currentIndex < 0) { + start = direction > 0 ? -1 : 0; + } + const nextIndex = (start + direction + availableRows.length) % availableRows.length; + availableRows[nextIndex]?.focus(); + } + if (event.key === "Enter" && currentIndex >= 0) { + event.preventDefault(); + availableRows[currentIndex]?.click(); + } + } + + webContainer.addEventListener("pointerdown", handlePointerDown, true); + webContainer.addEventListener("keydown", handleKeyDown); + focusFirstRow(); + return () => { + webContainer.removeEventListener("pointerdown", handlePointerDown, true); + webContainer.removeEventListener("keydown", handleKeyDown); + }; + }, [focusPane, isInteractive]); + + const handleKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (action.id === "workspace.agent.new" || action.id === "workspace.tab.target.agent") { + itemsById.get("agent")?.launch({ kind: "replace", tabId }); + return true; + } + if (action.id === "workspace.terminal.new") { + itemsById.get("terminal")?.launch({ kind: "replace", tabId }); + return true; + } + if (action.id === "workspace.browser.new" || action.id === "workspace.tab.target.browser") { + itemsById.get("browser")?.launch({ kind: "replace", tabId }); + return true; + } + if (action.id === "workspace.tab.target.changes") { + itemsById.get("changes")?.launch({ kind: "replace", tabId }); + return true; + } + if (action.id === "workspace.tab.target.files") { + itemsById.get("files")?.launch({ kind: "replace", tabId }); + return true; + } + return false; + }, + [itemsById, tabId], + ); + useKeyboardActionHandler({ + handlerId: `new-tab:${tabId}`, + actions: [ + "workspace.agent.new", + "workspace.terminal.new", + "workspace.browser.new", + "workspace.tab.target.agent", + "workspace.tab.target.browser", + "workspace.tab.target.changes", + "workspace.tab.target.files", + ], + enabled: isInteractive, + priority: 250, + handle: handleKeyboardAction, + }); + + return ( + + + + {groups.map((group) => ( + + {group.label ? ( + + {group.label} + {group.accessory ? ( + + ) : null} + + ) : null} + {group.items.map((item) => ( + + ))} + + ))} + + + + ); +}); + +export const newTabPanelRegistration: PanelRegistration<"new_tab"> = { + kind: "new_tab", + resourceKey: () => "new_tab", + component: NewTabPanel, + useDescriptor: useNewTabDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[6], + }, + rail: { + width: "100%", + maxWidth: LAUNCHER_MAX_WIDTH, + gap: theme.spacing[12], + }, + group: { + gap: theme.spacing[1], + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 44, + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: "transparent", + outlineWidth: 0, + outlineColor: "transparent", + backgroundColor: theme.colors.surface1, + }, + rowHovered: { backgroundColor: theme.colors.surface2 }, + rowFocused: { borderColor: theme.colors.borderAccent }, + rowPressed: { opacity: 0.85 }, + rowLabel: { flex: 1, color: theme.colors.foreground }, + // The section header shares the launcher rows' outer rails. The pencil's own + // padding is subtracted so its glyph — not its hover box — lands on the right rail. + groupHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 2, + marginBottom: theme.spacing[1], + }, + groupLabel: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.sm, + // Without an explicit line height the font's ascent/descent split the text box + // unevenly and the label rides above the pencil beside it. + lineHeight: EDIT_PROFILES_HIT_SIZE, + }, + // The box is the hover target; the negative margin cancels its growth on every + // side, so its layout footprint stays the glyph and the header keeps its height. + editProfiles: { + width: EDIT_PROFILES_HIT_SIZE, + height: EDIT_PROFILES_HIT_SIZE, + margin: -(EDIT_PROFILES_HIT_SIZE - ICON_SIZE.xs) / 2, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + outlineWidth: 0, + outlineColor: "transparent", + }, + // Opacity sits on the whole glyph, not per path — the pencil's two strokes + // overlap and would render unevenly if each faded on its own. + editProfilesGlyph: { + opacity: theme.opacity[50], + }, + editProfilesHovered: { + backgroundColor: theme.colors.surface2, + }, +})); diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx index 2020e9800b..56a0c22ce4 100644 --- a/packages/app/src/panels/pane-context.tsx +++ b/packages/app/src/panels/pane-context.tsx @@ -7,6 +7,8 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; export interface PaneContextValue { serverId: string; workspaceId: string; + /** Whether this pane is the workspace's dedicated Side panel. */ + isSidePanel: boolean; tabId: string; target: WorkspaceTabTarget; state?: JsonValue; diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index cb6c86dad3..9003cabb65 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -10,6 +10,7 @@ import { terminalPanelRegistration } from "@/panels/terminal-panel"; import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel"; import { pullRequestPanelRegistration } from "@/panels/pull-request-panel"; import { pluginPanelRegistration } from "@/plugins/workspace-panels/panel"; +import { newTabPanelRegistration } from "@/panels/new-tab-panel"; let panelsRegistered = false; @@ -18,6 +19,7 @@ export function ensurePanelsRegistered(): void { return; } registerPanel(draftPanelRegistration); + registerPanel(newTabPanelRegistration); registerPanel(agentPanelRegistration); registerPanel(providerSubagentPanelRegistration); registerPanel(setupPanelRegistration); diff --git a/packages/app/src/projects/icon-cache-storage.ts b/packages/app/src/projects/icon-cache-storage.ts new file mode 100644 index 0000000000..bcbecf7d11 --- /dev/null +++ b/packages/app/src/projects/icon-cache-storage.ts @@ -0,0 +1,4 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { ProjectIconCacheStorage } from "./icon-cache"; + +export const projectIconCacheStorage: ProjectIconCacheStorage = AsyncStorage; diff --git a/packages/app/src/projects/icon-cache-storage.web.ts b/packages/app/src/projects/icon-cache-storage.web.ts new file mode 100644 index 0000000000..3f89026530 --- /dev/null +++ b/packages/app/src/projects/icon-cache-storage.web.ts @@ -0,0 +1,46 @@ +import type { ProjectIconCacheStorage } from "./icon-cache"; + +const DATABASE_NAME = "paseo-project-icon-cache"; +const STORE_NAME = "key-value"; + +let databasePromise: Promise | null = null; + +function openDatabase(): Promise { + databasePromise ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => + reject(request.error ?? new Error("Failed to open project icon cache")), + ); + }); + return databasePromise; +} + +async function getItem(key: string): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key); + request.addEventListener("success", () => + resolve(typeof request.result === "string" ? request.result : null), + ); + request.addEventListener("error", () => reject(request.error)); + }); +} + +async function setItem(key: string, value: string): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + transaction.objectStore(STORE_NAME).put(value, key); + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("error", () => reject(transaction.error)); + transaction.addEventListener("abort", () => reject(transaction.error)); + }); +} + +export const projectIconCacheStorage: ProjectIconCacheStorage = { getItem, setItem }; diff --git a/packages/app/src/projects/icon-cache.ts b/packages/app/src/projects/icon-cache.ts index d409a59ba4..e88704be23 100644 --- a/packages/app/src/projects/icon-cache.ts +++ b/packages/app/src/projects/icon-cache.ts @@ -1,6 +1,6 @@ import type { ProjectIcon } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { replicaCacheStorage } from "@/runtime/replica-cache/storage"; +import { projectIconCacheStorage } from "./icon-cache-storage"; import type { ProjectIconTarget } from "./icon-target"; const STORAGE_KEY = "@paseo:project-icon-cache"; @@ -197,7 +197,7 @@ export class ProjectIconCache { } } -export const projectIconCache = new ProjectIconCache(replicaCacheStorage); +export const projectIconCache = new ProjectIconCache(projectIconCacheStorage); function resolveLookup( target: Pick, diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 2f9e2969aa..43b6af914a 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -20,7 +20,6 @@ import { import { isWeb } from "@/constants/platform"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; -import { useWorkspaceFocusRestoration } from "@/workspace/focus"; import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { @@ -441,7 +440,6 @@ export function InlineReviewEditor({ }) { const { t } = useTranslation(); const inputRef = useRef(null); - const focus = useWorkspaceFocusRestoration(); const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); @@ -452,13 +450,11 @@ export function InlineReviewEditor({ }, []); const handleFocus = useCallback(() => { - focus.unfocus(); setIsFocused(true); - }, [focus]); + }, []); const handleBlur = useCallback(() => { setIsFocused(false); - focus.restore(); - }, [focus]); + }, []); const handleSave = useCallback(() => onSave(trimmedBody), [onSave, trimmedBody]); useEffect(() => { diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index 9a1705bd53..cb6a9927c9 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -149,6 +149,52 @@ it("commits the authoritative snapshot before buffered project updates", () => { store.clearSession(serverId); }); +it("preserves unchanged project identity when another project changes", () => { + const serverId = "project-identity"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const first = normalizeProjectDescriptor({ + projectId: "first", + projectDisplayName: "First", + projectRootPath: "/repo/first", + projectKind: "git", + }); + const second = normalizeProjectDescriptor({ + projectId: "second", + projectDisplayName: "Second", + projectRootPath: "/repo/second", + projectKind: "git", + }); + replica.commitSnapshot( + { + workspaces: new Map(), + projects: new Map([ + ["first", first], + ["second", second], + ]), + }, + [], + ); + const previousSecond = useSessionStore.getState().sessions[serverId]?.projects.get("second"); + + replica.commitSnapshot( + { + workspaces: new Map(), + projects: new Map([ + ["first", { ...first, projectDisplayName: "Updated" }], + ["second", { ...second }], + ]), + }, + [], + ); + + expect(useSessionStore.getState().sessions[serverId]?.projects.get("second")).toBe( + previousSecond, + ); + store.clearSession(serverId); +}); + it("does not invent a null-key project from a workspace update", () => { const serverId = "workspace-before-project-update"; const store = useSessionStore.getState(); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 9fc711d683..0548cf853f 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -22,6 +22,7 @@ import { type HostRuntimeStorage, } from "./host-runtime"; import { ReplicaCache } from "./replica-cache"; +import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; @@ -451,6 +452,40 @@ function createMemoryHostRuntimeStorage(entries: Record = {}): H }; } +function createMemoryReplicaRowStore(): ReplicaRowStore { + const rows = new Map(); + const keyOf = (row: Pick) => + `${row.serverId}:${row.kind}:${row.id}`; + return { + open: async () => undefined, + readAll: async () => { + const hosts = new Map(); + for (const row of rows.values()) { + const hostRows = hosts.get(row.serverId) ?? []; + hostRows.push(row); + hosts.set(row.serverId, hostRows); + } + return [...hosts].map(([serverId, hostRows]) => ({ serverId, rows: hostRows })); + }, + apply: async (changes) => { + for (const key of changes.deletes) rows.delete(keyOf(key)); + for (const row of changes.upserts) rows.set(keyOf(row), row); + }, + deleteHost: async (serverId) => { + for (const [key, row] of rows) if (row.serverId === serverId) rows.delete(key); + }, + renameHost: async (oldServerId, newServerId) => { + for (const [key, row] of rows) { + if (row.serverId !== oldServerId) continue; + rows.delete(key); + const renamed = { ...row, serverId: newServerId }; + rows.set(keyOf(renamed), renamed); + } + }, + clear: async () => rows.clear(), + }; +} + function createAppearanceStore(storage: HostRuntimeStorage): HostRuntimeStore { return new HostRuntimeStore({ storage, @@ -1438,6 +1473,7 @@ describe("HostRuntimeStore", () => { it("restores the display replica before declaring the host registry loaded", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); + const replicaRowStore = createMemoryReplicaRowStore(); await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); await storage.setItem("@paseo:e2e", "1"); @@ -1470,13 +1506,14 @@ describe("HostRuntimeStore", () => { ], ]), ); - const cache = new ReplicaCache(storage); + const cache = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined }); cache.setHosts([host.serverId]); await cache.flush(); session.clearSession(host.serverId); const store = new HostRuntimeStore({ storage, + replicaRowStore, deps: { createClient: () => { throw new Error("createClient should not be called"); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 9a5e0338b3..684b5e6227 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -60,7 +60,8 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; -import { replicaCacheStorage } from "@/runtime/replica-cache/storage"; +import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store"; +import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory"; import { projectIconCache } from "@/projects/icon-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; import { revokePushNotifications } from "@/push-notifications"; @@ -1373,11 +1374,12 @@ export class HostRuntimeStore { constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage; + replicaRowStore?: ReplicaRowStore; revokePushNotifications?: typeof revokePushNotifications; }) { this.deps = input?.deps ?? createDefaultDeps(); this.storage = input?.storage ?? AsyncStorage; - this.replicaCache = new ReplicaCache(input?.storage ?? replicaCacheStorage); + this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; } @@ -1391,6 +1393,10 @@ export class HostRuntimeStore { return this.hostRegistryStatus; } + recordUserActivity(): void { + this.replicaCache.recordUserActivity(); + } + subscribeHostList(listener: () => void): () => void { this.hostListListeners.add(listener); return () => { diff --git a/packages/app/src/runtime/replica-cache/diff.test.ts b/packages/app/src/runtime/replica-cache/diff.test.ts new file mode 100644 index 0000000000..36c8f41659 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/diff.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import { diffMap, diffReplicaInput, type ReplicaInput } from "./diff"; + +function input(overrides: Partial = {}): ReplicaInput { + return { + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + focusedAgentId: undefined, + timelineItems: undefined, + timelineRange: null, + timelineHasOlder: false, + ...overrides, + }; +} + +describe("diffReplicaInput", () => { + it("upserts only values whose map identity changed and deletes missing ids", () => { + const retained = { value: "retained" }; + const previousValue = { value: "previous" }; + const changed = { value: "changed" }; + const removed = { value: "removed" }; + const previous = new Map([ + ["retained", retained], + ["changed", previousValue], + ["removed", removed], + ]); + const next = new Map([ + ["retained", retained], + ["changed", changed], + ]); + + expect(diffMap(previous, next)).toEqual({ + upserts: [changed], + deletes: ["removed"], + }); + }); + + it("does not rewrite the timeline when its inputs retain identity", () => { + const timelineItems: StreamItem[] = []; + const range = { epoch: "epoch", startSeq: 1, endSeq: 1 }; + const previous = input({ + focusedAgentId: "agent", + timelineItems, + timelineRange: range, + }); + const next = input({ + focusedAgentId: "agent", + timelineItems, + timelineRange: range, + }); + + expect(diffReplicaInput(previous, next).timelineChanged).toBe(false); + }); + + it("rewrites the timeline when one of its inputs changes identity", () => { + const previous = input({ focusedAgentId: "agent", timelineItems: [] }); + const next = input({ focusedAgentId: "agent", timelineItems: [] }); + + expect(diffReplicaInput(previous, next).timelineChanged).toBe(true); + }); +}); diff --git a/packages/app/src/runtime/replica-cache/diff.ts b/packages/app/src/runtime/replica-cache/diff.ts new file mode 100644 index 0000000000..8cfc4b0cb9 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/diff.ts @@ -0,0 +1,77 @@ +import type { + Agent, + ProjectDescriptor, + SessionReplicaTimeline, + WorkspaceDescriptor, +} from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; + +export interface ReplicaInput { + agents: ReadonlyMap; + workspaces: ReadonlyMap; + projects: ReadonlyMap; + focusedAgentId: string | undefined; + timelineItems: StreamItem[] | undefined; + timelineRange: SessionReplicaTimeline["range"]; + timelineHasOlder: boolean; +} + +interface EntityChanges { + upserts: Entity[]; + deletes: string[]; +} + +export interface ReplicaInputChanges { + agents: EntityChanges; + workspaces: EntityChanges; + projects: EntityChanges; + timelineChanged: boolean; +} + +export function diffMap( + previous: ReadonlyMap | undefined, + next: ReadonlyMap, +): EntityChanges { + if (previous === next) return { upserts: [], deletes: [] }; + const upserts: Entity[] = []; + const deletes: string[] = []; + for (const [id, value] of next) { + if (previous?.get(id) !== value) upserts.push(value); + } + if (previous) { + for (const id of previous.keys()) { + if (!next.has(id)) deletes.push(id); + } + } + return { upserts, deletes }; +} + +export function diffReplicaInput( + previous: ReplicaInput | undefined, + next: ReplicaInput, +): ReplicaInputChanges { + const timelineChanged = + previous === undefined || + previous.focusedAgentId !== next.focusedAgentId || + previous.timelineItems !== next.timelineItems || + previous.timelineRange !== next.timelineRange || + previous.timelineHasOlder !== next.timelineHasOlder; + return { + agents: diffMap(previous?.agents, next.agents), + workspaces: diffMap(previous?.workspaces, next.workspaces), + projects: diffMap(previous?.projects, next.projects), + timelineChanged, + }; +} + +export function hasReplicaInputChanges(changes: ReplicaInputChanges): boolean { + return ( + changes.agents.upserts.length > 0 || + changes.agents.deletes.length > 0 || + changes.workspaces.upserts.length > 0 || + changes.workspaces.deletes.length > 0 || + changes.projects.upserts.length > 0 || + changes.projects.deletes.length > 0 || + changes.timelineChanged + ); +} diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 3d27070fa1..12497e8a37 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { projectAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, @@ -8,27 +10,90 @@ import { useSessionStore, } from "@/stores/session-store"; import { createUserMessage, type StreamItem } from "@/types/stream"; -import { ReplicaCache, type ReplicaCacheStorage } from "."; +import { ReplicaCache } from "."; +import { AgentDirectoryReplica } from "../directory-sync/agent-replica"; +import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; const LRU_SERVER_IDS = ["host-a", "host-b", "host-c"] as const; -class MemoryStorage implements ReplicaCacheStorage { - readonly values = new Map(); +class MemoryStorage implements ReplicaRowStore { + readonly rows = new Map(); + readonly changes: ReplicaRowChanges[] = []; + readonly deletedHosts: string[] = []; + readonly renamedHosts: Array<{ oldServerId: string; newServerId: string }> = []; writes = 0; + clears = 0; + applyResults: Promise[] = []; - async getItem(key: string): Promise { - return this.values.get(key) ?? null; + private key(row: Pick): string { + return `${row.serverId}:${row.kind}:${row.id}`; } - async setItem(key: string, value: string): Promise { + async open(): Promise {} + + async readAll(): Promise { + const hosts = new Map(); + for (const row of this.rows.values()) { + const rows = hosts.get(row.serverId) ?? []; + rows.push(row); + hosts.set(row.serverId, rows); + } + return Array.from(hosts, ([serverId, rows]) => ({ serverId, rows })); + } + + async apply(changes: ReplicaRowChanges): Promise { this.writes += 1; - this.values.set(key, value); + const result = this.applyResults.shift(); + if (result) await result; + this.changes.push(changes); + for (const key of changes.deletes) this.rows.delete(this.key(key)); + for (const row of changes.upserts) this.rows.set(this.key(row), row); } - async removeItem(key: string): Promise { - this.values.delete(key); + async deleteHost(serverId: string): Promise { + this.deletedHosts.push(serverId); + for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); } + + async renameHost(oldServerId: string, newServerId: string): Promise { + this.renamedHosts.push({ oldServerId, newServerId }); + for (const [key, row] of this.rows) { + if (row.serverId !== oldServerId) continue; + this.rows.delete(key); + const renamed = { ...row, serverId: newServerId }; + this.rows.set(this.key(renamed), renamed); + } + } + + async clear(): Promise { + this.clears += 1; + this.rows.clear(); + } +} + +function deferred(): { + promise: Promise; + reject: (error: Error) => void; +} { + let reject = (_error: Error): void => undefined; + const promise = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }); + return { promise, reject }; +} + +async function waitForWrites(storage: MemoryStorage, expected: number): Promise { + for (let attempt = 0; attempt < 20 && storage.writes < expected; attempt += 1) { + await Promise.resolve(); + } + expect(storage.writes).toBe(expected); +} + +const NO_LEGACY_CLEANUP = { clearLegacyCache: async () => undefined }; + +function cache(storage: MemoryStorage, options: { maxBytes?: number } = {}): ReplicaCache { + return new ReplicaCache(storage, { ...options, ...NO_LEGACY_CLEANUP }); } function workspace( @@ -139,7 +204,26 @@ function toolCall(): StreamItem { function seedSession(): void { const store = useSessionStore.getState(); store.initializeSession(SERVER_ID, null); - store.setAgents(SERVER_ID, new Map([["agent-1", agent("agent-1")]])); + store.setAgents( + SERVER_ID, + new Map([ + [ + "agent-1", + { + ...agent("agent-1"), + pendingPermissions: [ + { + id: "pending-permission", + provider: "codex", + name: "shell", + kind: "tool", + title: "Approve shell command", + }, + ], + }, + ], + ]), + ); store.setWorkspaces( SERVER_ID, new Map([ @@ -209,42 +293,71 @@ afterEach(() => { }); describe("ReplicaCache", () => { + it("persists after user inactivity even while replica changes continue", async () => { + vi.useFakeTimers(); + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + replicaCache.start(); + const writesBeforeChange = storage.writes; + + useSessionStore + .getState() + .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("first", "First")]]])); + await vi.advanceTimersByTimeAsync(4_000); + replicaCache.recordUserActivity(); + await vi.advanceTimersByTimeAsync(1_000); + useSessionStore + .getState() + .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("second", "Second")]]])); + await vi.advanceTimersByTimeAsync(3_999); + + expect(storage.writes).toBe(writesBeforeChange); + + await vi.advanceTimersByTimeAsync(1); + + expect(storage.writes).toBe(writesBeforeChange + 1); + replicaCache.setHosts([]); + }); + it("persists focused replica changes without writing transient stream head updates", async () => { vi.useFakeTimers(); const storage = new MemoryStorage(); - const cache = new ReplicaCache(storage); - cache.setHosts([SERVER_ID]); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); seedSession(); - await cache.flush(); - cache.start(); + await replicaCache.flush(); + replicaCache.start(); const writesBeforeStream = storage.writes; useSessionStore .getState() .setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]])); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5_000); expect(storage.writes).toBe(writesBeforeStream); useSessionStore .getState() .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]])); - await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(5_000); expect(storage.writes).toBe(writesBeforeStream + 1); - cache.setHosts([]); + replicaCache.setHosts([]); }); it("restores the exact persisted canonical timeline window", async () => { const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); + const writer = cache(storage); writer.setHosts([SERVER_ID]); seedSession(); await writer.flush(); useSessionStore.getState().clearSession(SERVER_ID); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); @@ -260,6 +373,7 @@ describe("ReplicaCache", () => { expect(Array.from(session.projects.keys())).toEqual(["project-1", "empty-project"]); expect(session.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo"); + expect(session.agents.get("agent-1")?.pendingPermissions).toEqual([]); expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); // A restored row draws its label chips. The reconnect cursor is current, so nothing re-sends @@ -284,7 +398,7 @@ describe("ReplicaCache", () => { it("restores canonical turn membership without downgrading tagged rows", async () => { const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); + const writer = cache(storage); writer.setHosts([SERVER_ID]); seedSession(); const initial: StreamItem = { @@ -311,7 +425,7 @@ describe("ReplicaCache", () => { ); await writer.flush(); useSessionStore.getState().clearSession(SERVER_ID); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); const tail = @@ -321,14 +435,14 @@ describe("ReplicaCache", () => { it("restores tool calls inside an authoritative cached window", async () => { const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); + const writer = cache(storage); writer.setHosts([SERVER_ID]); seedSession(); useSessionStore.getState().setAgentStreamTail(SERVER_ID, new Map([["agent-1", [toolCall()]]])); await writer.flush(); useSessionStore.getState().clearSession(SERVER_ID); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); @@ -343,7 +457,7 @@ describe("ReplicaCache", () => { it("restores display-only when retained items do not reach the stored range end", async () => { const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); + const writer = cache(storage); writer.setHosts([SERVER_ID]); seedSession(); useSessionStore @@ -355,7 +469,7 @@ describe("ReplicaCache", () => { await writer.flush(); useSessionStore.getState().clearSession(SERVER_ID); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); @@ -370,8 +484,8 @@ describe("ReplicaCache", () => { it("persists the complete directory with only the focused timeline tail", async () => { const storage = new MemoryStorage(); - const cache = new ReplicaCache(storage); - cache.setHosts([SERVER_ID]); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); seedSession(); const store = useSessionStore.getState(); @@ -402,10 +516,10 @@ describe("ReplicaCache", () => { store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-2", true]])); store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-2", true); store.setFocusedAgentId(SERVER_ID, "agent-2"); - await cache.flush(); + await replicaCache.flush(); store.clearSession(SERVER_ID); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); @@ -426,23 +540,17 @@ describe("ReplicaCache", () => { items: secondTimeline.slice(-50), }); - const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as { - version: number; - hosts: Array<{ timeline: Record | null }>; - }; - expect(persisted.version).toBe(6); - expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual([ - "agentId", - "hasOlder", - "items", - "range", - ]); + const timelineRow = [...storage.rows.values()].find((row) => row.kind === "timeline"); + expect(timelineRow).toBeDefined(); + if (!timelineRow) throw new Error("Expected persisted timeline row"); + const persisted = JSON.parse(timelineRow.payload) as Record; + expect(Object.keys(persisted).sort()).toEqual(["agentId", "hasOlder", "items", "range"]); }); it("persists reconciled rows without caching unreconciled local presentations", async () => { const storage = new MemoryStorage(); - const cache = new ReplicaCache(storage); - cache.setHosts([SERVER_ID]); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); seedSession(); const unreconciled = createUserMessage({ clientMessageId: "client-pending", @@ -460,9 +568,9 @@ describe("ReplicaCache", () => { .getState() .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [unreconciled, reconciled]]])); - await cache.flush(); + await replicaCache.flush(); useSessionStore.getState().clearSession(SERVER_ID); - await cache.restore(); + await replicaCache.restore(); expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1")).toEqual([ reconciled, @@ -471,12 +579,12 @@ describe("ReplicaCache", () => { it("persists monotonic directory cursors with the complete host replica", async () => { const storage = new MemoryStorage(); - const cache = new ReplicaCache(storage); - cache.setHosts([SERVER_ID]); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); seedSession(); - await cache.flush(); + await replicaCache.flush(); - cache.writeDirectoryCheckpoint(SERVER_ID, { + replicaCache.writeDirectoryCheckpoint(SERVER_ID, { agents: { generation: "daemon-generation", afterSeq: 7 }, }); useSessionStore.getState().setAgents(SERVER_ID, (agents) => { @@ -484,14 +592,166 @@ describe("ReplicaCache", () => { if (!current) throw new Error("Expected seeded agent"); return new Map(agents).set("agent-1", { ...current, title: "Updated agent" }); }); - await cache.flush(); + await replicaCache.flush(); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({ agents: { generation: "daemon-generation", afterSeq: 7 }, }); + const finalWrite = storage.changes.at(-1); + expect(finalWrite?.upserts.map((row) => row.kind).sort()).toEqual(["agent", "checkpoint"]); + }); + + it("persists one agent update as exactly one row upsert", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + storage.changes.length = 0; + + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const current = agents.get("agent-1"); + if (!current) throw new Error("Expected seeded agent"); + return new Map(agents).set(current.id, { ...current, title: "Updated" }); + }); + await replicaCache.flush(); + + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]).toMatchObject({ + upserts: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], + deletes: [], + }); + }); + + it("persists the focused synced timeline produced by the session store", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + const focused = agent("agent-1"); + store.setAgents(SERVER_ID, new Map([[focused.id, focused]])); + store.setFocusedAgentId(SERVER_ID, focused.id); + store.setAgentStreamState(SERVER_ID, focused.id, { + tail: [message("synced-message", "Synced from daemon")], + }); + store.setAgentTimelineCursor( + SERVER_ID, + new Map([[focused.id, { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]), + ); + store.setAgentAuthoritativeHistoryApplied(SERVER_ID, focused.id, true); + + await replicaCache.flush(); + + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts).toContainEqual( + expect.objectContaining({ + serverId: SERVER_ID, + kind: "timeline", + id: "singleton", + }), + ); + }); + + it("does not persist unchanged agents after a changes-mode reconciliation", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + const directory = new AgentDirectoryReplica(SERVER_ID, () => undefined); + const agents = [agent("agent-1"), agent("agent-2")]; + directory.commitSnapshot( + agents.map((value) => ({ + agent: projectAgentSnapshot(value), + project: value.projectPlacement!, + })), + [], + ); + await replicaCache.flush(); + storage.changes.length = 0; + + directory.commitChanges([], [], []); + await replicaCache.flush(); + + expect(storage.changes).toEqual([]); + }); + + it("persists only the changed agent after a changes-mode reconciliation", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + const directory = new AgentDirectoryReplica(SERVER_ID, () => undefined); + const agents = [agent("agent-1"), agent("agent-2")]; + directory.commitSnapshot( + agents.map((value) => ({ + agent: projectAgentSnapshot(value), + project: value.projectPlacement!, + })), + [], + ); + await replicaCache.flush(); + storage.changes.length = 0; + + directory.commitChanges( + [ + { + agent: { ...projectAgentSnapshot(agents[0]), title: "Updated" }, + project: agents[0].projectPlacement!, + }, + ], + [], + [], + ); + await replicaCache.flush(); + + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]).toMatchObject({ + upserts: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], + deletes: [], + }); + }); + + it("persists a checkpoint without serializing host entity rows", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + storage.changes.length = 0; + + replicaCache.writeDirectoryCheckpoint(SERVER_ID, { + agents: { generation: "generation", afterSeq: 4 }, + }); + await replicaCache.flush(); + + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts).toEqual([ + expect.objectContaining({ kind: "checkpoint", id: "singleton" }), + ]); + }); + + it("maps host removal and reconciliation to row-store host operations", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + + replicaCache.reconcileServerId(SERVER_ID, "reconciled-host"); + await replicaCache.flush(); + replicaCache.setHosts([]); + await replicaCache.flush(); + + expect(storage.renamedHosts).toEqual([ + { oldServerId: SERVER_ID, newServerId: "reconciled-host" }, + ]); + expect(storage.deletedHosts).toContain("reconciled-host"); }); it("restores workspace change request checks beside the directory cursor", async () => { @@ -517,7 +777,7 @@ describe("ReplicaCache", () => { error: null, }; const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); + const writer = cache(storage); writer.setHosts([SERVER_ID]); seedSession(); useSessionStore.getState().setWorkspaces( @@ -539,7 +799,7 @@ describe("ReplicaCache", () => { await writer.flush(); useSessionStore.getState().clearSession(SERVER_ID); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts([SERVER_ID]); await reader.restore(); @@ -553,13 +813,13 @@ describe("ReplicaCache", () => { it("restores every registered host directory before any host reconnects", async () => { const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); + const writer = cache(storage); writer.setHosts(LRU_SERVER_IDS); for (const serverId of LRU_SERVER_IDS) seedTimeline(serverId, `cached-${serverId}`); await writer.flush(); for (const serverId of LRU_SERVER_IDS) useSessionStore.getState().clearSession(serverId); - const reader = new ReplicaCache(storage); + const reader = cache(storage); reader.setHosts(LRU_SERVER_IDS); await reader.restore(); @@ -575,62 +835,253 @@ describe("ReplicaCache", () => { it("evicts the least recently written host when the cache exceeds its byte budget", async () => { const storage = new MemoryStorage(); - const cache = new ReplicaCache(storage, { maxBytes: 7_000 }); - cache.setHosts(LRU_SERVER_IDS.slice(0, 2)); + const replicaCache = cache(storage, { maxBytes: 7_000 }); + replicaCache.setHosts(LRU_SERVER_IDS.slice(0, 2)); seedTimeline("host-a", "A".repeat(1_200)); seedTimeline("host-b", "B".repeat(1_200)); - await cache.flush(); + await replicaCache.flush(); seedTimeline("host-a", "A".repeat(1_201)); - await cache.flush(); + await replicaCache.flush(); - cache.setHosts(LRU_SERVER_IDS); + replicaCache.setHosts(LRU_SERVER_IDS); seedTimeline("host-c", "C".repeat(1_200)); - await cache.flush(); + await replicaCache.flush(); for (const serverId of LRU_SERVER_IDS) { useSessionStore.getState().clearSession(serverId); } - const reader = new ReplicaCache(storage, { maxBytes: 7_000 }); + const reader = cache(storage, { maxBytes: 7_000 }); reader.setHosts(LRU_SERVER_IDS); await reader.restore(); expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects and clears version 5 cache data before overwriting it on flush", async () => { + it("never writes an oversized host and does not retry it on every flush", async () => { const storage = new MemoryStorage(); - storage.values.set( - "@paseo:replica-cache", - JSON.stringify({ - version: 5, - hosts: [ - { - serverId: SERVER_ID, - agents: [], - workspaces: [], - emptyProjects: [], - timeline: { - agentId: "agent-1", - items: [], - cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 }, - hasOlder: false, - }, - }, - ], - }), - ); - const cache = new ReplicaCache(storage); - cache.setHosts([SERVER_ID]); + const replicaCache = cache(storage, { maxBytes: 1_000 }); + replicaCache.setHosts([SERVER_ID]); + seedTimeline(SERVER_ID, "oversized".repeat(500)); - await cache.restore(); - expect(storage.values.has("@paseo:replica-cache")).toBe(false); - await cache.flush(); + await replicaCache.flush(); + await replicaCache.flush(); + + expect(storage.changes).toEqual([]); + expect(storage.deletedHosts).toEqual([]); + }); + + it("lets a changed evicted host displace the oldest resident host", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage, { maxBytes: 7_000 }); + replicaCache.setHosts(LRU_SERVER_IDS.slice(0, 2)); + seedTimeline("host-a", "A".repeat(1_200)); + seedTimeline("host-b", "B".repeat(1_200)); + await replicaCache.flush(); + + seedTimeline("host-a", "A".repeat(1_201)); + await replicaCache.flush(); + replicaCache.setHosts(LRU_SERVER_IDS); + seedTimeline("host-c", "C".repeat(1_200)); + await replicaCache.flush(); + + seedTimeline("host-b", "B".repeat(1_201)); + await replicaCache.flush(); + + expect(new Set([...storage.rows.values()].map((row) => row.serverId))).toEqual( + new Set(["host-b", "host-c"]), + ); + }); + + it("persists an oversized host after its current input shrinks below the budget", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage, { maxBytes: 3_000 }); + replicaCache.setHosts([SERVER_ID]); + seedTimeline(SERVER_ID, "oversized".repeat(500)); + await replicaCache.flush(); + expect(storage.rows.size).toBe(0); + + seedTimeline(SERVER_ID, "small"); + await replicaCache.flush(); + + expect([...storage.rows.values()]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ serverId: SERVER_ID, kind: "timeline", id: "singleton" }), + ]), + ); + }); + + it("keeps the newest row after two ordered write failures", async () => { + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + storage.changes.length = 0; + const firstFailure = deferred(); + const secondFailure = deferred(); + storage.applyResults.push(firstFailure.promise, secondFailure.promise); + + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const current = agents.get("agent-1"); + if (!current) throw new Error("Expected seeded agent"); + return new Map(agents).set(current.id, { ...current, title: "v1" }); + }); + const firstWrite = replicaCache.flush(); + await waitForWrites(storage, 2); + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const current = agents.get("agent-1"); + if (!current) throw new Error("Expected seeded agent"); + return new Map(agents).set(current.id, { ...current, title: "v2" }); + }); + const secondWrite = replicaCache.flush(); + + firstFailure.reject(new Error("first failure")); + await waitForWrites(storage, 3); + secondFailure.reject(new Error("second failure")); + await Promise.all([firstWrite, secondWrite]); + await replicaCache.flush(); + + const persisted = [...storage.rows.values()].find( + (row) => row.serverId === SERVER_ID && row.kind === "agent" && row.id === "agent-1", + ); + expect(JSON.parse(persisted?.payload ?? "null").snapshot.title).toBe("v2"); + }); + + it("retries restored changes after a transient write failure", async () => { + vi.useFakeTimers(); + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + const failure = deferred(); + storage.applyResults.push(failure.promise); + + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const current = agents.get("agent-1"); + if (!current) throw new Error("Expected seeded agent"); + return new Map(agents).set(current.id, { ...current, title: "retried" }); + }); + const write = replicaCache.flush(); + await waitForWrites(storage, 2); + failure.reject(new Error("transient failure")); + await write; + + await vi.advanceTimersByTimeAsync(5_000); + await waitForWrites(storage, 3); + + const persisted = [...storage.rows.values()].find( + (row) => row.serverId === SERVER_ID && row.kind === "agent" && row.id === "agent-1", + ); + expect(JSON.parse(persisted?.payload ?? "null").snapshot.title).toBe("retried"); + }); + + it("does not restore a failed row write after its host was removed", async () => { + vi.useFakeTimers(); + const storage = new MemoryStorage(); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + seedSession(); + await replicaCache.flush(); + storage.changes.length = 0; + const failure = deferred(); + storage.applyResults.push(failure.promise); + + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const current = agents.get("agent-1"); + if (!current) throw new Error("Expected seeded agent"); + return new Map(agents).set(current.id, { ...current, title: "removed" }); + }); + const write = replicaCache.flush(); + await waitForWrites(storage, 2); + replicaCache.setHosts([]); + failure.reject(new Error("failed after removal")); + await write; + await vi.advanceTimersByTimeAsync(5_000); + await replicaCache.flush(); + + expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); + expect(storage.changes).toEqual([]); + }); + + it("clears the whole cache when one row payload is corrupt", async () => { + const storage = new MemoryStorage(); + storage.rows.set(`${SERVER_ID}:agent:agent-1`, { + serverId: SERVER_ID, + kind: "agent", + id: "agent-1", + payload: "{not json", + }); + storage.rows.set(`${SERVER_ID}:project:project-1`, { + serverId: SERVER_ID, + kind: "project", + id: "project-1", + payload: JSON.stringify({ projectId: "project-1" }), + }); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + + await replicaCache.restore(); expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); - expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ - version: 6, - hosts: [], + expect(storage.rows.size).toBe(0); + expect(storage.clears).toBe(1); + }); + + it("clears every host when one host contains a corrupt row", async () => { + const storage = new MemoryStorage(); + storage.rows.set(`${SERVER_ID}:agent:agent-1`, { + serverId: SERVER_ID, + kind: "agent", + id: "agent-1", + payload: "{bad", }); + storage.rows.set("second-host:checkpoint:singleton", { + serverId: "second-host", + kind: "checkpoint", + id: "singleton", + payload: JSON.stringify({ agents: { generation: "g", afterSeq: 1 } }), + }); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID, "second-host"]); + + await replicaCache.restore(); + + expect(storage.rows.size).toBe(0); + expect(storage.clears).toBe(1); + }); + + it("clears the whole cache when a valid JSON row has an unknown shape", async () => { + const storage = new MemoryStorage(); + storage.rows.set(`${SERVER_ID}:project:project-1`, { + serverId: SERVER_ID, + kind: "project", + id: "project-1", + payload: JSON.stringify({ projectId: "project-1", surprise: true }), + }); + const replicaCache = cache(storage); + replicaCache.setHosts([SERVER_ID]); + + await replicaCache.restore(); + + expect(storage.rows.size).toBe(0); + expect(storage.clears).toBe(1); + }); + + it("runs legacy blob cleanup when the row store is first prepared", async () => { + const storage = new MemoryStorage(); + let cleanups = 0; + const replicaCache = new ReplicaCache(storage, { + clearLegacyCache: async () => { + cleanups += 1; + }, + }); + replicaCache.setHosts([SERVER_ID]); + + await replicaCache.restore(); + await replicaCache.flush(); + + expect(cleanups).toBe(1); }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index ddbaef8484..faa5c25081 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,4 +1,5 @@ import { Buffer } from "buffer"; +import { Keyboard } from "react-native"; import { z } from "zod"; import { AgentStatusSchema, @@ -13,17 +14,34 @@ import { useSessionStore, type Agent, type SessionReplica, - type SessionReplicaTimeline, type SessionState, type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream"; +import { + isUnreconciledLocalUserMessage, + type AgentToolCallData, + type StreamItem, +} from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { + diffReplicaInput, + hasReplicaInputChanges, + type ReplicaInput, + type ReplicaInputChanges, +} from "./diff"; +import { clearLegacyReplicaCache } from "./legacy-cleanup"; +import { + REPLICA_SINGLETON_ROW_ID, + type ReplicaHostRows, + type ReplicaRow, + type ReplicaRowChanges, + type ReplicaRowKey, + type ReplicaRowKind, + type ReplicaRowStore, +} from "./row-store"; -const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 6; -const PERSIST_DELAY_MS = 750; +const PERSIST_AFTER_USER_INACTIVITY_MS = 5_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -284,35 +302,17 @@ const StoredHostSchema = z.strictObject({ directorySync: z.unknown().optional(), }); -const StoredCacheSchema = z.strictObject({ - version: z.literal(CACHE_VERSION), - hosts: z.array(StoredHostSchema), -}); - type StoredAgent = z.infer; type StoredHost = z.infer; +type StoredTimeline = z.infer; type StoredTimelineItem = z.infer; +type StoredToolCall = Extract["item"]; type StoredWorkspace = z.infer; type StoredProject = z.infer; -interface ReplicaInput { - agents: ReadonlyMap; - workspaces: ReadonlyMap; - projects: ReadonlyMap; - focusedAgent: Agent | undefined; - timelineItems: StreamItem[] | undefined; - timelineRange: SessionReplicaTimeline["range"]; - timelineHasOlder: boolean; -} - -export interface ReplicaCacheStorage { - getItem: (key: string) => Promise; - setItem: (key: string, value: string) => Promise; - removeItem: (key: string) => Promise; -} - interface ReplicaCacheOptions { maxBytes?: number; + clearLegacyCache?: () => Promise; } function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] { @@ -336,6 +336,24 @@ function timelineBase(item: StreamItem) { }; } +function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall { + const base = { + type: "tool_call" as const, + callId: data.callId, + name: data.name, + detail: data.detail, + ...(data.metadata ? { metadata: data.metadata } : {}), + }; + switch (data.status) { + case "running": + case "completed": + case "canceled": + return { ...base, status: data.status, error: null }; + case "failed": + return { ...base, status: data.status, error: data.error }; + } +} + function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { const base = timelineBase(item); switch (item.kind) { @@ -383,21 +401,11 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { }; case "tool_call": if (item.payload.source !== "agent") return null; - const storedTool = AgentTimelineItemPayloadSchema.safeParse({ - type: "tool_call", - callId: item.payload.data.callId, - name: item.payload.data.name, - status: item.payload.data.status, - error: item.payload.data.error, - detail: item.payload.data.detail, - ...(item.payload.data.metadata ? { metadata: item.payload.data.metadata } : {}), - }); - if (!storedTool.success || storedTool.data.type !== "tool_call") return null; return { ...base, kind: item.kind, provider: item.payload.data.provider, - item: storedTool.data, + item: serializeAgentToolCall(item.payload.data), }; } } @@ -614,18 +622,6 @@ function isTimelineItemStoredLosslessly(item: StreamItem): boolean { } } -function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean { - return ( - left.agents === right.agents && - left.workspaces === right.workspaces && - left.projects === right.projects && - left.focusedAgent === right.focusedAgent && - left.timelineItems === right.timelineItems && - left.timelineRange === right.timelineRange && - left.timelineHasOlder === right.timelineHasOlder - ); -} - function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput { const agent = agentId ? session.agents.get(agentId) : undefined; const timeline = agentId @@ -635,14 +631,14 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl agents: session.agents, workspaces: session.workspaces, projects: session.projects, - focusedAgent: agent, + focusedAgentId: agent?.id, timelineItems: timeline.status === "cold" ? undefined : timeline.items, timelineRange: timeline.status === "synced" ? timeline.range : null, timelineHasOlder: timeline.status === "synced" && timeline.older === "available", }; } -function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost { +function serializeTimeline(input: ReplicaInput): StoredTimeline | null { const canonicalItems = input.timelineItems?.filter( (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ); @@ -664,24 +660,14 @@ function serializeHost(serverId: string, input: ReplicaInput, directorySync?: un item.timelineCursor.seq <= range.endSeq, ) && canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); + if (!input.focusedAgentId || !items) return null; return { - serverId, - agents: Array.from(input.agents.values(), serializeAgent), - workspaces: Array.from(input.workspaces.values(), serializeWorkspace), - projects: Array.from(input.projects.values(), serializeProject), - emptyProjects: [], - timeline: - input.focusedAgent && items - ? { - agentId: input.focusedAgent.id, - items: items.slice(-MAX_TIMELINE_ITEMS), - range: canPersistCoverage - ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } - : null, - hasOlder: canPersistCoverage ? input.timelineHasOlder : false, - } - : null, - ...(directorySync ? { directorySync } : {}), + agentId: input.focusedAgentId, + items: items.slice(-MAX_TIMELINE_ITEMS), + range: canPersistCoverage + ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } + : null, + hasOlder: canPersistCoverage ? input.timelineHasOlder : false, }; } @@ -715,38 +701,66 @@ function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): P }; } +function rowKey(key: Pick): string { + return `${key.kind}\u0000${key.id}`; +} + +function pendingRowKey(key: ReplicaRowKey): string { + return `${key.serverId}\u0000${rowKey(key)}`; +} + +function payloadBytes(payload: string): number { + return Buffer.byteLength(payload, "utf8"); +} + +function parseJsonPayload(payload: string): unknown { + return JSON.parse(payload); +} + +function parseStoredPayload(schema: z.ZodType, payload: string): Value { + const parsed = schema.safeParse(parseJsonPayload(payload)); + if (!parsed.success) throw new Error("Invalid replica row payload"); + return parsed.data; +} + export class ReplicaCache { private readonly activeServerIds = new Set(); - private readonly storedHosts = new Map(); + private readonly storedRows = new Map>(); + private readonly hostBytes = new Map(); + private readonly hostWriteOrder = new Map(); + private readonly evictedHostBytes = new Map(); private readonly lastFocusedAgentIds = new Map(); private readonly capturedInputs = new Map(); + private readonly directoryCheckpoints = new Map(); + private readonly checkpointInputs = new Map(); + private pendingUpserts = new Map(); + private pendingDeletes = new Map(); private readonly maxBytes: number; - private needsPersist = false; + private totalBytes = 0; private unsubscribe: (() => void) | null = null; private persistTimer: ReturnType | null = null; private writeQueue: Promise = Promise.resolve(); + private preparePromise: Promise | null = null; constructor( - private readonly storage: ReplicaCacheStorage, + private readonly rowStore: ReplicaRowStore, options: ReplicaCacheOptions = {}, ) { - const emptyPayloadBytes = Buffer.byteLength( - JSON.stringify({ version: CACHE_VERSION, hosts: [] }), - "utf8", - ); - this.maxBytes = Math.max(options.maxBytes ?? MAX_CACHE_BYTES, emptyPayloadBytes); + this.maxBytes = Math.max(options.maxBytes ?? MAX_CACHE_BYTES, 0); + this.clearLegacyCache = options.clearLegacyCache ?? clearLegacyReplicaCache; } + private readonly clearLegacyCache: () => Promise; + setHosts(serverIds: Iterable): void { const next = new Set(serverIds); + const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId)); this.activeServerIds.clear(); for (const serverId of next) this.activeServerIds.add(serverId); - let removedStoredHost = false; - for (const serverId of this.storedHosts.keys()) { - if (!next.has(serverId)) { - this.storedHosts.delete(serverId); - removedStoredHost = true; - } + for (const serverId of removed) { + this.removeStoredHost(serverId); + this.dropPendingHostChanges(serverId); + this.queueOperation(() => this.rowStore.deleteHost(serverId)); } for (const serverId of this.lastFocusedAgentIds.keys()) { if (!next.has(serverId)) this.lastFocusedAgentIds.delete(serverId); @@ -754,46 +768,52 @@ export class ReplicaCache { for (const serverId of this.capturedInputs.keys()) { if (!next.has(serverId)) this.capturedInputs.delete(serverId); } - if (removedStoredHost) this.needsPersist = true; - if (this.unsubscribe && this.needsPersist) this.schedulePersist(); + for (const serverId of this.directoryCheckpoints.keys()) { + if (!next.has(serverId)) this.directoryCheckpoints.delete(serverId); + } + for (const serverId of this.checkpointInputs.keys()) { + if (!next.has(serverId)) this.checkpointInputs.delete(serverId); + } + for (const serverId of this.evictedHostBytes.keys()) { + if (!next.has(serverId)) this.evictedHostBytes.delete(serverId); + } } async restore(): Promise { - let raw: string | null; try { - raw = await this.storage.getItem(STORAGE_KEY); + await this.prepareStore(); } catch { return; } - if (!raw) return; - let parsed: unknown; + let hosts: ReplicaHostRows[]; try { - parsed = JSON.parse(raw); + hosts = await this.rowStore.readAll(); } catch { - await this.clearInvalidCache(); return; } - const cache = StoredCacheSchema.safeParse(parsed); - if (!cache.success) { - await this.clearInvalidCache(); - return; - } - for (const host of cache.data.hosts) { - if (!this.activeServerIds.has(host.serverId)) { - this.needsPersist = true; - continue; + this.storedRows.clear(); + this.hostBytes.clear(); + this.hostWriteOrder.clear(); + this.directoryCheckpoints.clear(); + this.checkpointInputs.clear(); + this.totalBytes = 0; + const restoredHosts: StoredHost[] = []; + try { + for (const hostRows of hosts) { + if (!this.activeServerIds.has(hostRows.serverId)) { + await this.rowStore.deleteHost(hostRows.serverId); + continue; + } + restoredHosts.push(this.restoreHostRows(hostRows)); } - this.storedHosts.set(host.serverId, host); - if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); - } - const bounded = this.buildBoundedPayload(); - if (!bounded) { + } catch { await this.clearInvalidCache(); - this.storedHosts.clear(); return; } - if (bounded.evicted) this.needsPersist = true; - for (const host of this.storedHosts.values()) { + await this.evictOverBudget(); + for (const host of restoredHosts) { + if (!this.storedRows.has(host.serverId)) continue; + if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); const session = useSessionStore.getState().sessions[host.serverId]; if (session) { @@ -805,26 +825,38 @@ export class ReplicaCache { } } + recordUserActivity(): void { + if (!this.persistTimer) return; + clearTimeout(this.persistTimer); + this.persistTimer = null; + this.schedulePersist(); + } + start(): void { if (this.unsubscribe) return; const changedBeforeSubscription = this.captureSessions(); - this.unsubscribe = useSessionStore.subscribe((state) => { + this.unsubscribe = useSessionStore.subscribe(() => { if (this.activeServerIds.size === 0) return; - let changed = false; - for (const serverId of this.activeServerIds) { - const session = state.sessions[serverId]; - if (session && this.captureHost(serverId, session)) changed = true; - } - if (changed) this.schedulePersist(); + this.schedulePersist(); }); - if (changedBeforeSubscription || this.needsPersist) this.schedulePersist(); + if (changedBeforeSubscription || this.hasPendingChanges()) this.schedulePersist(); } reconcileServerId(oldServerId: string, newServerId: string): void { - const stored = this.storedHosts.get(oldServerId); - if (stored) { - this.storedHosts.delete(oldServerId); - this.storedHosts.set(newServerId, { ...stored, serverId: newServerId }); + const rows = this.storedRows.get(oldServerId); + if (rows) { + const newRows = this.storedRows.get(newServerId) ?? new Map(); + this.totalBytes -= + (this.hostBytes.get(oldServerId) ?? 0) + (this.hostBytes.get(newServerId) ?? 0); + this.storedRows.delete(oldServerId); + for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId }); + this.storedRows.set(newServerId, newRows); + const bytes = [...newRows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0); + this.hostBytes.delete(oldServerId); + this.hostBytes.set(newServerId, bytes); + this.totalBytes += bytes; + this.hostWriteOrder.delete(oldServerId); + this.touchHost(newServerId); } const focusedAgentId = this.lastFocusedAgentIds.get(oldServerId); if (focusedAgentId) { @@ -836,46 +868,97 @@ export class ReplicaCache { this.capturedInputs.delete(oldServerId); this.capturedInputs.set(newServerId, capturedInput); } + if (this.directoryCheckpoints.has(oldServerId)) { + const checkpoint = this.directoryCheckpoints.get(oldServerId); + this.directoryCheckpoints.delete(oldServerId); + this.directoryCheckpoints.set(newServerId, checkpoint); + } + if (this.checkpointInputs.has(oldServerId)) { + const checkpoint = this.checkpointInputs.get(oldServerId); + this.checkpointInputs.delete(oldServerId); + this.checkpointInputs.set(newServerId, checkpoint); + } + const evictedBytes = this.evictedHostBytes.get(oldServerId); + if (evictedBytes !== undefined) { + this.evictedHostBytes.delete(oldServerId); + this.evictedHostBytes.set(newServerId, evictedBytes); + } + this.renamePendingHostChanges(oldServerId, newServerId); if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); - this.needsPersist = true; - this.schedulePersist(); + this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId)); } readDirectoryCheckpoint(serverId: string): unknown { - return this.storedHosts.get(serverId)?.directorySync; + return this.directoryCheckpoints.get(serverId); } writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void { - let stored = this.storedHosts.get(serverId); - if (!stored) { + if (this.checkpointInputs.get(serverId) === checkpoint) return; + this.checkpointInputs.set(serverId, checkpoint); + this.directoryCheckpoints.set(serverId, checkpoint); + if (this.evictedHostBytes.has(serverId)) { const session = useSessionStore.getState().sessions[serverId]; if (!session) return; - const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null); - this.capturedInputs.set(serverId, input); - stored = serializeHost(serverId, input); + this.captureHost(serverId, session, true); + } + const payload = JSON.stringify(checkpoint); + if (payload === undefined) { + this.queueDelete({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + }); + } else { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload, + }); } - this.storedHosts.delete(serverId); - this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint }); this.schedulePersist(); } async flush(): Promise { + await this.persist(false); + await this.writeQueue.catch(() => undefined); + } + + private async flushPending(): Promise { + if (Keyboard.isVisible()) { + this.schedulePersist(); + return; + } + await this.persist(true); + } + + private async persist(skipUnchanged: boolean): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } - this.captureSessions(); - const bounded = this.buildBoundedPayload(); - this.needsPersist = false; - if (!bounded) { - await this.clearInvalidCache(); - return; - } + const changed = this.captureSessions(); + if (skipUnchanged && !changed && !this.hasPendingChanges()) return; const write = this.writeQueue .catch(() => undefined) - .then(() => this.storage.setItem(STORAGE_KEY, bounded.payload)); + .then(async () => { + const changes = this.drainPendingChanges(); + if (changes.upserts.length === 0 && changes.deletes.length === 0) return; + try { + await this.prepareStore(); + const boundedChanges = await this.fitChangesToBudget(changes); + if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { + await this.rowStore.apply(boundedChanges); + this.applyStoredChanges(boundedChanges); + } + } catch { + this.restorePendingChanges(changes); + if (this.hasPendingChanges()) this.schedulePersist(); + } + return undefined; + }); this.writeQueue = write; - await write.catch(() => undefined); + await write; } private captureSessions(): boolean { @@ -889,7 +972,7 @@ export class ReplicaCache { return changed; } - private captureHost(serverId: string, session: SessionState): boolean { + private captureHost(serverId: string, session: SessionState, force = false): boolean { if (session.focusedAgentId) { this.lastFocusedAgentIds.set(serverId, session.focusedAgentId); } @@ -908,51 +991,341 @@ export class ReplicaCache { } else if (hasTimelineHead) { input = { ...selected, timelineRange: null, timelineHasOlder: false }; } - if (previous && replicaInputsEqual(previous, input)) return false; - + const changes = diffReplicaInput(previous, input); this.capturedInputs.set(serverId, input); - const directorySync = this.storedHosts.get(serverId)?.directorySync; - this.storedHosts.delete(serverId); - this.storedHosts.set(serverId, serializeHost(serverId, input, directorySync)); + const isEvicted = this.evictedHostBytes.has(serverId); + if (!hasReplicaInputChanges(changes) && !(force && isEvicted)) return false; + const persistedChanges = isEvicted ? diffReplicaInput(undefined, input) : changes; + this.queueReplicaChanges(serverId, input, persistedChanges); return true; } - private buildBoundedPayload(): { payload: string; evicted: boolean } | null { - let evicted = false; - let payload = this.serialize(); - if (payload === null) return null; - while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) { - const oldestServerId = this.storedHosts.keys().next().value; - if (oldestServerId === undefined) break; - this.storedHosts.delete(oldestServerId); - evicted = true; - payload = this.serialize(); - if (payload === null) return null; + private queueReplicaChanges( + serverId: string, + input: ReplicaInput, + changes: ReplicaInputChanges, + ): void { + for (const agent of changes.agents.upserts) { + this.queueEntityUpsert(serverId, "agent", agent.id, serializeAgent(agent)); + } + for (const id of changes.agents.deletes) this.queueEntityDelete(serverId, "agent", id); + for (const workspace of changes.workspaces.upserts) { + this.queueEntityUpsert(serverId, "workspace", workspace.id, serializeWorkspace(workspace)); + } + for (const id of changes.workspaces.deletes) this.queueEntityDelete(serverId, "workspace", id); + for (const project of changes.projects.upserts) { + this.queueEntityUpsert(serverId, "project", project.projectId, serializeProject(project)); + } + for (const id of changes.projects.deletes) this.queueEntityDelete(serverId, "project", id); + if (!changes.timelineChanged) return; + const timeline = serializeTimeline(input); + if (timeline) { + this.queueEntityUpsert(serverId, "timeline", REPLICA_SINGLETON_ROW_ID, timeline); + } else { + this.queueEntityDelete(serverId, "timeline", REPLICA_SINGLETON_ROW_ID); } - return { payload, evicted }; } - private serialize(): string | null { - const cache = StoredCacheSchema.safeParse({ - version: CACHE_VERSION, - hosts: Array.from(this.storedHosts.values()), - }); - return cache.success ? JSON.stringify(cache.data) : null; + private queueEntityUpsert( + serverId: string, + kind: ReplicaRowKind, + id: string, + value: unknown, + ): void { + this.queueUpsert({ serverId, kind, id, payload: JSON.stringify(value) }); } private async clearInvalidCache(): Promise { try { - await this.storage.removeItem(STORAGE_KEY); + await this.rowStore.clear(); } catch { // A storage failure must not turn invalid cached data into application state. } + this.storedRows.clear(); + this.hostBytes.clear(); + this.hostWriteOrder.clear(); + this.evictedHostBytes.clear(); + this.directoryCheckpoints.clear(); + this.checkpointInputs.clear(); + this.capturedInputs.clear(); + this.lastFocusedAgentIds.clear(); + this.pendingUpserts.clear(); + this.pendingDeletes.clear(); + this.totalBytes = 0; + } + + private restoreHostRows(hostRows: ReplicaHostRows): StoredHost { + const agents: StoredAgent[] = []; + const workspaces: StoredWorkspace[] = []; + const projects: StoredProject[] = []; + let timeline: StoredTimeline | null = null; + let checkpoint: unknown; + const rows = new Map(); + let bytes = 0; + for (const row of hostRows.rows) { + if (row.serverId !== hostRows.serverId) throw new Error("Replica row host key mismatch"); + switch (row.kind) { + case "agent": { + const agent = parseStoredPayload(StoredAgentSchema, row.payload); + if (agent.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); + agents.push(agent); + break; + } + case "workspace": { + const workspace = parseStoredPayload(StoredWorkspaceSchema, row.payload); + if (workspace.id !== row.id) throw new Error("Replica workspace row id mismatch"); + workspaces.push(workspace); + break; + } + case "project": { + const project = parseStoredPayload(StoredProjectSchema, row.payload); + if (project.projectId !== row.id) throw new Error("Replica project row id mismatch"); + projects.push(project); + break; + } + case "timeline": + if (row.id !== REPLICA_SINGLETON_ROW_ID) { + throw new Error("Replica timeline row id mismatch"); + } + timeline = parseStoredPayload(StoredTimelineSchema, row.payload); + break; + case "checkpoint": + if (row.id !== REPLICA_SINGLETON_ROW_ID) { + throw new Error("Replica checkpoint row id mismatch"); + } + checkpoint = parseJsonPayload(row.payload); + break; + default: + throw new Error("Unknown replica row kind"); + } + rows.set(rowKey(row), row); + bytes += payloadBytes(row.payload); + } + this.storedRows.set(hostRows.serverId, rows); + this.hostBytes.set(hostRows.serverId, bytes); + this.totalBytes += bytes; + this.touchHost(hostRows.serverId); + if (checkpoint !== undefined) { + this.directoryCheckpoints.set(hostRows.serverId, checkpoint); + this.checkpointInputs.set(hostRows.serverId, checkpoint); + } + return { + serverId: hostRows.serverId, + agents, + workspaces, + projects, + emptyProjects: [], + timeline, + ...(checkpoint !== undefined ? { directorySync: checkpoint } : {}), + }; + } + + private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { + this.queueDelete({ serverId, kind, id }); + } + + private queueUpsert(row: ReplicaRow): void { + const key = pendingRowKey(row); + this.pendingDeletes.delete(key); + this.pendingUpserts.set(key, row); + } + + private queueDelete(key: ReplicaRowKey): void { + const pendingKey = pendingRowKey(key); + this.pendingUpserts.delete(pendingKey); + this.pendingDeletes.set(pendingKey, key); + } + + private hasPendingChanges(): boolean { + return this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0; + } + + private drainPendingChanges(): ReplicaRowChanges { + const changes = { + upserts: [...this.pendingUpserts.values()], + deletes: [...this.pendingDeletes.values()], + }; + this.pendingUpserts = new Map(); + this.pendingDeletes = new Map(); + return changes; + } + + private restorePendingChanges(changes: ReplicaRowChanges): void { + for (const key of changes.deletes) { + const pendingKey = pendingRowKey(key); + if ( + this.activeServerIds.has(key.serverId) && + !this.pendingUpserts.has(pendingKey) && + !this.pendingDeletes.has(pendingKey) + ) { + this.queueDelete(key); + } + } + for (const row of changes.upserts) { + const pendingKey = pendingRowKey(row); + if ( + this.activeServerIds.has(row.serverId) && + !this.pendingUpserts.has(pendingKey) && + !this.pendingDeletes.has(pendingKey) + ) { + this.queueUpsert(row); + } + } + } + + private applyStoredChanges(changes: ReplicaRowChanges): void { + const touchedServerIds = new Set(); + for (const key of changes.deletes) { + const rows = this.storedRows.get(key.serverId); + const previous = rows?.get(rowKey(key)); + if (previous) { + rows?.delete(rowKey(key)); + this.adjustHostBytes(key.serverId, -payloadBytes(previous.payload)); + } + touchedServerIds.add(key.serverId); + } + for (const row of changes.upserts) { + const rows = this.storedRows.get(row.serverId) ?? new Map(); + const previous = rows.get(rowKey(row)); + const previousBytes = previous ? payloadBytes(previous.payload) : 0; + rows.set(rowKey(row), row); + this.storedRows.set(row.serverId, rows); + this.adjustHostBytes(row.serverId, payloadBytes(row.payload) - previousBytes); + this.evictedHostBytes.delete(row.serverId); + touchedServerIds.add(row.serverId); + } + for (const serverId of touchedServerIds) { + if ((this.storedRows.get(serverId)?.size ?? 0) === 0) { + this.removeStoredHost(serverId); + } else { + this.touchHost(serverId); + } + } + } + + private adjustHostBytes(serverId: string, delta: number): void { + this.hostBytes.set(serverId, (this.hostBytes.get(serverId) ?? 0) + delta); + this.totalBytes += delta; + } + + private touchHost(serverId: string): void { + this.hostWriteOrder.delete(serverId); + this.hostWriteOrder.set(serverId, true); + } + + private async evictOverBudget(): Promise { + while (this.totalBytes > this.maxBytes) { + const oldestServerId = this.hostWriteOrder.keys().next().value; + if (oldestServerId === undefined) return; + const bytes = this.hostBytes.get(oldestServerId) ?? 0; + await this.rowStore.deleteHost(oldestServerId); + this.removeStoredHost(oldestServerId); + this.evictedHostBytes.set(oldestServerId, bytes); + this.directoryCheckpoints.delete(oldestServerId); + this.checkpointInputs.delete(oldestServerId); + } + } + + private async fitChangesToBudget(changes: ReplicaRowChanges): Promise { + const touchedServerIds = new Set(); + for (const key of changes.deletes) touchedServerIds.add(key.serverId); + for (const row of changes.upserts) touchedServerIds.add(row.serverId); + + const projectedRows = new Map>(); + const projectedBytes = new Map(this.hostBytes); + for (const serverId of touchedServerIds) { + projectedRows.set(serverId, new Map(this.storedRows.get(serverId))); + } + for (const key of changes.deletes) { + projectedRows.get(key.serverId)?.delete(rowKey(key)); + } + for (const row of changes.upserts) { + projectedRows.get(row.serverId)?.set(rowKey(row), row); + } + for (const [serverId, rows] of projectedRows) { + projectedBytes.set( + serverId, + [...rows.values()].reduce((sum, row) => sum + payloadBytes(row.payload), 0), + ); + } + + const writeOrder = [...this.hostWriteOrder.keys()].filter( + (serverId) => !touchedServerIds.has(serverId), + ); + writeOrder.push(...touchedServerIds); + let projectedTotal = [...projectedBytes.values()].reduce((sum, bytes) => sum + bytes, 0); + const evicted = new Set(); + while (projectedTotal > this.maxBytes) { + const serverId = writeOrder.shift(); + if (serverId === undefined) break; + const bytes = projectedBytes.get(serverId) ?? 0; + projectedTotal -= bytes; + projectedBytes.delete(serverId); + evicted.add(serverId); + this.evictedHostBytes.set(serverId, bytes); + if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); + this.removeStoredHost(serverId); + this.directoryCheckpoints.delete(serverId); + this.checkpointInputs.delete(serverId); + } + + return { + upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), + deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + }; + } + + private removeStoredHost(serverId: string): void { + this.totalBytes -= this.hostBytes.get(serverId) ?? 0; + this.storedRows.delete(serverId); + this.hostBytes.delete(serverId); + this.hostWriteOrder.delete(serverId); + } + + private dropPendingHostChanges(serverId: string): void { + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId) this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId) this.pendingDeletes.delete(key); + } + } + + private renamePendingHostChanges(oldServerId: string, newServerId: string): void { + const changes = this.drainPendingChanges(); + for (const key of changes.deletes) { + this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); + } + for (const row of changes.upserts) { + this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); + } + } + + private prepareStore(): Promise { + this.preparePromise ??= (async () => { + await this.rowStore.open(); + // COMPAT(replica-blob-cache): remove after 2026-11 + await this.clearLegacyCache().catch(() => undefined); + })(); + return this.preparePromise; + } + + private queueOperation(operation: () => Promise): void { + this.writeQueue = this.writeQueue + .catch(() => undefined) + .then(async () => { + await this.prepareStore(); + await operation(); + return undefined; + }) + .catch(() => undefined); } private schedulePersist(): void { if (this.persistTimer) return; this.persistTimer = setTimeout(() => { this.persistTimer = null; - void this.flush(); - }, PERSIST_DELAY_MS); + void this.flushPending(); + }, PERSIST_AFTER_USER_INACTIVITY_MS); } } diff --git a/packages/app/src/runtime/replica-cache/legacy-cleanup.ts b/packages/app/src/runtime/replica-cache/legacy-cleanup.ts new file mode 100644 index 0000000000..2428b637ca --- /dev/null +++ b/packages/app/src/runtime/replica-cache/legacy-cleanup.ts @@ -0,0 +1,7 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const LEGACY_STORAGE_KEY = "@paseo:replica-cache"; + +export async function clearLegacyReplicaCache(): Promise { + await AsyncStorage.removeItem(LEGACY_STORAGE_KEY); +} diff --git a/packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts b/packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts new file mode 100644 index 0000000000..5adfbc6081 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts @@ -0,0 +1,48 @@ +import { indexedDB as fakeIndexedDb } from "fake-indexeddb"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { clearLegacyReplicaCache } from "./legacy-cleanup.web"; + +const LEGACY_DATABASE_NAME = "paseo-replica-cache"; + +beforeAll(() => vi.stubGlobal("indexedDB", fakeIndexedDb)); +afterAll(() => vi.unstubAllGlobals()); + +function openLegacyDatabase(): Promise<{ database: IDBDatabase; oldVersion: number }> { + return new Promise((resolve, reject) => { + const request = indexedDB.open(LEGACY_DATABASE_NAME, 1); + let oldVersion = 1; + request.addEventListener("upgradeneeded", (event) => { + oldVersion = event.oldVersion; + request.result.createObjectStore("key-value"); + }); + request.addEventListener("success", () => resolve({ database: request.result, oldVersion })); + request.addEventListener("error", () => reject(request.error)); + }); +} + +describe("clearLegacyReplicaCache", () => { + it("deletes the old blob database", async () => { + const seeded = await openLegacyDatabase(); + seeded.database.close(); + + await clearLegacyReplicaCache(); + + const reopened = await openLegacyDatabase(); + expect(reopened.oldVersion).toBe(0); + reopened.database.close(); + await clearLegacyReplicaCache(); + }); + + it("reports a blocked deletion so a later launch can retry", async () => { + const open = await openLegacyDatabase(); + + await expect(clearLegacyReplicaCache()).rejects.toThrow("blocked"); + open.database.close(); + + await clearLegacyReplicaCache(); + const reopened = await openLegacyDatabase(); + expect(reopened.oldVersion).toBe(0); + reopened.database.close(); + await clearLegacyReplicaCache(); + }); +}); diff --git a/packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts b/packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts new file mode 100644 index 0000000000..fc8a9e4801 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts @@ -0,0 +1,14 @@ +const LEGACY_DATABASE_NAME = "paseo-replica-cache"; + +export function clearLegacyReplicaCache(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(LEGACY_DATABASE_NAME); + request.addEventListener("success", () => resolve()); + request.addEventListener("error", () => + reject(request.error ?? new Error("Failed to delete legacy replica cache")), + ); + request.addEventListener("blocked", () => + reject(new Error("Legacy replica cache deletion was blocked")), + ); + }); +} diff --git a/packages/app/src/runtime/replica-cache/row-store-factory.native.ts b/packages/app/src/runtime/replica-cache/row-store-factory.native.ts new file mode 100644 index 0000000000..d1c8fafad3 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store-factory.native.ts @@ -0,0 +1 @@ +export { createReplicaRowStore } from "./row-store.native"; diff --git a/packages/app/src/runtime/replica-cache/row-store-factory.ts b/packages/app/src/runtime/replica-cache/row-store-factory.ts new file mode 100644 index 0000000000..42c86fdb72 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store-factory.ts @@ -0,0 +1 @@ +export { createReplicaRowStore } from "./row-store.web"; diff --git a/packages/app/src/runtime/replica-cache/row-store-factory.web.ts b/packages/app/src/runtime/replica-cache/row-store-factory.web.ts new file mode 100644 index 0000000000..42c86fdb72 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store-factory.web.ts @@ -0,0 +1 @@ +export { createReplicaRowStore } from "./row-store.web"; diff --git a/packages/app/src/runtime/replica-cache/row-store-schema.ts b/packages/app/src/runtime/replica-cache/row-store-schema.ts new file mode 100644 index 0000000000..7bd7e8d197 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store-schema.ts @@ -0,0 +1,3 @@ +export const REPLICA_ROW_STORE_SCHEMA_VERSION = 1; + +export const REPLICA_SINGLETON_ROW_ID = "singleton"; diff --git a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts new file mode 100644 index 0000000000..deedc2c912 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts @@ -0,0 +1,151 @@ +import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; + +export type SqliteValue = string | number | null; + +export interface ReplicaSqliteConnection { + exec(sql: string): Promise; + run(sql: string, params?: readonly SqliteValue[]): Promise; + all(sql: string, params?: readonly SqliteValue[]): Promise; + transaction(operation: (connection: ReplicaSqliteConnection) => Promise): Promise; +} + +export interface ReplicaSqliteDriver { + open(): Promise; +} + +const CREATE_ROWS_SQL = ` + CREATE TABLE IF NOT EXISTS rows ( + server_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('agent', 'workspace', 'project', 'timeline', 'checkpoint')), + id TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (server_id, kind, id) + ) +`; + +const CREATE_META_SQL = ` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ) +`; + +interface StoredMeta { + value: string; +} + +interface StoredRow { + server_id: string; + kind: ReplicaRow["kind"]; + id: string; + payload: string; +} + +export function createSqliteReplicaRowStore( + driver: ReplicaSqliteDriver, + schemaVersion: number, +): ReplicaRowStore { + let connection: ReplicaSqliteConnection | null = null; + let opening: Promise | null = null; + + function getConnection(): ReplicaSqliteConnection { + if (!connection) { + throw new Error("Replica row store has not been opened"); + } + return connection; + } + + async function open(): Promise { + opening ??= (async () => { + const openedConnection = await driver.open(); + await openedConnection.exec(CREATE_META_SQL); + const storedMeta = await openedConnection.all( + "SELECT value FROM meta WHERE key = ?", + ["schema_version"], + ); + + if (storedMeta[0]?.value !== String(schemaVersion)) { + await openedConnection.transaction(async (transaction) => { + await transaction.exec("DROP TABLE IF EXISTS rows"); + await transaction.exec(CREATE_ROWS_SQL); + await transaction.run("DELETE FROM meta"); + await transaction.run("INSERT INTO meta (key, value) VALUES (?, ?)", [ + "schema_version", + String(schemaVersion), + ]); + }); + } else { + await openedConnection.exec(CREATE_ROWS_SQL); + } + + connection = openedConnection; + })(); + + try { + await opening; + } catch (error) { + opening = null; + throw error; + } + } + + async function readAll(): Promise { + const storedRows = await getConnection().all( + "SELECT server_id, kind, id, payload FROM rows ORDER BY server_id, kind, id", + ); + const hosts = new Map(); + for (const storedRow of storedRows) { + const rows = hosts.get(storedRow.server_id) ?? []; + rows.push({ + serverId: storedRow.server_id, + kind: storedRow.kind, + id: storedRow.id, + payload: storedRow.payload, + }); + hosts.set(storedRow.server_id, rows); + } + return Array.from(hosts, ([serverId, rows]) => ({ serverId, rows })); + } + + async function apply(changes: ReplicaRowChanges): Promise { + await getConnection().transaction(async (transaction) => { + for (const key of changes.deletes) { + await transaction.run("DELETE FROM rows WHERE server_id = ? AND kind = ? AND id = ?", [ + key.serverId, + key.kind, + key.id, + ]); + } + for (const row of changes.upserts) { + await transaction.run( + `INSERT INTO rows (server_id, kind, id, payload) VALUES (?, ?, ?, ?) + ON CONFLICT (server_id, kind, id) DO UPDATE SET payload = excluded.payload`, + [row.serverId, row.kind, row.id, row.payload], + ); + } + }); + } + + async function deleteHost(serverId: string): Promise { + await getConnection().run("DELETE FROM rows WHERE server_id = ?", [serverId]); + } + + async function renameHost(oldServerId: string, newServerId: string): Promise { + if (oldServerId === newServerId) return; + await getConnection().transaction(async (transaction) => { + await transaction.run( + `INSERT INTO rows (server_id, kind, id, payload) + SELECT ?, kind, id, payload FROM rows WHERE server_id = ? + ON CONFLICT (server_id, kind, id) DO UPDATE SET payload = excluded.payload`, + [newServerId, oldServerId], + ); + await transaction.run("DELETE FROM rows WHERE server_id = ?", [oldServerId]); + }); + } + + async function clear(): Promise { + await getConnection().run("DELETE FROM rows"); + } + + return { open, readAll, apply, deleteHost, renameHost, clear }; +} diff --git a/packages/app/src/runtime/replica-cache/row-store.contract.ts b/packages/app/src/runtime/replica-cache/row-store.contract.ts new file mode 100644 index 0000000000..ed8e57c766 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store.contract.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ReplicaRow, ReplicaRowStore } from "./row-store"; + +export interface ReplicaRowStoreHarness { + store: ReplicaRowStore; + openWithSchemaVersion(schemaVersion: number): Promise; +} + +export type CreateReplicaRowStoreHarness = () => Promise; + +function row(serverId: string, kind: ReplicaRow["kind"], id: string, payload: string): ReplicaRow { + return { serverId, kind, id, payload }; +} + +export function runReplicaRowStoreContract( + engineName: string, + createHarness: CreateReplicaRowStoreHarness, +): void { + describe(`${engineName} replica row store`, () => { + let harness: ReplicaRowStoreHarness; + let store: ReplicaRowStore; + + beforeEach(async () => { + harness = await createHarness(); + store = harness.store; + await store.open(); + }); + + it("reads an empty store", async () => { + expect(await store.readAll()).toEqual([]); + }); + + it("round-trips opaque payloads grouped by server", async () => { + await store.apply({ + upserts: [ + row("server-b", "checkpoint", "singleton", '{"cursor":2}'), + row("server-a", "agent", "agent-1", '{"status":"running"}'), + row("server-a", "timeline", "singleton", "not parsed by the row store"), + ], + deletes: [], + }); + + expect(await store.readAll()).toEqual([ + { + serverId: "server-a", + rows: [ + row("server-a", "agent", "agent-1", '{"status":"running"}'), + row("server-a", "timeline", "singleton", "not parsed by the row store"), + ], + }, + { + serverId: "server-b", + rows: [row("server-b", "checkpoint", "singleton", '{"cursor":2}')], + }, + ]); + }); + + it("overwrites an existing row on upsert", async () => { + await store.apply({ + upserts: [row("server-a", "agent", "agent-1", "old")], + deletes: [], + }); + await store.apply({ + upserts: [row("server-a", "agent", "agent-1", "new")], + deletes: [], + }); + + expect(await store.readAll()).toEqual([ + { + serverId: "server-a", + rows: [row("server-a", "agent", "agent-1", "new")], + }, + ]); + }); + + it("deletes one row by its compound identity", async () => { + await store.apply({ + upserts: [ + row("server-a", "agent", "same-id", "agent"), + row("server-a", "workspace", "same-id", "workspace"), + ], + deletes: [], + }); + await store.apply({ + upserts: [], + deletes: [{ serverId: "server-a", kind: "agent", id: "same-id" }], + }); + + expect(await store.readAll()).toEqual([ + { + serverId: "server-a", + rows: [row("server-a", "workspace", "same-id", "workspace")], + }, + ]); + }); + + it("rolls back the entire apply when one write fails", async () => { + const invalidRow = { + ...row("server-a", "agent", "invalid", "invalid"), + serverId: undefined, + } as unknown as ReplicaRow; + + await expect( + store.apply({ + upserts: [row("server-a", "agent", "would-have-been-written", "payload"), invalidRow], + deletes: [], + }), + ).rejects.toBeInstanceOf(Error); + expect(await store.readAll()).toEqual([]); + }); + + it("deletes every row belonging to one host", async () => { + await store.apply({ + upserts: [ + row("server-a", "agent", "agent-1", "a"), + row("server-a", "project", "project-1", "p"), + row("server-b", "agent", "agent-2", "b"), + ], + deletes: [], + }); + + await store.deleteHost("server-a"); + + expect(await store.readAll()).toEqual([ + { serverId: "server-b", rows: [row("server-b", "agent", "agent-2", "b")] }, + ]); + }); + + it("renames a host and lets moved rows replace target collisions", async () => { + await store.apply({ + upserts: [ + row("temporary", "agent", "agent-1", "temporary-agent"), + row("temporary", "project", "project-1", "temporary-project"), + row("canonical", "agent", "agent-1", "canonical-agent"), + row("canonical", "workspace", "workspace-1", "canonical-workspace"), + ], + deletes: [], + }); + + await store.renameHost("temporary", "canonical"); + + expect(await store.readAll()).toEqual([ + { + serverId: "canonical", + rows: [ + row("canonical", "agent", "agent-1", "temporary-agent"), + row("canonical", "project", "project-1", "temporary-project"), + row("canonical", "workspace", "workspace-1", "canonical-workspace"), + ], + }, + ]); + }); + + it("clears all cached rows", async () => { + await store.apply({ + upserts: [ + row("server-a", "agent", "agent-1", "a"), + row("server-b", "agent", "agent-2", "b"), + ], + deletes: [], + }); + + await store.clear(); + + expect(await store.readAll()).toEqual([]); + }); + + it("wipes cached rows when the schema version changes on open", async () => { + await store.apply({ + upserts: [row("server-a", "agent", "agent-1", "stale")], + deletes: [], + }); + + const upgradedStore = await harness.openWithSchemaVersion(2); + + expect(await upgradedStore.readAll()).toEqual([]); + }); + }); +} diff --git a/packages/app/src/runtime/replica-cache/row-store.native.test.ts b/packages/app/src/runtime/replica-cache/row-store.native.test.ts new file mode 100644 index 0000000000..8041c05002 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store.native.test.ts @@ -0,0 +1,54 @@ +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; +import { runReplicaRowStoreContract } from "./row-store.contract"; +import { + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type ReplicaSqliteDriver, + type SqliteValue, +} from "./row-store-sqlite"; + +function createNodeSqliteDriver(): ReplicaSqliteDriver { + const database = new DatabaseSync(":memory:"); + + function connection(): ReplicaSqliteConnection { + return { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...(params as SQLInputValue[])); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + return database.prepare(sql).all(...(params as SQLInputValue[])) as Row[]; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection()); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + } + + return { + async open() { + return connection(); + }, + }; +} + +runReplicaRowStoreContract("SQLite", async () => { + const driver = createNodeSqliteDriver(); + return { + store: createSqliteReplicaRowStore(driver, 1), + async openWithSchemaVersion(schemaVersion) { + const store = createSqliteReplicaRowStore(driver, schemaVersion); + await store.open(); + return store; + }, + }; +}); diff --git a/packages/app/src/runtime/replica-cache/row-store.native.ts b/packages/app/src/runtime/replica-cache/row-store.native.ts new file mode 100644 index 0000000000..bb91eb0aa6 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store.native.ts @@ -0,0 +1,49 @@ +import { openDatabaseAsync, type SQLiteDatabase } from "expo-sqlite"; +import type { ReplicaRowStore } from "./row-store"; +import { REPLICA_ROW_STORE_SCHEMA_VERSION } from "./row-store-schema"; +import { + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type ReplicaSqliteDriver, + type SqliteValue, +} from "./row-store-sqlite"; + +export type { + ReplicaHostRows, + ReplicaRow, + ReplicaRowChanges, + ReplicaRowKey, + ReplicaRowKind, + ReplicaRowStore, +} from "./row-store"; +export { REPLICA_ROW_STORE_SCHEMA_VERSION, REPLICA_SINGLETON_ROW_ID } from "./row-store-schema"; + +const DATABASE_NAME = "paseo-replica-row-store.db"; + +function bind(params: readonly SqliteValue[]): SqliteValue[] { + return [...params]; +} + +function createConnection(database: SQLiteDatabase): ReplicaSqliteConnection { + return { + exec: (sql) => database.execAsync(sql), + run: async (sql, params = []) => { + await database.runAsync(sql, bind(params)); + }, + all: (sql, params = []) => database.getAllAsync(sql, bind(params)), + transaction: (operation) => + database.withExclusiveTransactionAsync(async (transaction) => { + await operation(createConnection(transaction)); + }), + }; +} + +const expoSqliteDriver: ReplicaSqliteDriver = { + async open() { + return createConnection(await openDatabaseAsync(DATABASE_NAME)); + }, +}; + +export function createReplicaRowStore(): ReplicaRowStore { + return createSqliteReplicaRowStore(expoSqliteDriver, REPLICA_ROW_STORE_SCHEMA_VERSION); +} diff --git a/packages/app/src/runtime/replica-cache/row-store.ts b/packages/app/src/runtime/replica-cache/row-store.ts new file mode 100644 index 0000000000..7600f0c368 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store.ts @@ -0,0 +1,32 @@ +export { REPLICA_ROW_STORE_SCHEMA_VERSION, REPLICA_SINGLETON_ROW_ID } from "./row-store-schema"; + +export type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint"; + +export interface ReplicaRowKey { + serverId: string; + kind: ReplicaRowKind; + id: string; +} + +export interface ReplicaRow extends ReplicaRowKey { + payload: string; +} + +export interface ReplicaRowChanges { + upserts: ReplicaRow[]; + deletes: ReplicaRowKey[]; +} + +export interface ReplicaHostRows { + serverId: string; + rows: ReplicaRow[]; +} + +export interface ReplicaRowStore { + open(): Promise; + readAll(): Promise; + apply(changes: ReplicaRowChanges): Promise; + deleteHost(serverId: string): Promise; + renameHost(oldServerId: string, newServerId: string): Promise; + clear(): Promise; +} diff --git a/packages/app/src/runtime/replica-cache/row-store.web.test.ts b/packages/app/src/runtime/replica-cache/row-store.web.test.ts new file mode 100644 index 0000000000..ef91a322cb --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store.web.test.ts @@ -0,0 +1,20 @@ +import { IDBKeyRange as FakeIDBKeyRange, indexedDB as fakeIndexedDb } from "fake-indexeddb"; +import { runReplicaRowStoreContract } from "./row-store.contract"; +import { createIndexedDbReplicaRowStore } from "./row-store.web"; + +globalThis.indexedDB = fakeIndexedDb; +globalThis.IDBKeyRange = FakeIDBKeyRange; + +let databaseSequence = 0; + +runReplicaRowStoreContract("IndexedDB", async () => { + const databaseName = `replica-row-store-test-${databaseSequence++}`; + return { + store: createIndexedDbReplicaRowStore({ databaseName, schemaVersion: 1 }), + async openWithSchemaVersion(schemaVersion) { + const store = createIndexedDbReplicaRowStore({ databaseName, schemaVersion }); + await store.open(); + return store; + }, + }; +}); diff --git a/packages/app/src/runtime/replica-cache/row-store.web.ts b/packages/app/src/runtime/replica-cache/row-store.web.ts new file mode 100644 index 0000000000..c740283706 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/row-store.web.ts @@ -0,0 +1,186 @@ +import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; +import { REPLICA_ROW_STORE_SCHEMA_VERSION } from "./row-store-schema"; + +export type { + ReplicaHostRows, + ReplicaRow, + ReplicaRowChanges, + ReplicaRowKey, + ReplicaRowKind, + ReplicaRowStore, +} from "./row-store"; +export { REPLICA_ROW_STORE_SCHEMA_VERSION, REPLICA_SINGLETON_ROW_ID } from "./row-store-schema"; + +const DATABASE_NAME = "paseo-replica-row-store"; +const DATABASE_VERSION = 1; +const ROWS_STORE = "rows"; +const META_STORE = "meta"; +const SCHEMA_VERSION_KEY = "schema_version"; + +interface IndexedDbReplicaRowStoreOptions { + databaseName: string; + schemaVersion: number; +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => + reject(request.error ?? new Error("Replica row store request failed")), + ); + }); +} + +function transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => + reject(transaction.error ?? new Error("Replica row store transaction was aborted")), + ); + transaction.addEventListener("error", () => + reject(transaction.error ?? new Error("Replica row store transaction failed")), + ); + }); +} + +async function runTransaction( + database: IDBDatabase, + storeNames: string | string[], + operation: (transaction: IDBTransaction) => Promise, +): Promise { + const transaction = database.transaction(storeNames, "readwrite"); + const completion = transactionComplete(transaction); + try { + await operation(transaction); + await completion; + } catch (error) { + try { + transaction.abort(); + } catch { + // The transaction already aborted after the failed request. + } + await completion.catch(() => undefined); + throw error; + } +} + +async function openDatabase(databaseName: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(ROWS_STORE)) { + request.result.createObjectStore(ROWS_STORE, { keyPath: ["serverId", "kind", "id"] }); + } + if (!request.result.objectStoreNames.contains(META_STORE)) { + request.result.createObjectStore(META_STORE); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => + reject(request.error ?? new Error("Failed to open replica row store")), + ); + request.addEventListener("blocked", () => + reject(new Error("Replica row store database open was blocked")), + ); + }); +} + +/** @package Test seam for exercising the browser engine with an isolated database. */ +export function createIndexedDbReplicaRowStore( + options: IndexedDbReplicaRowStoreOptions, +): ReplicaRowStore { + let database: IDBDatabase | null = null; + let opening: Promise | null = null; + + function getDatabase(): IDBDatabase { + if (!database) throw new Error("Replica row store has not been opened"); + return database; + } + + async function open(): Promise { + opening ??= (async () => { + const openedDatabase = await openDatabase(options.databaseName); + await runTransaction(openedDatabase, [ROWS_STORE, META_STORE], async (transaction) => { + const storedVersion = await requestResult( + transaction.objectStore(META_STORE).get(SCHEMA_VERSION_KEY), + ); + if (storedVersion !== options.schemaVersion) { + await requestResult(transaction.objectStore(ROWS_STORE).clear()); + await requestResult( + transaction.objectStore(META_STORE).put(options.schemaVersion, SCHEMA_VERSION_KEY), + ); + } + }); + database = openedDatabase; + })(); + + try { + await opening; + } catch (error) { + opening = null; + throw error; + } + } + + async function readAll(): Promise { + const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); + const completion = transactionComplete(transaction); + const rows = await requestResult(transaction.objectStore(ROWS_STORE).getAll()); + await completion; + const hosts = new Map(); + for (const row of rows) { + const hostRows = hosts.get(row.serverId) ?? []; + hostRows.push(row); + hosts.set(row.serverId, hostRows); + } + return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows })); + } + + async function apply(changes: ReplicaRowChanges): Promise { + await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { + const rows = transaction.objectStore(ROWS_STORE); + for (const key of changes.deletes) { + await requestResult(rows.delete([key.serverId, key.kind, key.id])); + } + for (const row of changes.upserts) { + await requestResult(rows.put(row)); + } + }); + } + + async function deleteHost(serverId: string): Promise { + await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { + const rows = transaction.objectStore(ROWS_STORE); + const range = IDBKeyRange.bound([serverId], [serverId, []]); + await requestResult(rows.delete(range)); + }); + } + + async function renameHost(oldServerId: string, newServerId: string): Promise { + if (oldServerId === newServerId) return; + await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { + const rows = transaction.objectStore(ROWS_STORE); + const range = IDBKeyRange.bound([oldServerId], [oldServerId, []]); + const oldRows = await requestResult(rows.getAll(range)); + for (const row of oldRows) { + await requestResult(rows.put({ ...row, serverId: newServerId })); + await requestResult(rows.delete([row.serverId, row.kind, row.id])); + } + }); + } + + async function clear(): Promise { + await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { + await requestResult(transaction.objectStore(ROWS_STORE).clear()); + }); + } + + return { open, readAll, apply, deleteHost, renameHost, clear }; +} + +export function createReplicaRowStore(): ReplicaRowStore { + return createIndexedDbReplicaRowStore({ + databaseName: DATABASE_NAME, + schemaVersion: REPLICA_ROW_STORE_SCHEMA_VERSION, + }); +} diff --git a/packages/app/src/runtime/replica-cache/storage.ts b/packages/app/src/runtime/replica-cache/storage.ts deleted file mode 100644 index e4bc220e24..0000000000 --- a/packages/app/src/runtime/replica-cache/storage.ts +++ /dev/null @@ -1,4 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { ReplicaCacheStorage } from "./index"; - -export const replicaCacheStorage: ReplicaCacheStorage = AsyncStorage; diff --git a/packages/app/src/runtime/replica-cache/storage.web.ts b/packages/app/src/runtime/replica-cache/storage.web.ts deleted file mode 100644 index ef2ef68960..0000000000 --- a/packages/app/src/runtime/replica-cache/storage.web.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ReplicaCacheStorage } from "./index"; - -const DATABASE_NAME = "paseo-replica-cache"; -const STORE_NAME = "key-value"; - -let databasePromise: Promise | null = null; - -function openDatabase(): Promise { - databasePromise ??= new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE_NAME, 1); - request.addEventListener("upgradeneeded", () => { - if (!request.result.objectStoreNames.contains(STORE_NAME)) { - request.result.createObjectStore(STORE_NAME); - } - }); - request.addEventListener("success", () => resolve(request.result)); - request.addEventListener("error", () => - reject(request.error ?? new Error("Failed to open replica cache")), - ); - request.addEventListener("blocked", () => - reject(new Error("Replica cache database upgrade was blocked")), - ); - }); - return databasePromise; -} - -async function getItem(key: string): Promise { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key); - request.addEventListener("success", () => - resolve(typeof request.result === "string" ? request.result : null), - ); - request.addEventListener("error", () => - reject(request.error ?? new Error("Failed to read replica cache")), - ); - }); -} - -async function setItem(key: string, value: string): Promise { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = database.transaction(STORE_NAME, "readwrite"); - transaction.objectStore(STORE_NAME).put(value, key); - transaction.addEventListener("complete", () => resolve()); - transaction.addEventListener("error", () => - reject(transaction.error ?? new Error("Failed to write replica cache")), - ); - transaction.addEventListener("abort", () => - reject(transaction.error ?? new Error("Replica cache write was aborted")), - ); - }); -} - -async function removeItem(key: string): Promise { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = database.transaction(STORE_NAME, "readwrite"); - transaction.objectStore(STORE_NAME).delete(key); - transaction.addEventListener("complete", () => resolve()); - transaction.addEventListener("error", () => - reject(transaction.error ?? new Error("Failed to remove replica cache")), - ); - transaction.addEventListener("abort", () => - reject(transaction.error ?? new Error("Replica cache removal was aborted")), - ); - }); -} - -export const replicaCacheStorage: ReplicaCacheStorage = { getItem, setItem, removeItem }; diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index eaa4e09e5e..e35e9d44a4 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -19,8 +19,12 @@ import { upsertCreatedTerminalPayload, } from "@/screens/workspace/terminals/state"; +export type TerminalTabDestination = + | { kind: "open"; paneId?: string } + | { kind: "replace"; tabId: string }; + interface PendingTerminalCreateInput { - paneId?: string; + destination: TerminalTabDestination; profile?: TerminalProfile; } @@ -34,7 +38,7 @@ interface UseWorkspaceTerminalsInput { workspaceScripts: WorkspaceDescriptor["scripts"]; hasHydratedWorkspaces: boolean; isMissingWorkspaceDirectory: boolean; - onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void; + onTerminalCreated: (input: { terminalId: string; destination: TerminalTabDestination }) => void; onScriptTerminalSelected: (terminalId: string) => void; onWorkspacePathUnavailable: () => void; onTerminalCreateQueued: () => void; @@ -125,13 +129,11 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { ); const createMutation = useMutation({ - mutationFn: async (_input?: PendingTerminalCreateInput) => { + mutationFn: async (_input: PendingTerminalCreateInput) => { if (!client || !workspaceDirectory) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const profile = _input?.profile - ? resolveTerminalProfileLaunch(_input.profile, "") - : undefined; + const profile = _input.profile ? resolveTerminalProfileLaunch(_input.profile, "") : undefined; const payload = profile ? await client.createTerminal(workspaceDirectory, profile.name, undefined, { command: profile.command, @@ -165,7 +167,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { if (createdTerminal) { onTerminalCreated({ terminalId: createdTerminal.id, - paneId: createInput?.paneId, + destination: createInput.destination, }); } }, @@ -212,7 +214,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { ]); const createTerminal = useCallback( - (createInput?: PendingTerminalCreateInput) => { + (createInput: PendingTerminalCreateInput) => { if (createMutation.isPending || pendingCreateInput) { return; } @@ -227,7 +229,7 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) { return; } - setPendingCreateInput(createInput ?? {}); + setPendingCreateInput(createInput); onTerminalCreateQueued(); }, [ diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts index b9e9e86623..6fdd246a9b 100644 --- a/packages/app/src/screens/workspace/visible-agent-ids.ts +++ b/packages/app/src/screens/workspace/visible-agent-ids.ts @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { deriveWorkspacePaneState } from "./workspace-pane-state"; @@ -25,3 +26,20 @@ export function selectVisibleAgentIds(input: { ), ].sort(); } + +export function useVisibleAgentIds(input: { + layout: WorkspaceLayout | null; + tabs: WorkspaceTab[]; + routeFocused: boolean; + focusedPaneOnly: boolean; +}): string[] { + const nextAgentIds = selectVisibleAgentIds(input); + const stableAgentIds = useRef([]); + if ( + stableAgentIds.current.length !== nextAgentIds.length || + stableAgentIds.current.some((agentId, index) => agentId !== nextAgentIds[index]) + ) { + stableAgentIds.current = nextAgentIds; + } + return stableAgentIds.current; +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index c2161bdf06..bfe922b2a2 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -24,15 +24,9 @@ import { Copy, Pencil, RotateCw, - Globe, - FileDiff, - FolderTree, - GitPullRequest, Maximize2, Minimize2, Plus, - SquarePen, - SquareTerminal, X, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -44,7 +38,6 @@ import Animated, { } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { useTranslation } from "react-i18next"; -import { useRouter, type Href } from "expo-router"; import { SortableInlineList } from "@/components/sortable-inline-list"; import type { DraggableListDragHandleProps, @@ -58,14 +51,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; @@ -87,27 +73,15 @@ import { type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; -import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { - getTerminalProfileIcon, - resolveTerminalProfiles, -} from "@getpaseo/protocol/terminal-profiles"; -import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; -import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; -import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; -import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu"; +import { SIDE_PANEL_PANE_ID } from "@/stores/workspace-layout-actions"; const DROPDOWN_WIDTH = 220; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; @@ -194,30 +168,13 @@ const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); const ThemedPencil = withUnistyles(Pencil); -const ThemedSquarePen = withUnistyles(SquarePen); -const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedGlobe = withUnistyles(Globe); const ThemedPlus = withUnistyles(Plus); -const ThemedFileDiff = withUnistyles(FileDiff); -const ThemedFolderTree = withUnistyles(FolderTree); -const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedMinimize2 = withUnistyles(Minimize2); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); -const AGENT_ICON = ; -const TERMINAL_ICON = ; -const BROWSER_ICON = ; -const CHANGES_ICON = ; -const FILES_ICON = ; -const PULL_REQUEST_ICON = ; - -const CHANGES_TARGET = { kind: "working_diff" } as const; -const FILES_TARGET = { kind: "files" } as const; -const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; - function inlineAddActionButtonStyle({ hovered, pressed, @@ -262,108 +219,29 @@ function TabLabelMeasurement({ ); } -interface TerminalProfileMenuItemProps { - profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; - disabled?: boolean; - onLaunch: (profile: TerminalProfile) => void; -} - -function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) { - const leading = useMemo( - () => , - [profile], - ); - const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]); - - return ( - - {profile.name} - - ); -} - -interface TabTargetLauncherOptions { - normalizedServerId: string; - onCreateAgentTab: () => void; - onCreateTerminal: () => void; - onCreateBrowser: () => void; - onOpenChanges: () => void; - onOpenFiles: () => void; - onOpenPullRequest: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; -} - -interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { - open: boolean; - onOpenChange: (open: boolean) => void; +interface WorkspaceNewTabButtonProps { + serverId: string; + paneId?: string; shortcutKeys: ShortcutKey[][] | null; - onEditProfiles: () => void; - showCreateBrowserTab: boolean; - terminalDisabled: boolean; - isGit: boolean; - showPullRequest: boolean; onLayout: (event: LayoutChangeEvent) => void; } function WorkspaceNewTabButton({ - open, - onOpenChange, + serverId, + paneId, shortcutKeys, - onCreateAgentTab, - onCreateTerminal, - onCreateBrowser, - onOpenChanges, - onOpenFiles, - onOpenPullRequest, - onCreateTerminalWithProfile, - onEditProfiles, - normalizedServerId, - showCreateBrowserTab, - terminalDisabled, - isGit, - showPullRequest, onLayout, }: WorkspaceNewTabButtonProps) { const { t } = useTranslation(); - const { config } = useDaemonConfig(normalizedServerId); - const profiles = useMemo( - () => resolveTerminalProfiles(config?.terminalProfiles), - [config?.terminalProfiles], - ); - const agentKeys = useShortcutKeys("workspace-tab-target-agent"); - const terminalKeys = useShortcutKeys("workspace-terminal-new"); - const browserKeys = useShortcutKeys("workspace-tab-target-browser"); - const changesKeys = useShortcutKeys("workspace-tab-target-changes"); - const filesKeys = useShortcutKeys("workspace-tab-target-files"); const tooltipText = t("workspace.tabs.actions.newTab"); - const agentShortcut = useMemo( - () => (agentKeys ? : undefined), - [agentKeys], - ); - const terminalShortcut = useMemo( - () => (terminalKeys ? : undefined), - [terminalKeys], - ); - const browserShortcut = useMemo( - () => (browserKeys ? : undefined), - [browserKeys], - ); - const changesShortcut = useMemo( - () => (changesKeys ? : undefined), - [changesKeys], - ); - const filesShortcut = useMemo( - () => (filesKeys ? : undefined), - [filesKeys], - ); return ( - + - - - {t("workspace.tabs.fallback.agent")} - - - {t("workspace.tabs.fallback.terminal")} - - {showCreateBrowserTab ? ( - - {t("workspace.tabs.fallback.browser")} - - ) : null} - {isGit ? ( - - {t("workspace.tabs.actions.changes")} - - ) : null} - - {t("workspace.tabs.actions.files")} - - {showPullRequest ? ( - - {t("workspace.tabs.actions.pullRequest")} - - ) : null} - - {t("workspace.tabs.actions.terminalProfilesMenu")} - {profiles.map((profile) => ( - - ))} - - - {t("workspace.tabs.actions.editTerminalProfiles")} - - + ); @@ -667,12 +480,7 @@ interface WorkspaceDesktopTabsRowProps { onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; - onCreateDraftTab: (input: { paneId?: string }) => void; - onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfile }) => void; - onCreateBrowserTab: (input: { paneId?: string }) => void; - showCreateBrowserTab?: boolean; - disableCreateTerminal?: boolean; - isWaitingOnTerminalReadiness?: boolean; + onCreateNewTab: (input: { paneId?: string }) => void; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; externalDndContext?: boolean; activeDragTabId?: string | null; @@ -734,6 +542,7 @@ function WorkspaceDesktopTabPresentationCommit({ function getFallbackTabLabel( tab: WorkspaceTabDescriptor, labels: { + newTab: string; newAgent: string; setup: string; terminal: string; @@ -743,6 +552,9 @@ function getFallbackTabLabel( pullRequest: string; }, ): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } if (tab.target.kind === "draft") { return labels.newAgent; } @@ -961,6 +773,8 @@ function TabChip({ ); const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); + const testIdentity = + tab.target.kind === "new_tab" ? tab.tabId : buildDeterministicWorkspaceTabId(tab.target); const tabLabelSkeletonStyle = styles.tabLabelSkeleton; const tabLabelStyle = useMemo( () => [styles.tabLabel, isHighlighted && styles.tabLabelActive], @@ -980,7 +794,7 @@ function TabChip({ @@ -1007,7 +821,7 @@ function TabChip({ align="center" offset={8} maxWidth={720} - testID={`workspace-tab-tooltip-${buildDeterministicWorkspaceTabId(tab.target)}`} + testID={`workspace-tab-tooltip-${testIdentity}`} > {tab.target.kind === "agent" ? ( @@ -1151,12 +965,7 @@ function ResolvedWorkspaceDesktopTabsRow({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onCreateDraftTab, - onCreateTerminalTab, - onCreateBrowserTab, - showCreateBrowserTab = false, - disableCreateTerminal = false, - isWaitingOnTerminalReadiness = false, + onCreateNewTab, onReorderTabs, externalDndContext = false, activeDragTabId = null, @@ -1168,13 +977,11 @@ function ResolvedWorkspaceDesktopTabsRow({ onExitFocusMode, }: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); - const router = useRouter(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); - const [newTabMenuOpen, setNewTabMenuOpen] = useState(false); const tabScrollOffset = useSharedValue(0); const tabScrollViewportWidth = useSharedValue(0); const tabScrollContentWidth = useSharedValue(0); @@ -1182,26 +989,6 @@ function ResolvedWorkspaceDesktopTabsRow({ () => new Map(), ); const [trackSnapshot, setTrackSnapshot] = useState(null); - const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; - const checkoutStatus = useCheckoutStatusQuery({ - serverId: normalizedServerId, - cwd: workspaceRoot, - }); - const isGit = checkoutStatus.status?.isGit === true; - const showPullRequest = isGit; - const workspaceKey = buildWorkspaceTabPersistenceKey({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - }); - const openTab = useWorkspaceLayoutStore((state) => state.openTab); - const openNewTab = useCallback( - ( - requestedWorkspaceKey: string, - target: WorkspaceTabTarget, - placement?: WorkspaceTabPlacement, - ) => openTab({ workspaceKey: requestedWorkspaceKey, target, intent: "new", placement }), - [openTab], - ); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setTabsContainerWidth, event); @@ -1278,6 +1065,7 @@ function ResolvedWorkspaceDesktopTabsRow({ const fallbackTabLabels = useMemo( () => ({ + newTab: t("workspace.tabs.actions.newTab"), newAgent: t("workspace.tabs.fallback.newAgent"), setup: t("workspace.tabs.fallback.setup"), terminal: t("workspace.tabs.fallback.terminal"), @@ -1415,110 +1203,28 @@ function ResolvedWorkspaceDesktopTabsRow({ }); }, [paneId]); - const handleCreateAgentTab = useCallback(() => { - onCreateDraftTab({ paneId }); - }, [onCreateDraftTab, paneId]); - - const handleCreateTerminal = useCallback(() => { - onCreateTerminalTab({ paneId }); - }, [onCreateTerminalTab, paneId]); - - const handleCreateTerminalWithProfile = useCallback( - (profile: TerminalProfile) => { - onCreateTerminalTab({ paneId, profile }); - }, - [onCreateTerminalTab, paneId], - ); - - const handleEditProfiles = useCallback(() => { - router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); - }, [normalizedServerId, router]); - - const handleCreateBrowser = useCallback(() => { - onCreateBrowserTab({ paneId }); - }, [onCreateBrowserTab, paneId]); - - const openPanelTarget = useCallback( - (target: { kind: "working_diff" } | { kind: "files" } | { kind: "pull_request" }) => { - if (!workspaceKey) { - return; - } - // Picked from this pane's own menu, so the tab belongs here even if it is - // currently open somewhere else. - openNewTab(workspaceKey, target, paneId ? { mode: "pane", paneId } : undefined); - }, - [openNewTab, paneId, workspaceKey], - ); - const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]); - const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]); - const handleOpenPullRequest = useCallback( - () => openPanelTarget(PULL_REQUEST_TARGET), - [openPanelTarget], - ); - - const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; - - useEffect(() => { - if (!isFocused) { - setNewTabMenuOpen(false); - } - }, [isFocused]); + const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]); const handleNewTabKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { if (!isFocused) return false; if (action.id === "workspace.tab.menu.open") { - setNewTabMenuOpen(true); + createNewTab(); return true; } - switch (action.id) { - case "workspace.tab.target.agent": - setNewTabMenuOpen(false); - handleCreateAgentTab(); - return true; - case "workspace.tab.target.browser": - if (!showCreateBrowserTab) return true; - setNewTabMenuOpen(false); - handleCreateBrowser(); - return true; - case "workspace.tab.target.changes": - if (!isGit) return true; - setNewTabMenuOpen(false); - handleOpenChanges(); - return true; - case "workspace.tab.target.files": - setNewTabMenuOpen(false); - handleOpenFiles(); - return true; - default: - return false; - } + return false; }, - [ - handleCreateAgentTab, - handleCreateBrowser, - handleOpenChanges, - handleOpenFiles, - isFocused, - isGit, - showCreateBrowserTab, - ], + [createNewTab, isFocused], ); useKeyboardActionHandler({ handlerId: buildWorkspaceKeyboardHandlerId({ - name: "workspace-new-tab-menu", + name: "workspace-new-tab", serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, paneId, }), - actions: [ - "workspace.tab.menu.open", - "workspace.tab.target.agent", - "workspace.tab.target.browser", - "workspace.tab.target.changes", - "workspace.tab.target.files", - ], + actions: ["workspace.tab.menu.open"], enabled: isFocused, priority: 200, handle: handleNewTabKeyboardAction, @@ -1656,22 +1362,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( ) : null} @@ -1684,22 +1377,9 @@ function ResolvedWorkspaceDesktopTabsRow({ {layout.requiresHorizontalScrollFallback ? ( ) : null} diff --git a/packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts b/packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts deleted file mode 100644 index 7dfa44326a..0000000000 --- a/packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldSeedEmptyWorkspaceDraft } from "./workspace-empty-draft-seed"; - -const readyEmptyWorkspace = { - isRouteFocused: true, - hasPersistenceKey: true, - hasWorkspaceDirectory: true, - hasHydratedWorkspaceLayoutStore: true, - hasHydratedAgents: true, - hasLoadedTerminals: true, - activeAgentCount: 0, - terminalCount: 0, - tabCount: 0, -}; - -describe("shouldSeedEmptyWorkspaceDraft", () => { - it("waits for refresh-time hydration before seeding a draft", () => { - expect( - shouldSeedEmptyWorkspaceDraft({ - ...readyEmptyWorkspace, - hasHydratedWorkspaceLayoutStore: false, - }), - ).toBe(false); - expect( - shouldSeedEmptyWorkspaceDraft({ - ...readyEmptyWorkspace, - hasHydratedAgents: false, - }), - ).toBe(false); - expect( - shouldSeedEmptyWorkspaceDraft({ - ...readyEmptyWorkspace, - hasLoadedTerminals: false, - }), - ).toBe(false); - }); - - it("does not seed when existing workspace content is known", () => { - expect( - shouldSeedEmptyWorkspaceDraft({ - ...readyEmptyWorkspace, - activeAgentCount: 1, - }), - ).toBe(false); - expect( - shouldSeedEmptyWorkspaceDraft({ - ...readyEmptyWorkspace, - terminalCount: 1, - }), - ).toBe(false); - expect( - shouldSeedEmptyWorkspaceDraft({ - ...readyEmptyWorkspace, - tabCount: 1, - }), - ).toBe(false); - }); - - it("seeds once an empty focused workspace is fully known", () => { - expect(shouldSeedEmptyWorkspaceDraft(readyEmptyWorkspace)).toBe(true); - }); -}); diff --git a/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts b/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts deleted file mode 100644 index 76c3678a90..0000000000 --- a/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function shouldSeedEmptyWorkspaceDraft(input: { - isRouteFocused: boolean; - hasPersistenceKey: boolean; - hasWorkspaceDirectory: boolean; - hasHydratedWorkspaceLayoutStore: boolean; - hasHydratedAgents: boolean; - hasLoadedTerminals: boolean; - activeAgentCount: number; - terminalCount: number; - tabCount: number; -}): boolean { - if ( - !input.isRouteFocused || - !input.hasPersistenceKey || - !input.hasWorkspaceDirectory || - !input.hasHydratedWorkspaceLayoutStore || - !input.hasHydratedAgents || - !input.hasLoadedTerminals - ) { - return false; - } - - return input.activeAgentCount === 0 && input.terminalCount === 0 && input.tabCount === 0; -} diff --git a/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx new file mode 100644 index 0000000000..da2d2d7fe7 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx @@ -0,0 +1,117 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { View } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Shortcut } from "@/components/ui/shortcut"; +import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import type { Theme } from "@/styles/theme"; +import { + useWorkspaceTabLaunchCatalog, + type WorkspaceTabLaunchItem, + type WorkspaceTabLaunchPurpose, +} from "@/workspace-tabs/launcher"; +import type { LucideIcon } from "lucide-react-native"; + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function LaunchItemIconGlyph({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { + return ; +} + +const ThemedLaunchItemIconGlyph = withUnistyles(LaunchItemIconGlyph); + +function LaunchItemIcon({ item }: { item: WorkspaceTabLaunchItem }): ReactElement | null { + if (item.Icon) { + return ; + } + if (item.terminalIconKey) { + return ( + + + + ); + } + return null; +} + +function WorkspaceNewTabMenuItem({ + item, + paneId, +}: { + item: WorkspaceTabLaunchItem; + paneId?: string; +}) { + const leading = useMemo(() => , [item]); + const trailing = useMemo( + () => + item.shortcutActionId ? : undefined, + [item.shortcutActionId], + ); + const handleSelect = useCallback(() => item.launch({ kind: "open", paneId }), [item, paneId]); + + return ( + + {item.label} + + ); +} + +function LaunchItemShortcut({ actionId }: { actionId: string }) { + const keys = useShortcutKeys(actionId); + return keys ? : null; +} + +export function WorkspaceNewTabMenuContent({ + serverId, + purpose, + paneId, +}: { + serverId: string; + purpose: WorkspaceTabLaunchPurpose; + paneId?: string; +}) { + const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose }); + + return ( + + {groups.map((group, index) => ( + + {index > 0 ? : null} + {group.label ? {group.label} : null} + {group.items.map((item) => ( + + ))} + {group.accessory ? ( + <> + + + {group.accessory.label} + + + ) : null} + + ))} + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx index 974fc095d1..4367e3fb5b 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.test.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.test.tsx @@ -55,11 +55,12 @@ const agentTab: WorkspaceTabDescriptor = { target: { kind: "agent", agentId: "agent-a" }, }; -function buildContent(tab: WorkspaceTabDescriptor = agentTab) { +function buildContent(tab: WorkspaceTabDescriptor = agentTab, isSidePanel = false) { return buildWorkspacePaneContentModel({ tab, normalizedServerId: "server-a", normalizedWorkspaceId: "workspace-a", + isSidePanel, onOpenTab: vi.fn(), onCloseCurrentTab: vi.fn(), onRetargetCurrentTab: vi.fn(), @@ -122,6 +123,24 @@ describe("WorkspacePaneContent", () => { }); }); + it("exposes whether the pane is the Side panel to its content", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + , + ); + }); + + expect(snapshots[0]?.paneContextValue.isSidePanel).toBe(true); + }); + it("keeps pane content mounted when a draft tab is retargeted in place", () => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index e8ab76500a..aa02409f19 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -23,6 +23,7 @@ export interface BuildWorkspacePaneContentModelInput { tab: WorkspaceTabDescriptor; normalizedServerId: string; normalizedWorkspaceId: string; + isSidePanel: boolean; fileNavigationRevision?: number; onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; onCloseCurrentTab: () => void; @@ -36,6 +37,7 @@ export function buildWorkspacePaneContentModel({ tab, normalizedServerId, normalizedWorkspaceId, + isSidePanel, fileNavigationRevision, onOpenTab, onCloseCurrentTab, @@ -53,6 +55,7 @@ export function buildWorkspacePaneContentModel({ paneContextValue: { serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, + isSidePanel, tabId: tab.tabId, target: tab.target, state: tab.state, @@ -91,6 +94,7 @@ export function WorkspacePaneContent({ () => ({ serverId: paneContextValue.serverId, workspaceId: paneContextValue.workspaceId, + isSidePanel: paneContextValue.isSidePanel, tabId: paneContextValue.tabId, target: paneContextValue.target, state: paneContextValue.state, @@ -113,6 +117,7 @@ export function WorkspacePaneContent({ paneContextValue.target, paneContextValue.state, paneContextValue.workspaceId, + paneContextValue.isSidePanel, retargetCurrentTab, setCurrentTabState, ], diff --git a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx b/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx deleted file mode 100644 index c7ebde0995..0000000000 --- a/packages/app/src/screens/workspace/workspace-pane-empty-state.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { memo, useCallback, type ReactElement, type ReactNode } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { useTranslation } from "react-i18next"; -import { - FileDiff, - FolderTree, - GitPullRequest, - SquarePen, - SquareTerminal, -} from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Button } from "@/components/ui/button"; -import { Shortcut } from "@/components/ui/shortcut"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import type { Theme } from "@/styles/theme"; -import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; - -const ThemedFileDiff = withUnistyles(FileDiff); -const ThemedFolderTree = withUnistyles(FolderTree); -const ThemedGitPullRequest = withUnistyles(GitPullRequest); -const ThemedSquarePen = withUnistyles(SquarePen); -const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -const FILES_ICON = ; -const CHANGES_ICON = ; -const PULL_REQUEST_ICON = ; -const AGENT_ICON = ; -const TERMINAL_ICON = ; - -const FILES_TARGET: WorkspaceTabTarget = { kind: "files" }; -const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" }; -const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" }; - -/** The launcher's own binding, or nothing when the action has no shortcut. */ -function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null { - const keys = useShortcutKeys(actionId); - return keys ? : null; -} - -function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) { - return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed]; -} - -/** - * One launcher. This is a row with a trailing slot, not a ` - ) : null} - - - ); -}); - -// Wide enough for a shortcut badge to sit clear of the longest label, narrow enough -// that the stack still reads as one column in a half-width pane. -const LAUNCHER_RAIL_WIDTH = 380; - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: theme.spacing[6], - }, - rail: { - width: "100%", - maxWidth: LAUNCHER_RAIL_WIDTH, - gap: theme.spacing[3], - }, - stack: { - gap: theme.spacing[1], - }, - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 44, - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface2, - }, - rowHovered: { - backgroundColor: theme.colors.surface3, - }, - pressed: { - opacity: 0.85, - }, - rowLabel: { - color: theme.colors.foreground, - }, - rowSpacer: { - flex: 1, - }, - closePane: { - alignSelf: "center", - }, -})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 14fde09004..dfe1948b40 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar import { isSidePanelOpen, openSupportingTab, + openTabInSidePanel, toggleSidePanel, useIsSidePanelOpen, } from "@/workspace-tabs/side-panel"; @@ -63,6 +64,7 @@ import { findPaneById, getFocusedBrowserId, FOCUSED_PANE_PLACEMENT, + selectSidePanelPaneId, type WorkspaceLayout, type WorkspaceTabPlacement, useWorkspaceLayoutStore, @@ -76,14 +78,13 @@ import { import { useSettings } from "@/hooks/use-settings"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; -import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; +import type { + KeyboardActionDefinition, + WorkspacePanelTarget, +} from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { - buildDeterministicWorkspaceTabId, - normalizeWorkspaceTabTarget, - workspaceTabTargetsEqual, -} from "@/workspace-tabs/identity"; -import { selectVisibleAgentIds } from "./visible-agent-ids"; +import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; +import { useVisibleAgentIds } from "./visible-agent-ids"; import { getHostRuntimeStore, useHostRuntimeClient, @@ -121,7 +122,6 @@ import { WorkspaceTabRenameModal, } from "@/screens/workspace/use-workspace-tab-rename"; import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; -import { WorkspacePaneEmptyState } from "@/screens/workspace/workspace-pane-empty-state"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, @@ -156,7 +156,13 @@ import { } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; import { WorkspaceFocusProvider } from "@/workspace/focus"; -import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed"; +import type { NewTabSelection } from "@/workspace-tabs/new-tab"; +import { + NewTabLauncherProvider, + type NewTabLauncher, + type WorkspaceTabLaunchDestination, +} from "@/workspace-tabs/launcher"; +import type { TerminalTabDestination } from "@/screens/workspace/terminals/use-workspace-terminals"; import { buildBulkCloseConfirmationMessage, type BulkCloseConfirmationLabels, @@ -293,6 +299,7 @@ function useSyncWorkspaceActiveBrowser(input: { function getFallbackTabOptionLabel( tab: WorkspaceTabDescriptor, labels: { + newTab: string; newAgent: string; setup: string; terminal: string; @@ -303,6 +310,9 @@ function getFallbackTabOptionLabel( pullRequest: string; }, ): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } if (tab.target.kind === "draft") { return labels.newAgent; } @@ -336,6 +346,7 @@ function getFallbackTabOptionLabel( function getFallbackTabOptionDescription( tab: WorkspaceTabDescriptor, labels: { + newTab: string; newAgent: string; workspaceSetup: string; agent: string; @@ -346,6 +357,9 @@ function getFallbackTabOptionDescription( pullRequest: string; }, ): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } if (tab.target.kind === "draft") { return labels.newAgent; } @@ -547,7 +561,7 @@ function MobileWorkspaceTabOption({ }), [t], ); - const menuTestIDBase = `workspace-tab-menu-${buildDeterministicWorkspaceTabId(tab.target)}`; + const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; const menuEntries = buildWorkspaceTabMenuEntries({ surface: "mobile", tab, @@ -569,6 +583,7 @@ function MobileWorkspaceTabOption({ const fallbackLabels = useMemo( () => ({ + newTab: t("workspace.tabs.actions.newTab"), newAgent: t("workspace.tabs.fallback.newAgent"), setup: t("workspace.tabs.fallback.setup"), terminal: t("workspace.tabs.fallback.terminal"), @@ -1053,6 +1068,7 @@ interface RenderWorkspaceContentInput { isMissingWorkspaceDirectory: boolean; activeTabDescriptor: WorkspaceTabDescriptor | null; hasHydratedAgents: boolean; + hasLoadedTerminals: boolean; mountedFocusedPaneTabIds: string[]; focusedPaneTabDescriptorMap: Map; isRouteFocused: boolean; @@ -1068,6 +1084,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React isMissingWorkspaceDirectory, activeTabDescriptor, hasHydratedAgents, + hasLoadedTerminals, mountedFocusedPaneTabIds, focusedPaneTabDescriptorMap, isRouteFocused, @@ -1084,7 +1101,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React ); } - if (!activeTabDescriptor && !hasHydratedAgents) { + if (!hasHydratedAgents || !hasLoadedTerminals) { return ( @@ -1266,6 +1283,20 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { ); } +function WorkspacePanelContent({ + launcher, + content, +}: { + launcher: NewTabLauncher; + content: ReactNode; +}) { + return ( + + {content} + + ); +} + function renderWorkspaceScreenGateShell(input: { gate: ReactNode; workspaceKey: string | null; @@ -1351,6 +1382,11 @@ interface WorkspaceTerminalTabActionsInput { target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement, ) => string | null; + replaceWorkspaceTabTarget: ( + workspaceKey: string, + tabId: string, + target: WorkspaceTabTarget, + ) => string | null; labels: { workspacePathUnavailable: string; terminalQueued: string; @@ -1362,7 +1398,10 @@ interface WorkspaceTerminalTabActionsInput { } interface WorkspaceTerminalTabActions { - handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void; + handleTerminalCreated: (input: { + terminalId: string; + destination: TerminalTabDestination; + }) => void; handleScriptTerminalSelected: (terminalId: string) => void; handleWorkspacePathUnavailable: () => void; handleTerminalCreateQueued: () => void; @@ -1372,21 +1411,29 @@ interface WorkspaceTerminalTabActions { function useWorkspaceTerminalTabActions({ persistenceKey, openWorkspaceTabFocused, + replaceWorkspaceTabTarget, labels, toast, }: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions { const handleTerminalCreated = useCallback( - ({ terminalId, paneId }: { terminalId: string; paneId?: string }) => { + ({ terminalId, destination }: { terminalId: string; destination: TerminalTabDestination }) => { if (!persistenceKey) { return; } + if (destination.kind === "replace") { + replaceWorkspaceTabTarget(persistenceKey, destination.tabId, { + kind: "terminal", + terminalId, + }); + return; + } openWorkspaceTabFocused( persistenceKey, { kind: "terminal", terminalId }, - paneLocalPlacement(paneId), + paneLocalPlacement(destination.paneId), ); }, - [openWorkspaceTabFocused, persistenceKey], + [openWorkspaceTabFocused, persistenceKey, replaceWorkspaceTabTarget], ); const handleScriptTerminalSelected = useCallback( (terminalId: string) => { @@ -1423,6 +1470,17 @@ function useWorkspaceTerminalTabActions({ }; } +function resolveCommandCenterPanelTarget(target: WorkspacePanelTarget): WorkspaceTabTarget { + switch (target) { + case "changes": + return { kind: "working_diff" }; + case "files": + return { kind: "files" }; + case "pull-request": + return { kind: "pull_request" }; + } +} + function WorkspaceScreenContent({ serverId, workspaceId, @@ -1501,6 +1559,7 @@ function WorkspaceScreenContent({ [normalizedServerId, normalizedWorkspaceId], ); const openTab = useWorkspaceLayoutStore((state) => state.openTab); + const replaceWorkspaceTabTarget = useWorkspaceLayoutStore((state) => state.replaceTab); const openWorkspaceTabFocused = useCallback( (workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) => openTab({ workspaceKey, target, intent: "reveal", placement }), @@ -1570,6 +1629,7 @@ function WorkspaceScreenContent({ } = useWorkspaceTerminalTabActions({ persistenceKey, openWorkspaceTabFocused, + replaceWorkspaceTabTarget, labels: { workspacePathUnavailable: t("workspace.header.toasts.workspacePathUnavailable"), terminalQueued: t("workspace.header.toasts.terminalQueued"), @@ -1591,7 +1651,6 @@ function WorkspaceScreenContent({ queryKey: terminalsQueryKey, removeTerminalFromCache, standaloneTerminalIds, - terminals, } = useWorkspaceTerminals({ client, isConnected, @@ -1691,6 +1750,9 @@ function WorkspaceScreenContent({ const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); + const sidePanelPaneId = useWorkspaceLayoutStore((state) => + persistenceKey ? selectSidePanelPaneId(state, persistenceKey) : null, + ); const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null, @@ -1725,7 +1787,6 @@ function WorkspaceScreenContent({ const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); - const replaceWorkspaceTabTarget = useWorkspaceLayoutStore((state) => state.replaceTab); const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState); const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); @@ -1788,16 +1849,16 @@ function WorkspaceScreenContent({ const viewedTimelineSync = useSessionStore( (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, ); - const visibleAgentIds = useMemo( - () => - selectVisibleAgentIds({ - layout: workspaceLayout, - tabs: uiTabs, - routeFocused: isRouteFocused, - focusedPaneOnly: isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), - }), - [isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout], + const syncFocusedPaneOnly = useMemo( + () => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), + [isFocusModeEnabled, isMobile], ); + const visibleAgentIds = useVisibleAgentIds({ + layout: workspaceLayout, + tabs: uiTabs, + routeFocused: isRouteFocused, + focusedPaneOnly: syncFocusedPaneOnly, + }); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; @@ -1880,7 +1941,7 @@ function WorkspaceScreenContent({ [openWorkspaceTabFocused, openWorkspaceTabInBackground, persistenceKey], ); - useEffect(() => { + useLayoutEffect(() => { if (!isRouteFocused) { return; } @@ -1970,8 +2031,6 @@ function WorkspaceScreenContent({ [navigateToTabId, openWorkspaceTabFocused, persistenceKey], ); - const emptyWorkspaceSeedRef = useRef(null); - useEffect(() => { if (!isRouteFocused) { return; @@ -2034,46 +2093,6 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, ]); - useEffect(() => { - if ( - !shouldSeedEmptyWorkspaceDraft({ - isRouteFocused, - hasPersistenceKey: Boolean(persistenceKey), - hasWorkspaceDirectory: Boolean(workspaceDirectory), - hasHydratedWorkspaceLayoutStore, - hasHydratedAgents, - hasLoadedTerminals: terminalsQuery.isSuccess, - activeAgentCount: workspaceAgentVisibility.activeAgentIds.size, - terminalCount: terminals.length, - // Workspace-wide, like every other count here. A pane-scoped count made an - // empty pane look like an empty workspace and reseeded a draft on top of it. - tabCount: uiTabs.length, - }) - ) { - emptyWorkspaceSeedRef.current = null; - return; - } - const workspaceKey = `${normalizedServerId}:${normalizedWorkspaceId}`; - if (emptyWorkspaceSeedRef.current === workspaceKey) { - return; - } - emptyWorkspaceSeedRef.current = workspaceKey; - openWorkspaceDraftTab({ ambient: true }); - }, [ - normalizedServerId, - normalizedWorkspaceId, - openWorkspaceDraftTab, - persistenceKey, - hasHydratedAgents, - hasHydratedWorkspaceLayoutStore, - isRouteFocused, - terminals.length, - terminalsQuery.isSuccess, - uiTabs.length, - workspaceDirectory, - workspaceAgentVisibility.activeAgentIds.size, - ]); - const handleOpenFileFromChat = useCallback( (location: WorkspaceFileLocation, parentTabId?: string | null) => { const normalizedLocation = normalizeWorkspaceFileLocation(location); @@ -2197,6 +2216,7 @@ function WorkspaceScreenContent({ }, [uiTabs]); const bulkCloseConfirmationLabels = useMemo( () => ({ + newTab: t("workspace.tabs.actions.newTab"), all: ({ agents, terminals: terminalCount, tabs: tabCount }) => t("workspace.tabs.confirmations.bulk.all", { agents, @@ -2229,6 +2249,7 @@ function WorkspaceScreenContent({ const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]); const tabFallbackLabels = useMemo( () => ({ + newTab: t("workspace.tabs.actions.newTab"), newAgent: t("workspace.tabs.fallback.newAgent"), setup: t("workspace.tabs.fallback.setup"), workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), @@ -2259,21 +2280,15 @@ function WorkspaceScreenContent({ [openWorkspaceDraftTab], ); - const handleCreateTerminal = useStableEvent(createTerminal); - - const handleOpenPaneView = useCallback( - (input: { paneId: string; target: WorkspaceTabTarget }) => { - if (!persistenceKey) { - return; - } - createWorkspaceTab(persistenceKey, input.target, paneLocalPlacement(input.paneId)); - }, - [createWorkspaceTab, persistenceKey], - ); + const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => { + createTerminal({ + destination: input?.paneId ? { kind: "open", paneId: input.paneId } : { kind: "open" }, + }); + }); const handleCreateTerminalWithProfile = useCallback( (profile: TerminalProfile) => { - createTerminal({ profile }); + createTerminal({ profile, destination: { kind: "open" } }); }, [createTerminal], ); @@ -2293,6 +2308,52 @@ function WorkspaceScreenContent({ [openWorkspaceTabFocused, persistenceKey], ); + const handleCreateNewTab = useCallback( + (input?: { paneId?: string }) => { + if (!persistenceKey) { + return; + } + createWorkspaceTab(persistenceKey, { kind: "new_tab" }, paneLocalPlacement(input?.paneId)); + }, + [createWorkspaceTab, persistenceKey], + ); + + const launchWorkspaceTab = useCallback( + (selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => { + if (!persistenceKey) { + return; + } + const openTarget = (target: WorkspaceTab["target"]) => { + if (destination.kind === "replace") { + replaceWorkspaceTabTarget(persistenceKey, destination.tabId, target); + } else { + createWorkspaceTab(persistenceKey, target, paneLocalPlacement(destination.paneId)); + } + }; + if (selection.kind === "target") { + openTarget(selection.target); + return; + } + if (selection.kind === "agent") { + openTarget({ + kind: "draft", + draftId: generateDraftId(), + }); + return; + } + if (selection.kind === "terminal") { + createTerminal({ + profile: selection.profile, + destination, + }); + return; + } + const { browserId } = createWorkspaceBrowser(); + openTarget({ kind: "browser", browserId }); + }, + [createTerminal, createWorkspaceTab, persistenceKey, replaceWorkspaceTabTarget], + ); + const handleOpenUrlInBrowserTab = useCallback( (url: string) => { if (!persistenceKey || !getIsElectron()) { @@ -2842,6 +2903,109 @@ function WorkspaceScreenContent({ ], ); + const handleWorkspacePanelOpenAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (action.id !== "workspace.tab.open") return false; + if (!persistenceKey) return true; + + const target = resolveCommandCenterPanelTarget(action.target); + if (action.placement === "supporting") { + openSupportingTab({ + isCompact: isMobile, + workspaceKey: persistenceKey, + target, + openInSidePanelByDefault, + }); + return true; + } + if (action.placement === "side-panel") { + openTabInSidePanel({ + isCompact: isMobile, + workspaceKey: persistenceKey, + checkout: activeExplorerCheckout, + target, + }); + return true; + } + const focusedPaneId = focusedPaneTabState.pane?.id; + openWorkspaceTabFocused( + persistenceKey, + target, + focusedPaneId ? { mode: "pane", paneId: focusedPaneId } : undefined, + ); + return true; + }, + [ + activeExplorerCheckout, + focusedPaneTabState.pane?.id, + isMobile, + openInSidePanelByDefault, + openWorkspaceTabFocused, + persistenceKey, + ], + ); + + const handleWorkspaceCurrentTabMetadataAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + const descriptor = activeTab?.descriptor; + switch (action.id) { + case "workspace.tab.rename-current": + if (descriptor) handleRenameTab(descriptor); + return true; + case "workspace.tab.reload-current": + if (descriptor?.target.kind === "agent") + void handleReloadAgent(descriptor.target.agentId); + return true; + case "workspace.tab.copy-resume-command": + if (descriptor?.target.kind === "agent") { + void handleCopyResumeCommand(descriptor.target.agentId); + } + return true; + case "workspace.tab.copy-id": + if (descriptor?.target.kind === "agent") + void handleCopyAgentId(descriptor.target.agentId); + if (descriptor?.target.kind === "terminal") { + void handleCopyTerminalId(descriptor.target.terminalId); + } + return true; + case "workspace.tab.copy-file-path": + if (descriptor?.target.kind === "file") void handleCopyFilePath(descriptor.target.path); + return true; + default: + return false; + } + }, + [ + activeTab, + handleCopyAgentId, + handleCopyFilePath, + handleCopyResumeCommand, + handleCopyTerminalId, + handleReloadAgent, + handleRenameTab, + ], + ); + + const handleWorkspaceCurrentTabCloseAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!activeTabId) return true; + if (action.id === "workspace.tab.close-left") { + void handleCloseTabsToLeft(activeTabId); + return true; + } + if (action.id === "workspace.tab.close-right") { + void handleCloseTabsToRight(activeTabId); + return true; + } + if (action.id === "workspace.tab.close-others") { + void handleCloseOtherTabs(activeTabId); + return true; + } + return false; + }, + [activeTabId, handleCloseOtherTabs, handleCloseTabsToLeft, handleCloseTabsToRight], + ); + const handleWorkspaceTabAction = useCallback( (action: KeyboardActionDefinition): boolean => { switch (action.id) { @@ -2854,6 +3018,9 @@ function WorkspaceScreenContent({ case "workspace.browser.new": handleCreateBrowserTab(); return true; + case "workspace.tab.menu.open": + handleCreateNewTab({ paneId: focusedPaneTabState.pane?.id }); + return true; case "workspace.tab.close-current": if (activeTabId) { void handleCloseTabById(activeTabId); @@ -2887,12 +3054,55 @@ function WorkspaceScreenContent({ handleCloseTabById, handleCreateDraftTab, handleCreateBrowserTab, + handleCreateNewTab, handleCreateTerminal, + focusedPaneTabState.pane?.id, navigateToTabId, tabs, ], ); + const handleWorkspaceDirectTargetAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + const paneId = focusedPaneTabState.pane?.id; + switch (action.id) { + case "workspace.tab.target.agent": + handleCreateDraftTab({ paneId }); + return true; + case "workspace.tab.target.browser": + handleCreateBrowserTab({ paneId }); + return true; + case "workspace.tab.target.changes": + if (persistenceKey && isGitCheckout) { + openSupportingTab({ + isCompact: isMobile, + workspaceKey: persistenceKey, + target: { kind: "working_diff" }, + openInSidePanelByDefault, + }); + } + return true; + case "workspace.tab.target.files": + if (persistenceKey) { + openWorkspaceTabFocused(persistenceKey, { kind: "files" }, paneLocalPlacement(paneId)); + } + return true; + default: + return false; + } + }, + [ + focusedPaneTabState.pane?.id, + handleCreateBrowserTab, + handleCreateDraftTab, + isGitCheckout, + isMobile, + openInSidePanelByDefault, + openWorkspaceTabFocused, + persistenceKey, + ], + ); + const handleWorkspaceSidebarAction = useCallback( (action: KeyboardActionDefinition): boolean => { if (action.id === "sidebar.toggle.right") { @@ -2999,6 +3209,10 @@ function WorkspaceScreenContent({ ], ); + const workspaceKeyboardActionsEnabled = Boolean( + isRouteFocused && normalizedServerId && normalizedWorkspaceId, + ); + useKeyboardActionHandler({ handlerId: buildWorkspaceKeyboardHandlerId({ name: "workspace-tab-actions", @@ -3012,13 +3226,81 @@ function WorkspaceScreenContent({ "workspace.tab.navigate-relative", "workspace.terminal.new", "workspace.browser.new", + "workspace.tab.menu.open", ] as const, - enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), + enabled: workspaceKeyboardActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceTabAction, }); + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: "workspace-direct-target-actions", + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }), + actions: [ + "workspace.tab.target.agent", + "workspace.tab.target.browser", + "workspace.tab.target.changes", + "workspace.tab.target.files", + ] as const, + enabled: workspaceKeyboardActionsEnabled, + priority: 100, + isActive: () => true, + handle: handleWorkspaceDirectTargetAction, + }); + + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: "workspace-panel-open-actions", + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }), + actions: ["workspace.tab.open"] as const, + enabled: workspaceKeyboardActionsEnabled, + priority: 100, + isActive: () => true, + handle: handleWorkspacePanelOpenAction, + }); + + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: "workspace-current-tab-metadata-actions", + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }), + actions: [ + "workspace.tab.rename-current", + "workspace.tab.reload-current", + "workspace.tab.copy-resume-command", + "workspace.tab.copy-id", + "workspace.tab.copy-file-path", + ] as const, + enabled: workspaceKeyboardActionsEnabled, + priority: 100, + isActive: () => true, + handle: handleWorkspaceCurrentTabMetadataAction, + }); + + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: "workspace-current-tab-close-actions", + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }), + actions: [ + "workspace.tab.close-left", + "workspace.tab.close-right", + "workspace.tab.close-others", + ] as const, + enabled: workspaceKeyboardActionsEnabled, + priority: 100, + isActive: () => true, + handle: handleWorkspaceCurrentTabCloseAction, + }); + useKeyboardActionHandler({ handlerId: buildWorkspaceKeyboardHandlerId({ name: "workspace-pane-actions", @@ -3039,7 +3321,7 @@ function WorkspaceScreenContent({ "workspace.pane.close", "workspace.focus.toggle", ] as const, - enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), + enabled: workspaceKeyboardActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspacePaneAction, @@ -3052,7 +3334,7 @@ function WorkspaceScreenContent({ workspaceId: normalizedWorkspaceId, }), actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const, - enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId), + enabled: workspaceKeyboardActionsEnabled, priority: 100, isActive: () => true, handle: handleWorkspaceSidebarAction, @@ -3093,6 +3375,8 @@ function WorkspaceScreenContent({ tab: input.tab, normalizedServerId, normalizedWorkspaceId, + isSidePanel: + canRenderDesktopPaneSplits && input.paneId !== null && input.paneId === sidePanelPaneId, fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0, onOpenTab: (target) => { if (!persistenceKey) { @@ -3148,11 +3432,13 @@ function WorkspaceScreenContent({ navigateToTabId, normalizedServerId, normalizedWorkspaceId, + canRenderDesktopPaneSplits, openImportSheet, revealWorkspaceChildTab, persistenceKey, replaceWorkspaceTabTarget, setWorkspaceTabState, + sidePanelPaneId, ], ); const focusedPaneId = useMemo( @@ -3193,6 +3479,7 @@ function WorkspaceScreenContent({ isMissingWorkspaceDirectory, activeTabDescriptor, hasHydratedAgents, + hasLoadedTerminals: terminalsQuery.isSuccess, mountedFocusedPaneTabIds, focusedPaneTabDescriptorMap, isRouteFocused, @@ -3286,40 +3573,6 @@ function WorkspaceScreenContent({ [focusedPaneId, handleReorderTabsInPane], ); - const renderSplitPaneEmptyState = useCallback( - function renderSplitPaneEmptyState(input: { - paneId: string; - paneIsolated: boolean; - onClosePane: () => void; - }) { - // Same predicate the close action reads, so the button is offered exactly when - // pressing it would do something. - const canClosePane = - !input.paneIsolated && - Boolean(workspaceLayout) && - canDismissPaneInLayout(workspaceLayout as WorkspaceLayout, input.paneId); - return ( - - ); - }, - [ - handleCreateDraftTab, - handleCreateTerminal, - handleOpenPaneView, - hasPullRequest, - isGitCheckout, - workspaceLayout, - ], - ); - const containerStyle = [styles.container, styles.containerWorkspaceBackground]; const workspaceScreenGate = renderWorkspaceRouteGate({ @@ -3431,6 +3684,22 @@ function WorkspaceScreenContent({ [createTerminalMutation.isPending, pendingTerminalCreateInput], ); const showCreateBrowserTab = getIsElectron(); + const newTabLauncher = useMemo( + () => ({ + showChanges: isGitCheckout, + showPullRequest: hasPullRequest, + showBrowser: showCreateBrowserTab, + terminalDisabled: createTerminalDisabled, + launch: launchWorkspaceTab, + }), + [ + createTerminalDisabled, + hasPullRequest, + isGitCheckout, + launchWorkspaceTab, + showCreateBrowserTab, + ], + ); const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]); const desktopFocusModeEnabled = useMemo( () => isFocusModeEnabled && !isMobile, @@ -3469,19 +3738,14 @@ function WorkspaceScreenContent({ onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} - onCreateDraftTab={handleCreateDraftTab} - onCreateTerminalTab={handleCreateTerminal} - onCreateBrowserTab={handleCreateBrowserTab} - showCreateBrowserTab={showCreateBrowserTab} + onCreateNewTab={handleCreateNewTab} buildPaneContentModel={buildDesktopPaneContentModel} onFocusPane={handleFocusPane} onSplitPane={handleSplitPane} onSplitPaneEmpty={handleCreateEmptySplit} onMoveTabToPane={handleMoveTabToPane} - onClosePane={handleClosePane} onResizeSplit={handleResizePaneSplit} onReorderTabsInPane={handleReorderTabsInPane} - renderPaneEmptyState={renderSplitPaneEmptyState} /> ); }, [ @@ -3507,22 +3771,24 @@ function WorkspaceScreenContent({ handleCloseTabsToLeftInPane, handleCloseTabsToRightInPane, handleCloseOtherTabsInPane, - handleCreateDraftTab, - handleCreateTerminal, - handleCreateBrowserTab, - showCreateBrowserTab, + handleCreateNewTab, buildDesktopPaneContentModel, handleFocusPane, handleSplitPane, handleCreateEmptySplit, handleMoveTabToPane, - handleClosePane, handleResizePaneSplit, handleReorderTabsInPane, - renderSplitPaneEmptyState, ]); const desktopContent = desktopSplitContent ?? content; + const workspacePanelContent = ( + + ); + const workspaceCenterColumn = ( {showScreenHeader && ( @@ -3606,63 +3872,57 @@ function WorkspaceScreenContent({ onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} - onCreateDraftTab={handleCreateDraftTab} - onCreateTerminalTab={handleCreateTerminal} - onCreateBrowserTab={handleCreateBrowserTab} - showCreateBrowserTab={showCreateBrowserTab} - disableCreateTerminal={createTerminalMutation.isPending} - isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null} + onCreateNewTab={handleCreateNewTab} onReorderTabs={handleReorderTabsInFocusedPane} focusModeEnabled={desktopFocusModeEnabled} onExitFocusMode={toggleFocusMode} /> ) : null} - - {isMobile ? ( - {content} - ) : ( - {desktopContent} - )} - + {workspacePanelContent} ); + const renderedWorkspaceScreen = ( + + + + + + {workspaceCenterColumn} + + + + + + + + ); + + if (gatedWorkspaceScreen) { + return gatedWorkspaceScreen; + } return ( - gatedWorkspaceScreen ?? ( - - - - - - - {workspaceCenterColumn} - - - - - - - - - ) + + {renderedWorkspaceScreen} + ); } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 8b57dc29a1..7b97f46c23 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -157,6 +157,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "plugin") { return `workspace-plugin-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`; } + if (tab.target.kind === "new_tab") { + return `workspace-new-tab-close-${tab.tabId}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } @@ -321,7 +324,7 @@ export function buildWorkspaceTabMenuEntries( export function buildWorkspaceDesktopTabActions( input: BuildWorkspaceDesktopTabActionsInput, ): WorkspaceDesktopTabActions { - const contextMenuTestId = `workspace-tab-context-${buildDeterministicWorkspaceTabId(input.tab.target)}`; + const contextMenuTestId = `workspace-tab-context-${input.tab.tabId}`; return { contextMenuTestId, menuEntries: buildWorkspaceTabMenuEntries({ diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index b7e6c967f8..5068d5779a 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -3,7 +3,6 @@ import { deriveWorkspacePaneState, type WorkspaceDerivedTab, } from "@/screens/workspace/workspace-pane-state"; -import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity"; export interface WorkspaceTabModel { tabs: WorkspaceDerivedTab[]; @@ -11,10 +10,6 @@ export interface WorkspaceTabModel { activeTab: WorkspaceDerivedTab | null; } -export function buildWorkspaceTabId(target: WorkspaceTabTarget): string { - return buildDeterministicWorkspaceTabId(target); -} - export function deriveWorkspaceTabModel(input: { tabs: WorkspaceTab[]; focusedTabId?: string | null; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index fdae0941ed..d408525c15 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -205,21 +205,22 @@ function preserveWorkspaceDescriptorIdentity( return incoming; } -function preserveWorkspaceMapIdentity( - existing: Map, - incoming: Map, -): Map { +function preserveMapIdentity( + existing: Map, + incoming: Map, +): Map { if (existing === incoming) { return existing; } - const next = new Map(); + const next = new Map(); let changed = existing.size !== incoming.size; const existingEntries = existing.entries(); for (const [key, workspace] of incoming) { const existingWorkspace = existing.get(key); - const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existingWorkspace); + const nextWorkspace = + existingWorkspace && equal(existingWorkspace, workspace) ? existingWorkspace : workspace; next.set(key, nextWorkspace); const existingEntry = existingEntries.next().value; if (!existingEntry || existingEntry[0] !== key || existingEntry[1] !== nextWorkspace) { @@ -230,17 +231,6 @@ function preserveWorkspaceMapIdentity( return changed ? next : existing; } -function projectMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left.size !== right.size) return false; - for (const [projectId, project] of right) { - if (!equal(left.get(projectId), project)) return false; - } - return true; -} - export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; export type ExplorerEncoding = "utf-8" | "base64" | "none"; @@ -1659,10 +1649,7 @@ export const useSessionStore = create()( } const nextWorkspaces = typeof workspaces === "function" ? workspaces(session.workspaces) : workspaces; - const preservedWorkspaces = preserveWorkspaceMapIdentity( - session.workspaces, - nextWorkspaces, - ); + const preservedWorkspaces = preserveMapIdentity(session.workspaces, nextWorkspaces); if (session.workspaces === preservedWorkspaces) { return prev; } @@ -1681,10 +1668,15 @@ export const useSessionStore = create()( for (const project of projects) next.set(project.projectId, project); set((prev) => { const session = prev.sessions[serverId]; - if (!session || projectMapsEqual(session.projects, next)) return prev; + if (!session) return prev; + const preservedProjects = preserveMapIdentity(session.projects, next); + if (session.projects === preservedProjects) return prev; return { ...prev, - sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } }, + sessions: { + ...prev.sessions, + [serverId]: { ...session, projects: preservedProjects }, + }, }; }); }, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index ddea75d9fa..18e91a2415 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -10,6 +10,8 @@ import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/workspace-tabs/identity"; +import { createNewWorkspaceTab } from "@/workspace-tabs/new-tab"; +import { generateDraftId } from "@/stores/draft-keys"; export interface SplitPane { id: string; @@ -309,6 +311,22 @@ function createPaneNode(input: { }; } +function ensureRetainedPaneHasTab(pane: SplitPaneInternal): SplitPaneInternal { + if (pane.tabs.length > 0) { + return pane; + } + const newTab = createNewWorkspaceTab(); + return normalizePaneAfterTabChange({ + ...pane, + tabs: [newTab], + focusedTabId: newTab.tabId, + }); +} + +function isSoleNewTabPane(pane: SplitPaneInternal): boolean { + return pane.tabs.length === 1 && pane.tabs[0]?.target.kind === "new_tab"; +} + function createGroupNode(input: { id: string; direction: "horizontal" | "vertical"; @@ -339,7 +357,11 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null { if (!target) { return null; } - const tabId = trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target); + const persistedTabId = trimNonEmpty(tab.tabId); + if (!persistedTabId && target.kind === "new_tab") { + return null; + } + const tabId = persistedTabId ?? buildDeterministicWorkspaceTabId(target); if (!tabId) { return null; } @@ -730,7 +752,7 @@ function reorderTabsForPane(input: ReorderTabsForPaneInput): SplitPaneInternal { function removePaneByPath(root: SplitNodeInternal, path: number[]): SplitNodeInternal { if (path.length === 0) { invariant(root.kind === "pane", "Expected pane at root while removing pane"); - return createPaneNode({ id: root.pane.id }); + return createPaneNode({ id: root.pane.id, tabs: [createNewWorkspaceTab()] }); } const parentPath = path.slice(0, -1); @@ -777,7 +799,17 @@ function detachTabFromTree( const nextRoot = replaceNodeAtPath(root, panePath, () => ({ kind: "pane", pane: nextPane })); if (nextPane.tabs.length > 0 || nextPane.id === input.preserveEmptyPaneId) { - return { root: nextRoot, tab, sourcePaneId: paneNode.pane.id }; + return { + root: + nextPane.tabs.length > 0 + ? nextRoot + : replaceNodeAtPath(nextRoot, panePath, () => ({ + kind: "pane", + pane: ensureRetainedPaneHasTab(nextPane), + })), + tab, + sourcePaneId: paneNode.pane.id, + }; } return { @@ -796,10 +828,14 @@ function insertTabIntoPane( return replaceNodeAtPath(root, panePath, (node) => { invariant(node.kind === "pane", "Expected pane while inserting tab"); const existingIndex = node.pane.tabs.findIndex((tab) => tab.tabId === input.tab.tabId); - const nextTabs = - existingIndex >= 0 - ? node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)) - : [...node.pane.tabs, input.tab]; + let nextTabs: WorkspaceTab[]; + if (input.tab.target.kind !== "new_tab" && isSoleNewTabPane(node.pane)) { + nextTabs = [input.tab]; + } else if (existingIndex >= 0) { + nextTabs = node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)); + } else { + nextTabs = [...node.pane.tabs, input.tab]; + } return { kind: "pane", pane: normalizePaneAfterTabChange({ @@ -1045,9 +1081,7 @@ export function collectAllPanes(root: SplitNode): SplitPane[] { } function isEphemeralTab(tab: WorkspaceTab): boolean { - // Commit diff tabs are ephemeral: their SHA may be rebased away before the next - // load, so a restored tab could point at a dead commit. - return tab.target.kind === "commit_diff"; + return tab.target.kind === "commit_diff" || tab.target.kind === "new_tab"; } function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal { @@ -1074,12 +1108,8 @@ function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal } /** - * Returns a copy of `layout` with every ephemeral tab (commit diff tabs) removed - * from each pane. Applied in the layout store's `partialize` so commit diff tabs - * are never written to storage — they're dropped on the next reload rather than - * restored pointing at a possibly-rebased SHA. Working diff tabs and all other - * tab kinds are left intact. Panes (and their ids/structure) are preserved even - * when emptied; the parent-tab map is renormalized against the surviving tabs. + * Returns a copy of `layout` with ephemeral tabs removed from each pane. Panes and + * their structure remain so deserialization can restore empty panes explicitly. */ export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): WorkspaceLayout { const internalLayout = asInternalLayout(layout); @@ -1091,6 +1121,32 @@ export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): Workspace }); } +function restoreEmptyPanesInNode(node: SplitNodeInternal): SplitNodeInternal { + if (node.kind === "pane") { + return node.pane.tabs.length > 0 + ? node + : createPaneNode({ + id: node.pane.id, + tabs: [createNewWorkspaceTab()], + hidden: node.pane.hidden, + }); + } + return createGroupNode({ + id: node.group.id, + direction: node.group.direction, + children: node.group.children.map(restoreEmptyPanesInNode), + sizes: node.group.sizes, + }); +} + +export function restoreEmptyPanesInLayout(layout: WorkspaceLayout): WorkspaceLayout { + const normalized = normalizeLayout(layout); + return { + ...normalized, + root: restoreEmptyPanesInNode(asInternalNode(normalized.root)), + }; +} + export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null { if (!layout) { return null; @@ -1107,7 +1163,7 @@ export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): export function createDefaultLayout(): WorkspaceLayout { return { - root: createPaneNode({ id: DEFAULT_PANE_ID }), + root: createPaneNode({ id: DEFAULT_PANE_ID, tabs: [createNewWorkspaceTab()] }), focusedPaneId: DEFAULT_PANE_ID, }; } @@ -1119,8 +1175,12 @@ export function createWorkspaceLayoutWithSidePanel(): WorkspaceLayout { id: DEFAULT_LAYOUT_GROUP_ID, direction: "horizontal", children: [ - createPaneNode({ id: DEFAULT_PANE_ID }), - createPaneNode({ id: SIDE_PANEL_PANE_ID, hidden: true }), + createPaneNode({ id: DEFAULT_PANE_ID, tabs: [createNewWorkspaceTab()] }), + createPaneNode({ + id: SIDE_PANEL_PANE_ID, + tabs: [createNewWorkspaceTab()], + hidden: true, + }), ], sizes: [0.5, 0.5], }), @@ -1176,17 +1236,17 @@ const SIDE_PANEL_EXCLUDED_TAB_KINDS: ReadonlySet = n ]); function resolvePlacementPane(input: { - layout: { root: SplitNode; focusedPaneId: string | null }; + layout: { root: SplitNodeInternal; focusedPaneId: string | null }; target: WorkspaceTabTarget; placement: WorkspaceTabPlacement; sidePanelPaneId: string | null; -}): SplitPane { +}): SplitPaneInternal { const requestedPane = input.placement.mode === "pane" || input.placement.mode === "prefer" ? findPaneById(input.layout.root, input.placement.paneId) : null; if (requestedPane) { - return requestedPane; + return requestedPane as SplitPaneInternal; } // `collectAllPanes` skips hidden panes, so a focused-but-hidden pane — the side @@ -1202,18 +1262,16 @@ function resolvePlacementPane(input: { focusedPane.id !== input.sidePanelPaneId || !SIDE_PANEL_EXCLUDED_TAB_KINDS.has(input.target.kind) ) { - return focusedPane; + return focusedPane as SplitPaneInternal; } // `collectAllPanes` skips hidden panes, so the chain falls back to the side panel // pane itself only when it is the sole visible pane. The side panel is the only // pane anything ever hides, so today that means "the side panel is the only pane". const panes = collectAllPanes(input.layout.root); - return ( - panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ?? + return (panes.find((pane) => pane.id === DEFAULT_PANE_ID && pane.id !== focusedPane.id) ?? panes.find((pane) => pane.id !== focusedPane.id) ?? - focusedPane - ); + focusedPane) as SplitPaneInternal; } function insertNewTabIntoPane( @@ -1235,6 +1293,23 @@ function insertNewTabIntoPane( ...(input.state !== undefined ? { state: input.state } : {}), }; + const currentTab = isSoleNewTabPane(targetPane) ? targetPane.tabs[0] : null; + if (currentTab && input.target.kind !== "new_tab") { + return { + tabId, + layout: withNormalizedParentTabMap({ + root: replaceTabInTree(layout.root, { + tabId: currentTab.tabId, + nextTabId: tabId, + target: input.target, + state: input.state, + }), + focusedPaneId: input.focus ? targetPane.id : layout.focusedPaneId, + parentTabIdByTabId: input.layout.parentTabIdByTabId, + }), + }; + } + const preservedFocusTabId = targetPane.focusedTabId ?? tabId; return { @@ -1252,6 +1327,9 @@ function insertNewTabIntoPane( } function findExistingTabForTarget(root: SplitNodeInternal, target: WorkspaceTabTarget) { + if (target.kind === "new_tab") { + return null; + } const registration = getPanelRegistration(target.kind); const targetResourceKey = registration?.resourceKey(target as never); const targetIdentity = targetResourceKey ?? buildDeterministicWorkspaceTabId(target); @@ -1582,7 +1660,13 @@ export function replaceTabTargetInLayout( }; } let tabId = input.createTabId(); - if (currentTab?.target.kind === "draft") { + // File navigation is a retarget of one File tab, not a new panel instance. Keeping its + // identity preserves the mounted tab track and the File tab's local presentation state. + if ( + currentTab?.target.kind === "new_tab" || + currentTab?.target.kind === "draft" || + (currentTab?.target.kind === "file" && input.target.kind === "file") + ) { tabId = input.tabId; } else if (input.target.kind === "draft") { tabId = buildDeterministicWorkspaceTabId(input.target); @@ -1770,7 +1854,7 @@ export function splitPaneEmptyInLayout( invariant(targetNode.kind === "pane", "Expected target pane"); const newPaneId = input.createNodeId("pane"); - const newPaneNode = createPaneNode({ id: newPaneId }); + const newPaneNode = createPaneNode({ id: newPaneId, tabs: [createNewWorkspaceTab()] }); const parentPath = targetPath.slice(0, -1); const targetIndex = targetPath[targetPath.length - 1] ?? 0; @@ -1818,6 +1902,17 @@ export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): Worksp return null; } + const sourceTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId); + const isMovingSoleNewTab = + sourcePane.id !== input.toPaneId && + sourcePane.tabIds.length === 1 && + sourceTab?.target.kind === "new_tab"; + if (isMovingSoleNewTab) { + return sourcePane.id === SIDE_PANEL_PANE_ID + ? setPaneHiddenInLayout({ layout, paneId: sourcePane.id, hidden: true }) + : closePaneInLayout({ layout, paneId: sourcePane.id }); + } + const detached = detachTabFromTree(layout.root, { tabId: input.tabId, // Dragging a pane's last tab elsewhere collapses that pane — except the side @@ -2194,6 +2289,36 @@ function addMissingEntityTabs(input: { return nextLayout; } +function seedDraftForEmptyWorkspace(input: { + layout: WorkspaceLayout; + snapshot: WorkspaceTabSnapshot; + activeAgentIds: Set; + knownTerminalIds: Set; + sidePanelPaneId: string | null; +}): WorkspaceLayout { + const ready = input.snapshot.agentsHydrated && input.snapshot.terminalsHydrated; + const creatingContent = + input.snapshot.hasActivePendingDraftCreate === true || + input.snapshot.hasActivePendingTerminalCreate === true; + const hasWorkspaceEntities = input.activeAgentIds.size > 0 || input.knownTerminalIds.size > 0; + const hasContentTab = collectAllTabs(input.layout.root).some( + (tab) => tab.target.kind !== "new_tab", + ); + if (!ready || creatingContent || hasWorkspaceEntities || hasContentTab) { + return input.layout; + } + + const draftId = generateDraftId(); + return createTabInLayout({ + layout: input.layout, + target: { kind: "draft", draftId }, + now: Date.now(), + placement: FOCUSED_PANE_PLACEMENT, + sidePanelPaneId: input.sidePanelPaneId, + createTabId: () => draftId, + }).layout; +} + export function reconcileWorkspaceTabs( state: WorkspaceTabReconcileState, snapshot: WorkspaceTabSnapshot, @@ -2282,6 +2407,14 @@ export function reconcileWorkspaceTabs( sidePanelPaneId: state.sidePanelPaneId, }); + nextLayout = seedDraftForEmptyWorkspace({ + layout: nextLayout, + snapshot, + activeAgentIds, + knownTerminalIds, + sidePanelPaneId: state.sidePanelPaneId, + }); + if (reconciledFocusedTabId) { nextLayout = focusTabInLayout({ diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 1a806d96ca..56a8f1c2fa 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -118,12 +118,52 @@ function collectTabIds(root: SplitNode): string[] { return collectAllTabs(root).map((tab) => tab.tabId); } +function collectContentTabs(root: SplitNode): WorkspaceTab[] { + return collectAllTabs(root).filter((tab) => tab.target.kind !== "new_tab"); +} + +function contentTabs(tabs: WorkspaceTab[]): WorkspaceTab[] { + return tabs.filter((tab) => tab.target.kind !== "new_tab"); +} + function expectGroup(node: SplitNode): Extract { expect(node.kind).toBe("group"); return node as Extract; } describe("workspace-layout-store helpers", () => { + it("creates independent random New tabs for the default panes", () => { + const layout = createWorkspaceLayoutWithSidePanel(); + const tabs = collectAllTabs(layout.root); + + expect(tabs).toHaveLength(2); + expect(tabs.map((tab) => tab.target)).toEqual([{ kind: "new_tab" }, { kind: "new_tab" }]); + expect(new Set(tabs.map((tab) => tab.tabId)).size).toBe(2); + }); + + it("discards persisted New tabs and restores each empty pane with a fresh identity", async () => { + const persisted = createWorkspaceLayoutWithSidePanel(); + const persistedIds = new Set(collectAllTabs(persisted.root).map((tab) => tab.tabId)); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { + layoutByWorkspace: { workspace: persisted }, + splitSizesByWorkspace: {}, + explorerPaneIdByWorkspace: { workspace: "explorer" }, + }, + version: 1, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + + const restoredTabs = collectAllTabs(restored.getState().layoutByWorkspace.workspace.root); + expect(restoredTabs).toHaveLength(2); + expect(new Set(restoredTabs.map((tab) => tab.tabId)).size).toBe(2); + expect(restoredTabs.every((tab) => !persistedIds.has(tab.tabId))).toBe(true); + }); + it("finds panes and tabs across nested groups", () => { const root: SplitNode = { kind: "group", @@ -400,7 +440,8 @@ describe("workspace-layout-store tree transforms", () => { const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] }); const emptied = removeTabFromTree(singlePaneRoot, "tab-a"); - expect(emptied).toEqual(createPane({ id: "main", tabIds: [], focusedTabId: null })); + expect(collectAllTabs(emptied)).toHaveLength(1); + expect(collectAllTabs(emptied)[0]?.target).toEqual({ kind: "new_tab" }); }); }); @@ -459,6 +500,7 @@ describe("workspace-layout-store actions", () => { const fileState: FileState = { treeVisible: true, treeWidth: 240 }; store.setTabState(workspaceKey, file, fileState); const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" }); + expect(sameKind).toBe(file); expect( collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find( (tab) => tab.tabId === sameKind, @@ -554,11 +596,9 @@ describe("workspace-layout-store actions", () => { { kind: "file", path: "/repo/b.ts" }, { ...defaultFileState, treeVisible: true, treeWidth: 300 }, ); - expect(replacement).not.toBe(file); + expect(replacement).toBe(file); const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined(); - expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent); - expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file); + expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent); store.closeTab(workspaceKey, replacement as string); expect( @@ -702,6 +742,74 @@ describe("workspace-layout-store actions", () => { }); }); + it("replaces a pane's sole New tab when real content opens", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const initialLayout = createWorkspaceLayoutWithSidePanel(); + workspaceLayoutStore.setState({ layoutByWorkspace: { [workspaceKey]: initialLayout } }); + const initialMainTabId = findPaneById(initialLayout.root, "main")?.focusedTabId; + + const agentTabId = store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "background", + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId]); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId); + expect(collectTabIds(layout.root)).not.toContain(initialMainTabId); + }); + + it("keeps a New tab's random identity when its launcher selects content", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const initialLayout = createWorkspaceLayoutWithSidePanel(); + workspaceLayoutStore.setState({ layoutByWorkspace: { [workspaceKey]: initialLayout } }); + const newTabId = findPaneById(initialLayout.root, "main")?.focusedTabId as string; + + const resultTabId = store.replaceTab(workspaceKey, newTabId, { kind: "files" }); + + expect(resultTabId).toBe(newTabId); + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([newTabId]); + expect(collectAllTabs(layout.root).find((tab) => tab.tabId === newTabId)?.target).toEqual({ + kind: "files", + }); + }); + + it("closes a split through its sole New tab but keeps the final visible split", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + useWorkspaceLayoutIds("new-pane", "new-group"); + const splitPaneId = store.splitPaneEmpty(workspaceKey, { + targetPaneId: "main", + position: "right", + }) as string; + const splitNewTabId = findPaneById( + workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, + splitPaneId, + )?.focusedTabId as string; + + store.closeTab(workspaceKey, splitNewTabId); + + let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, splitPaneId)).toBeNull(); + store.closeTab(workspaceKey, findPaneById(layout.root, "main")?.focusedTabId as string); + layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const finalNewTabId = findPaneById(layout.root, "main")?.focusedTabId as string; + const before = layout; + + store.closeTab(workspaceKey, finalNewTabId); + + expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before); + }); + it("migrates legacy layouts to include the hidden registered explorer pane", async () => { const legacyLayout = createDefaultLayout(); await AsyncStorage.setItem( @@ -1359,7 +1467,7 @@ describe("workspace-layout-store actions", () => { await restored.persist.rehydrate(); const layout = restored.getState().layoutByWorkspace.workspace; - expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ + expect(layout && collectContentTabs(layout.root).map((tab) => tab.target)).toEqual([ workspaceTarget, agentTarget, ]); @@ -1409,7 +1517,7 @@ describe("workspace-layout-store actions", () => { expect(secondTabId).toBe("file_/repo/worktree/b.ts"); expect(duplicateTabId).toBe(secondTabId); expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ + expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([ "file_/repo/worktree/a.ts", "file_/repo/worktree/b.ts", ]); @@ -1442,7 +1550,7 @@ describe("workspace-layout-store actions", () => { expect(firstTabId).toBe("file_/repo/worktree/a.ts"); expect(secondTabId).toBe(firstTabId); - expect(collectAllTabs(layout.root)).toEqual([ + expect(collectContentTabs(layout.root)).toEqual([ { tabId: "file_/repo/worktree/a.ts", target: { @@ -1702,7 +1810,7 @@ describe("workspace-layout-store actions", () => { expect(secondTabId).toBe("draft-2"); expect(firstTabId).not.toBe(secondTabId); expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]); - expect(collectAllTabs(layout.root)).toEqual([ + expect(collectContentTabs(layout.root)).toEqual([ { tabId: firstTabId, target: { kind: "draft", draftId: "draft-1" }, @@ -1926,7 +2034,7 @@ describe("workspace-layout-store actions", () => { expect(draftTabId).toBe("draft-retarget"); expect(nextTabId).toBe(draftTabId); expect(findPaneById(layout.root, "main")?.tabIds).toEqual([draftTabId!]); - expect(collectAllTabs(layout.root)).toEqual([ + expect(collectContentTabs(layout.root)).toEqual([ { tabId: draftTabId!, target: { kind: "file", path: "/repo/worktree/retargeted.ts" }, @@ -1956,7 +2064,7 @@ describe("workspace-layout-store actions", () => { expect(agentTabId).toBe("agent_agent-retarget"); expect(nextTabId).toBe("draft-from-agent"); expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["draft-from-agent"]); - expect(collectAllTabs(layout.root)).toEqual([ + expect(collectContentTabs(layout.root)).toEqual([ { tabId: "draft-from-agent", target: { kind: "draft", draftId: "draft-from-agent" }, @@ -2007,7 +2115,7 @@ describe("workspace-layout-store actions", () => { expect(draftTabId).toBe("draft-dup"); expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555"); expect(nextTabId).not.toBe(existingFileTabId); - expect(collectAllTabs(layout.root)).toHaveLength(3); + expect(collectContentTabs(layout.root)).toHaveLength(3); expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "file")).toHaveLength(2); }); @@ -2044,7 +2152,7 @@ describe("workspace-layout-store actions", () => { expect(firstAgentTabId).toBe(firstDraftTabId); expect(nextTabId).not.toBe(firstDraftTabId); - expect(collectAllTabs(layout.root)).toHaveLength(2); + expect(collectContentTabs(layout.root)).toHaveLength(2); expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "agent")).toHaveLength( 2, ); @@ -2370,6 +2478,49 @@ describe("workspace-layout-store actions", () => { ]); }); + it("moving a pane's sole New tab closes that pane instead of moving the tab", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const fileTabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }) as string; + const splitPaneId = store.splitPaneEmpty(workspaceKey, { + targetPaneId: "main", + position: "right", + }) as string; + const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const newTabId = findPaneById(before.root, splitPaneId)?.focusedTabId as string; + + store.moveTabToPane(workspaceKey, newTabId, "main"); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([fileTabId]); + expect(collectAllTabs(layout.root).some((tab) => tab.tabId === newTabId)).toBe(false); + }); + + it("moving the Side panel's sole New tab hides the panel instead of moving the tab", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const fileTabId = store.openTab({ + workspaceKey, + target: { kind: "file", path: "/repo/worktree/a.ts" }, + intent: "reveal", + }) as string; + const sidePanelPaneId = store.showSidePanel(workspaceKey) as string; + const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const newTabId = findPaneById(before.root, sidePanelPaneId)?.focusedTabId as string; + + store.moveTabToPane(workspaceKey, newTabId, "main"); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, sidePanelPaneId)?.hidden).toBe(true); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([fileTabId]); + expect(findPaneById(layout.root, sidePanelPaneId)?.tabIds).toEqual([newTabId]); + }); + it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => { useWorkspaceLayoutIds( "78787878-7878-7878-7878-787878787878", @@ -2461,7 +2612,7 @@ describe("workspace-layout-store actions", () => { expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab"); expect(duplicateTabId).toBe(secondTabId); expect(layout.focusedPaneId).toBe(splitPaneId); - expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ + expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([ "file_/repo/worktree/a.ts", "file_/repo/worktree/b.ts", ]); @@ -2622,7 +2773,7 @@ describe("workspace-layout-store actions", () => { expect(total).toBeCloseTo(1, 10); }); - it("closing the last tab keeps a single empty pane in the layout", () => { + it("closing the last content tab creates a fresh New tab in the retained pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -2634,7 +2785,12 @@ describe("workspace-layout-store actions", () => { store.closeTab(workspaceKey, tabId!); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(layout).toEqual(createWorkspaceLayoutWithSidePanel()); + const mainTab = collectAllTabs(layout.root).find( + (tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main", + ); + expect(mainTab?.target).toEqual({ kind: "new_tab" }); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId); + expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); it("keeps pinned archived agents in memory per workspace without persisting them", () => { @@ -2740,7 +2896,7 @@ describe("workspace-layout-store actions", () => { expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767"); expect(nextTabId).toBe("agent_agent-1"); - expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); + expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); expect(layout.focusedPaneId).toBe(splitPaneId); expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId); }); @@ -2859,7 +3015,7 @@ describe("workspace-layout-store actions", () => { const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(collectAllTabs(layout.root)).toEqual([ + expect(collectContentTabs(layout.root)).toEqual([ { tabId: "draft-agent", target: { kind: "agent", agentId: "agent-1" }, @@ -2889,7 +3045,47 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]); + }); + + it("reconcileTabs lands on an existing agent instead of the initial New tab", () => { + const workspaceKey = createWorkspaceKey(); + + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + knownAgentIds: ["agent-1"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["agent_agent-1"]); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1"); + }); + + it("reconcileTabs lands on a draft when the hydrated workspace is empty", () => { + const workspaceKey = createWorkspaceKey(); + + workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + const mainTab = collectAllTabs(layout.root).find( + (tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main", + ); + expect(mainTab?.target.kind).toBe("draft"); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId); }); it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => { @@ -2906,10 +3102,9 @@ describe("workspace-layout-store actions", () => { }); expect( - workspaceLayoutStore - .getState() - .getWorkspaceTabs(workspaceKey) - .map((tab) => tab.tabId), + contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map( + (tab) => tab.tabId, + ), ).toEqual(["agent_parent-agent"]); }); @@ -2934,10 +3129,9 @@ describe("workspace-layout-store actions", () => { }); expect( - workspaceLayoutStore - .getState() - .getWorkspaceTabs(workspaceKey) - .map((tab) => tab.tabId), + contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map( + (tab) => tab.tabId, + ), ).toEqual(["agent_child-agent", "agent_parent-agent"]); }); @@ -2962,10 +3156,9 @@ describe("workspace-layout-store actions", () => { }); expect( - workspaceLayoutStore - .getState() - .getWorkspaceTabs(workspaceKey) - .map((tab) => tab.tabId), + contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map( + (tab) => tab.tabId, + ), ).toEqual(["agent_parent-agent"]); }); @@ -2984,7 +3177,7 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]); store.openTab({ workspaceKey: workspaceKey, @@ -2994,7 +3187,7 @@ describe("workspace-layout-store actions", () => { const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); - expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toEqual([ "agent_child-agent", ]); }); @@ -3023,7 +3216,7 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - const tabs = workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey); + const tabs = contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]); expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId); @@ -3043,7 +3236,7 @@ describe("workspace-layout-store actions", () => { hasActivePendingDraftCreate: false, }); - expect(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]); }); it("explicitly opening an agent tab clears hidden intent", () => { @@ -3059,7 +3252,9 @@ describe("workspace-layout-store actions", () => { const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); - expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]); + expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toEqual([ + "agent_agent-1", + ]); }); it("pinning an agent clears hidden intent", () => { @@ -3095,9 +3290,12 @@ describe("workspace-layout-store actions", () => { standaloneTerminalIds: [], }); - expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ - "agent_archived-agent", - ]); + expect( + store + .getWorkspaceTabs(workspaceKey) + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.tabId), + ).toEqual(["agent_archived-agent"]); store.reconcileTabs(workspaceKey, { agentsHydrated: true, @@ -3108,9 +3306,12 @@ describe("workspace-layout-store actions", () => { standaloneTerminalIds: [], }); - expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ - "agent_archived-agent", - ]); + expect( + store + .getWorkspaceTabs(workspaceKey) + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.tabId), + ).toEqual(["agent_archived-agent"]); store.resolvePendingAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { @@ -3122,7 +3323,12 @@ describe("workspace-layout-store actions", () => { standaloneTerminalIds: [], }); - expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]); + expect( + store + .getWorkspaceTabs(workspaceKey) + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.tabId), + ).toEqual([]); store.openTab({ workspaceKey: workspaceKey, @@ -3139,9 +3345,12 @@ describe("workspace-layout-store actions", () => { standaloneTerminalIds: [], }); - expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ - "agent_archived-agent", - ]); + expect( + store + .getWorkspaceTabs(workspaceKey) + .filter((tab) => tab.target.kind === "agent") + .map((tab) => tab.tabId), + ).toEqual(["agent_archived-agent"]); }); it("retargeting a tab to an agent clears hidden intent", () => { @@ -3219,7 +3428,7 @@ describe("workspace-layout-store actions", () => { store.closePane(workspaceKey, "main"); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([keptTabId]); + expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([keptTabId]); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId]); }); @@ -3266,7 +3475,7 @@ describe("workspace-layout-store actions", () => { ).toBeUndefined(); }); - it("closing the final Side panel tab hides its empty pane until it is revealed again", () => { + it("closing the final Side panel content tab leaves New tab hidden until reveal", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); store.openTab({ @@ -3285,12 +3494,17 @@ describe("workspace-layout-store actions", () => { store.closeTab(workspaceKey, filesTabId); let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [], hidden: true }); + const hiddenPane = findPaneById(layout.root, paneId); + expect(hiddenPane?.hidden).toBe(true); + expect(hiddenPane?.tabIds).toHaveLength(1); + expect( + collectAllTabs(layout.root).find((tab) => tab.tabId === hiddenPane?.focusedTabId)?.target, + ).toEqual({ kind: "new_tab" }); expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); expect(store.showSidePanel(workspaceKey)).toBe(paneId); layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] }); + expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(hiddenPane?.tabIds); expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); }); @@ -3323,7 +3537,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(["working_diff"]); }); - it("closing the final Side panel tab keeps the final visible pane on screen", () => { + it("closing the final Side panel content tab keeps New tab on screen", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); store.openTab({ @@ -3344,7 +3558,11 @@ describe("workspace-layout-store actions", () => { const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([paneId]); - expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] }); + const finalPane = findPaneById(layout.root, paneId); + expect(finalPane?.tabIds).toHaveLength(1); + expect( + collectAllTabs(layout.root).find((tab) => tab.tabId === finalPane?.focusedTabId)?.target, + ).toEqual({ kind: "new_tab" }); expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); }); @@ -3408,7 +3626,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneContainingTab(layout.root, fileTabId)?.id).toBe("main"); expect(layout.focusedPaneId).toBe("main"); }); - it("leaves the side panel behind, empty, when its last tab is claimed by another pane", () => { + it("leaves New tab in the Side panel when its content is claimed by another pane", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); store.openTab({ @@ -3433,7 +3651,11 @@ describe("workspace-layout-store actions", () => { const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; expect(findPaneContainingTab(layout.root, changesTabId)?.id).toBe("main"); - expect(findPaneById(layout.root, paneId)).toMatchObject({ tabIds: [] }); + const sidePane = findPaneById(layout.root, paneId); + expect(sidePane?.tabIds).toHaveLength(1); + expect( + collectAllTabs(layout.root).find((tab) => tab.tabId === sidePane?.focusedTabId)?.target, + ).toEqual({ kind: "new_tab" }); expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined(); }); it("closePane refuses to hide the side panel once it is the only visible pane", () => { diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index ecc1c1c977..8b4b0f2a75 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -36,6 +36,7 @@ import { openTabInLayoutBackground, replaceTabTargetInLayout, revealTargetInLayout, + restoreEmptyPanesInLayout, reconcileWorkspaceTabs, removePaneFromTree, removeTabFromTree, @@ -174,6 +175,7 @@ const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), }); const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("new_tab") }), z.strictObject({ kind: z.literal("draft"), draftId: z.string(), @@ -605,6 +607,29 @@ export function createWorkspaceLayoutStore( state.sidePanelPaneIdByWorkspace[normalizedWorkspaceKey], ); const closingPane = findPaneContainingTab(layout.root, normalizedTabId); + const closingTab = collectAllTabs(layout.root).find( + (tab) => tab.tabId === normalizedTabId, + ); + if (closingPane?.tabIds.length === 1 && closingTab?.target.kind === "new_tab") { + const nextLayout = + closingPane.id === sidePanelPaneId + ? setPaneHiddenInLayout({ + layout, + paneId: closingPane.id, + hidden: true, + }) + : closePaneInLayout({ layout, paneId: closingPane.id }); + if (!nextLayout) { + return state; + } + return { + ...withoutFocusRestoration(state, normalizedWorkspaceKey), + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: nextLayout, + }, + }; + } const preserveEmptyPaneId = closingPane?.id === "main" || closingPane?.id === sidePanelPaneId ? closingPane.id @@ -1304,13 +1329,16 @@ export function createWorkspaceLayoutStore( for (const [workspaceKey, persistedLayout] of Object.entries( result.data.layoutByWorkspace, )) { + const restoredLayout = restoreEmptyPanesInLayout( + stripEphemeralTabsFromLayout(persistedLayout), + ); const sidePanel = ensurePersistedSidePanelPane({ - layout: normalizeLayout(persistedLayout), + layout: restoredLayout, registeredPaneId: sidePanelPaneIdByWorkspace[workspaceKey], ids, }); if (!sidePanel) { - layoutByWorkspace[workspaceKey] = normalizeLayout(persistedLayout); + layoutByWorkspace[workspaceKey] = restoredLayout; continue; } layoutByWorkspace[workspaceKey] = sidePanel.layout; diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index f56bac931c..0cbcfb5191 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -114,10 +114,11 @@ function reconcileWorkspaceTabs(workspaceKey: string, visibility: WorkspaceAgent ); } -function getWorkspaceTabIds(workspaceKey: string): string[] { +function getWorkspaceContentTabIds(workspaceKey: string): string[] { return useWorkspaceLayoutStore .getState() .getWorkspaceTabs(workspaceKey) + .filter((tab) => tab.target.kind !== "new_tab") .map((tab) => tab.tabId); } @@ -153,13 +154,13 @@ describe("workspace subagents integration", () => { reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); - expect(getWorkspaceTabIds(workspaceKey!)).toEqual([]); + expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual([]); appendAgent(parent); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); - expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); + expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); expect( selectSubagentsForParent( useSessionStore.getState(), @@ -192,7 +193,7 @@ describe("workspace subagents integration", () => { initializeAgents([parent, child]); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); - expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); + expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); expect( selectSubagentsForParent( useSessionStore.getState(), @@ -207,7 +208,10 @@ describe("workspace subagents integration", () => { appendAgent({ ...child, parentAgentId: null, labels: {} }); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); - expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent", "agent_child-agent"]); + expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual([ + "agent_parent-agent", + "agent_child-agent", + ]); expect( selectSubagentsForParent( useSessionStore.getState(), @@ -241,7 +245,7 @@ describe("workspace subagents integration", () => { initializeAgents([parent, child]); reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); - expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_child-agent"]); + expect(getWorkspaceContentTabIds(workspaceKey!)).toEqual(["agent_child-agent"]); expect( selectSubagentsForParent( useSessionStore.getState(), diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index d807701c16..fa49aca69a 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -329,7 +329,7 @@ export function createMarkdownStyles(theme: Theme) { hr: { backgroundColor: theme.colors.border, height: 1, - marginVertical: theme.spacing[6], + marginVertical: 10, }, // ========================================================================= diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index f158178e14..2b189e7faf 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -464,6 +464,24 @@ test("manual retries can immediately re-attempt a failed catch-up", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); +test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + + const failed = await world.nextFetch("agent-a"); + failed.fail("timeline unavailable"); + await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); +}); + test("a manual retry that fails returns to the error state", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -586,6 +604,22 @@ test("membership failures retry with exponential backoff", async () => { await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); +test("redeclaring unchanged visibility does not bypass membership backoff", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + const failed = await world.nextMembership(); + failed.fail("subscription unavailable"); + await world.nextRetry(); + + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + + world.expectNoPendingMembership(); + world.expectNoPendingFetch(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); +}); + test("backgrounding preserves the hot membership without catch-up on return", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 110ce0acee..cf90918c11 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -365,12 +365,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } }; - const retryFailedCatchUps = () => { - for (const agentId of acknowledged) { - if (catchUps.get(agentId)?.status === "error") startCatchUp(agentId); - } - }; - const retryVisibleAgentTimeline = (agentId: string) => { if (!isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); @@ -407,8 +401,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } if (sameAgentIds(nextDesired, desired)) { if (statusChanged) notifyListeners(); - if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership(); - retryFailedCatchUps(); return; } diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 1e0f0f87c3..2d81158c81 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -1,3 +1,4 @@ +import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import { type Agent, useSessionStore } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; @@ -187,13 +188,20 @@ export function replaceFetchedAgentDirectory(input: { } } - store.setAgents(input.serverId, fetchedAgents); - for (const agent of fetchedAgents.values()) { + const currentAgents = store.sessions[input.serverId]?.agents ?? new Map(); + const agents = new Map(); + for (const [agentId, fetchedAgent] of fetchedAgents) { + const current = currentAgents.get(agentId); + agents.set(agentId, current && equal(current, fetchedAgent) ? current : fetchedAgent); + } + + store.setAgents(input.serverId, agents); + for (const agent of agents.values()) { applyAgentTurnSnapshot(input.serverId, agent); } store.setAgentDetails(input.serverId, (prev) => { let next: Map | null = null; - for (const agentId of fetchedAgents.keys()) { + for (const agentId of agents.keys()) { if (!prev.has(agentId)) { continue; } @@ -204,12 +212,12 @@ export function replaceFetchedAgentDirectory(input: { }); const lastActivityByAgentId = new Map(); - for (const agent of fetchedAgents.values()) { + for (const agent of agents.values()) { lastActivityByAgentId.set(agent.id, agent.lastActivityAt); } store.setAgentLastActivityBatch(lastActivityByAgentId); store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); store.setHasHydratedAgents(input.serverId, true); - return { agents: fetchedAgents }; + return { agents }; } diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index b2859000ae..425acdb2d1 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -5,6 +5,18 @@ import { workspaceTabTargetsEqual, } from "./identity"; +describe("New tab identity", () => { + it("stays outside deterministic target identity", () => { + const target = { kind: "new_tab" } as const; + + expect(normalizeWorkspaceTabTarget(target)).toEqual(target); + expect(workspaceTabTargetsEqual(target, target)).toBe(false); + expect(() => buildDeterministicWorkspaceTabId(target)).toThrow( + "New tabs do not have deterministic target identities", + ); + }); +}); + describe("provider subagent tab identity", () => { test("normalizes and compares the parent and provider child as one tab identity", () => { const target = normalizeWorkspaceTabTarget({ diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index fc10fc105e..6e01210d14 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -15,6 +15,9 @@ export function normalizeWorkspaceTabTarget( const setup = normalizeWorkspaceDraftTabSetup(value.setup); return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId }; } + if (value.kind === "new_tab") { + return { kind: "new_tab" }; + } if (value.kind === "agent") { const agentId = trimNonEmpty(value.agentId); return agentId ? { kind: "agent", agentId } : null; @@ -185,6 +188,9 @@ function recordsShallowEqual( } export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string { + if (target.kind === "new_tab") { + throw new Error("New tabs do not have deterministic target identities"); + } if (target.kind === "draft") { return target.draftId; } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx new file mode 100644 index 0000000000..402c8554cd --- /dev/null +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -0,0 +1,209 @@ +import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; +import { useRouter, type Href } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { + FileDiff, + FolderTree, + GitPullRequest, + Globe, + SquarePen, + SquareTerminal, + type LucideIcon, +} from "lucide-react-native"; +import invariant from "tiny-invariant"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { resolvePluginIcon } from "@/plugins/icons"; +import { useInstalledPlugins } from "@/plugins/registry"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; +import type { NewTabSelection } from "@/workspace-tabs/new-tab"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { + getTerminalProfileIcon, + resolveTerminalProfiles, +} from "@getpaseo/protocol/terminal-profiles"; +import { getBuiltInLaunchOrder, type BuiltInLaunchItemId } from "./internal/catalog"; + +export type WorkspaceTabLaunchPurpose = "primary" | "supporting"; + +export type WorkspaceTabLaunchDestination = + | { kind: "open"; paneId?: string } + | { kind: "replace"; tabId: string }; + +export interface NewTabLauncher { + showChanges: boolean; + showPullRequest: boolean; + showBrowser: boolean; + terminalDisabled: boolean; + launch: (selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => void; +} + +export interface WorkspaceTabLaunchItem { + id: string; + label: string; + Icon?: LucideIcon; + terminalIconKey?: string; + shortcutActionId?: string; + disabled: boolean; + launch: (destination: WorkspaceTabLaunchDestination) => void; +} + +export interface WorkspaceTabLaunchGroup { + id: "tabs" | "terminal-profiles"; + label: string | null; + items: readonly WorkspaceTabLaunchItem[]; + accessory?: { id: string; label: string; run: () => void }; +} + +const NewTabLauncherContext = createContext(null); + +export function NewTabLauncherProvider({ + value, + children, +}: { + value: NewTabLauncher; + children: ReactNode; +}) { + return {children}; +} + +const BUILT_IN_SELECTIONS: Record = { + agent: { kind: "agent" }, + terminal: { kind: "terminal" }, + changes: { kind: "target", target: { kind: "working_diff" } }, + files: { kind: "target", target: { kind: "files" } }, + browser: { kind: "browser" }, + pullRequest: { kind: "target", target: { kind: "pull_request" } }, +}; + +export function useWorkspaceTabLaunchCatalog(input: { + serverId: string; + purpose: WorkspaceTabLaunchPurpose; +}): readonly WorkspaceTabLaunchGroup[] { + const { serverId, purpose } = input; + const { t } = useTranslation(); + const router = useRouter(); + const launcher = useContext(NewTabLauncherContext); + invariant(launcher, "NewTabLauncherProvider is required"); + const { config } = useDaemonConfig(serverId); + const plugins = useInstalledPlugins(); + + const launchSelection = useCallback( + (selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => { + launcher.launch(selection, destination); + }, + [launcher], + ); + const editTerminalProfiles = useCallback(() => { + router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href); + }, [router, serverId]); + + return useMemo(() => { + const builtIns: Record = { + agent: { + id: "agent", + label: t("workspace.tabs.fallback.agent"), + Icon: SquarePen, + shortcutActionId: "workspace-tab-target-agent", + disabled: false, + launch: launchSelection(BUILT_IN_SELECTIONS.agent), + }, + terminal: { + id: "terminal", + label: t("workspace.tabs.fallback.terminal"), + Icon: SquareTerminal, + shortcutActionId: "workspace-terminal-new", + disabled: launcher.terminalDisabled, + launch: launchSelection(BUILT_IN_SELECTIONS.terminal), + }, + changes: { + id: "changes", + label: t("workspace.tabs.actions.changes"), + Icon: FileDiff, + shortcutActionId: "workspace-tab-target-changes", + disabled: false, + hidden: !launcher.showChanges, + launch: launchSelection(BUILT_IN_SELECTIONS.changes), + }, + files: { + id: "files", + label: t("workspace.tabs.actions.files"), + Icon: FolderTree, + shortcutActionId: "workspace-tab-target-files", + disabled: false, + launch: launchSelection(BUILT_IN_SELECTIONS.files), + }, + browser: { + id: "browser", + label: t("workspace.tabs.fallback.browser"), + Icon: Globe, + shortcutActionId: "workspace-tab-target-browser", + disabled: false, + hidden: !launcher.showBrowser, + launch: launchSelection(BUILT_IN_SELECTIONS.browser), + }, + pullRequest: { + id: "pull-request", + label: t("workspace.tabs.actions.pullRequest"), + Icon: GitPullRequest, + disabled: false, + hidden: !launcher.showPullRequest, + launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest), + }, + }; + const tabItems = getBuiltInLaunchOrder(purpose).flatMap((id) => { + const item = builtIns[id]; + return item.hidden ? [] : [item]; + }); + + for (const plugin of plugins) { + if (plugin.serverId !== serverId) continue; + for (const panel of plugin.workspacePanels) { + if (panel.context !== "workspace") continue; + const selection: NewTabSelection = { + kind: "target", + target: { kind: "plugin", pluginId: plugin.id, panelId: panel.id, context: "workspace" }, + }; + tabItems.push({ + id: `plugin:${plugin.id}:${panel.id}`, + label: panel.title, + Icon: resolvePluginIcon(panel.icon), + disabled: false, + launch: launchSelection(selection), + }); + } + } + + const profiles = resolveTerminalProfiles(config?.terminalProfiles); + const groups: WorkspaceTabLaunchGroup[] = [{ id: "tabs", label: null, items: tabItems }]; + if (profiles.length > 0) { + groups.push({ + id: "terminal-profiles", + label: t("workspace.tabs.actions.terminalProfilesMenu"), + items: profiles.map((profile: TerminalProfile) => ({ + id: `terminal-profile:${profile.id}`, + label: profile.name, + terminalIconKey: getTerminalProfileIcon(profile), + disabled: launcher.terminalDisabled, + launch: launchSelection({ kind: "terminal", profile }), + })), + accessory: { + id: "edit-terminal-profiles", + label: t("workspace.tabs.actions.editTerminalProfiles"), + run: editTerminalProfiles, + }, + }); + } + return groups; + }, [ + config?.terminalProfiles, + editTerminalProfiles, + launchSelection, + launcher, + plugins, + purpose, + serverId, + t, + ]); +} + +export { getBuiltInLaunchOrder } from "./internal/catalog"; diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts new file mode 100644 index 0000000000..420ffdccfb --- /dev/null +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { getBuiltInLaunchOrder } from "./catalog"; + +describe("getBuiltInLaunchOrder", () => { + it("leads with creating work in a primary pane", () => { + expect(getBuiltInLaunchOrder("primary")).toEqual([ + "agent", + "terminal", + "changes", + "files", + "browser", + "pullRequest", + ]); + }); + + it("leads with companion tools in a supporting pane", () => { + expect(getBuiltInLaunchOrder("supporting")).toEqual([ + "changes", + "files", + "terminal", + "agent", + "browser", + "pullRequest", + ]); + }); +}); diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts new file mode 100644 index 0000000000..a2d55dbb90 --- /dev/null +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts @@ -0,0 +1,23 @@ +export const PRIMARY_LAUNCH_ORDER = [ + "agent", + "terminal", + "changes", + "files", + "browser", + "pullRequest", +] as const; + +export const SUPPORTING_LAUNCH_ORDER = [ + "changes", + "files", + "terminal", + "agent", + "browser", + "pullRequest", +] as const; + +export type BuiltInLaunchItemId = (typeof PRIMARY_LAUNCH_ORDER)[number]; + +export function getBuiltInLaunchOrder(purpose: "primary" | "supporting") { + return purpose === "supporting" ? SUPPORTING_LAUNCH_ORDER : PRIMARY_LAUNCH_ORDER; +} diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index b059b3b2ac..0bda48a708 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -33,6 +33,7 @@ export type PluginWorkspaceTabTarget = }; export type WorkspaceTabTarget = + | { kind: "new_tab" } | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } | { kind: "agent"; agentId: string } | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } diff --git a/packages/app/src/workspace-tabs/new-tab.ts b/packages/app/src/workspace-tabs/new-tab.ts new file mode 100644 index 0000000000..f667f4d6c0 --- /dev/null +++ b/packages/app/src/workspace-tabs/new-tab.ts @@ -0,0 +1,17 @@ +import { generateMessageId } from "@/types/stream"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; + +export type NewTabSelection = + | { kind: "target"; target: WorkspaceTab["target"] } + | { kind: "agent" } + | { kind: "terminal"; profile?: TerminalProfile } + | { kind: "browser" }; + +export function createNewWorkspaceTab(): WorkspaceTab { + return { + tabId: `tab_${generateMessageId()}`, + target: { kind: "new_tab" }, + createdAt: Date.now(), + }; +} diff --git a/packages/app/src/workspace-tabs/side-panel.test.ts b/packages/app/src/workspace-tabs/side-panel.test.ts index a874e1b8a7..e2a7430876 100644 --- a/packages/app/src/workspace-tabs/side-panel.test.ts +++ b/packages/app/src/workspace-tabs/side-panel.test.ts @@ -31,6 +31,7 @@ import { openSupportingTab, showSidePanel, toggleSidePanel, + toggleSupportingTab, } from "@/workspace-tabs/side-panel"; const WORKSPACE_KEY = "server-1:ws-main"; @@ -75,6 +76,18 @@ beforeEach(() => { describe("compact surface", () => { const compact = { isCompact: true, workspaceKey: WORKSPACE_KEY, checkout: CHECKOUT }; + it("opens Changes in the explorer overlay without creating a workspace tab", () => { + toggleSupportingTab({ + ...compact, + target: { kind: "working_diff" }, + openInSidePanelByDefault: true, + }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + expect(usePanelStore.getState().explorerTab).toBe("changes"); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + }); + it("opens and closes the overlay without touching the layout", () => { openSidePanelView({ ...compact, view: "files" }); @@ -101,11 +114,11 @@ describe("non-compact with splits", () => { checkout: CHECKOUT, }; - it("reveals an empty pane rather than seeding a view into it", () => { + it("reveals a New tab pane rather than seeding a supporting view into it", () => { toggleSidePanel(wide); expect(isSidePanelOpen(wide)).toBe(true); - expect(tabKinds()).toEqual([]); + expect(tabKinds()).toEqual(["new_tab", "new_tab"]); expect(collectAllPanes(layout().root).length).toBeGreaterThan(1); }); @@ -203,7 +216,28 @@ describe("non-compact with splits", () => { hideSidePanel(wide); expect(isSidePanelOpen(wide)).toBe(false); - expect(tabKinds()).toEqual(["files"]); + expect(tabKinds()).toEqual(["new_tab", "files"]); + }); + + it("toggles the visible supporting target without closing its tab", () => { + const changes = { + ...wide, + target: { kind: "working_diff" } as const, + openInSidePanelByDefault: true, + }; + const tabId = toggleSupportingTab(changes); + openSidePanelView({ ...wide, view: "files" }); + + toggleSupportingTab(changes); + expect(isSidePanelOpen(wide)).toBe(true); + expect(focusedPaneTabKinds()).toContain("working_diff"); + + toggleSupportingTab(changes); + expect(isSidePanelOpen(wide)).toBe(false); + expect(tabKinds()).toContain("working_diff"); + + expect(toggleSupportingTab(changes)).toBe(tabId); + expect(isSidePanelOpen(wide)).toBe(true); }); }); diff --git a/packages/app/src/workspace-tabs/side-panel.ts b/packages/app/src/workspace-tabs/side-panel.ts index e77fd5ee78..da154529a4 100644 --- a/packages/app/src/workspace-tabs/side-panel.ts +++ b/packages/app/src/workspace-tabs/side-panel.ts @@ -14,6 +14,7 @@ import { type WorkspaceTabPlacement, } from "@/stores/workspace-layout-store"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; /** * The side panel is the companion surface beside the user's work — Changes, Files, @@ -62,6 +63,20 @@ function canUseSidePanelPane(input: SidePanelQuery): boolean { return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits()); } +function isTargetVisibleInSidePanel( + state: LayoutState, + workspaceKey: string, + target: WorkspaceTabTarget, +): boolean { + if (!selectIsSidePanelVisible(state, workspaceKey)) return false; + const layout = state.layoutByWorkspace[workspaceKey]; + const paneId = selectSidePanelPaneId(state, workspaceKey); + const focusedTabId = layout && paneId ? findPaneById(layout.root, paneId)?.focusedTabId : null; + if (!layout || !focusedTabId) return false; + const focusedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === focusedTabId); + return Boolean(focusedTab && workspaceTabTargetsEqual(focusedTab.target, target)); +} + // ─── Side panel tabs (non-compact, no splits) ────────────────────────────── /** @@ -92,6 +107,13 @@ export interface OpenSupportingTabInput extends SidePanelQuery { background?: boolean; } +type SidePanelViewTarget = Extract; + +export interface ToggleSupportingTabInput extends SidePanelInput { + target: SidePanelViewTarget; + openInSidePanelByDefault: boolean; +} + /** * Opens a tab the user did not place themselves — an agent's file link, a detected * pull request, workspace setup progress. A supporting tab that is already open is @@ -123,6 +145,67 @@ export function openSupportingTab(input: OpenSupportingTabInput): string | null }); } +/** Toggle a supporting target when it is the visible Side panel tab; otherwise reveal it. */ +export function toggleSupportingTab(input: ToggleSupportingTabInput): string | null { + if (input.isCompact) { + openSidePanelView({ ...input, view: explorerViewForTarget(input.target) }); + return null; + } + if (!input.workspaceKey) return null; + const store = useWorkspaceLayoutStore.getState(); + if ( + input.openInSidePanelByDefault && + canUseSidePanelPane(input) && + isTargetVisibleInSidePanel(store, input.workspaceKey, input.target) + ) { + store.hideSidePanel(input.workspaceKey); + return null; + } + return openSupportingTab(input); +} + +function explorerViewForTarget(target: SidePanelViewTarget): ExplorerTab { + switch (target.kind) { + case "working_diff": + return "changes"; + case "files": + return "files"; + case "pull_request": + return "pr"; + } +} + +/** Explicitly place a companion tab in the Side panel, moving an existing tab there. */ +export function openTabInSidePanel( + input: SidePanelInput & { target: WorkspaceTabTarget }, +): string | null { + if (!input.workspaceKey) { + return null; + } + + if (input.isCompact) { + const view = Object.entries(SIDE_PANEL_VIEW_TARGETS).find( + ([, target]) => target.kind === input.target.kind, + )?.[0] as ExplorerTab | undefined; + if (!view || !input.checkout) { + return null; + } + const panel = usePanelStore.getState(); + panel.setExplorerTabForCheckout({ ...input.checkout, tab: view }); + panel.openCompactFileExplorer(input.checkout); + return null; + } + + const store = useWorkspaceLayoutStore.getState(); + const paneId = canUseSidePanelPane(input) ? store.showSidePanel(input.workspaceKey) : undefined; + return store.openTab({ + workspaceKey: input.workspaceKey, + target: input.target, + intent: "reveal", + placement: paneId ? { mode: "pane", paneId } : undefined, + }); +} + /** * Undefined means "no opinion, use the focused pane". Revealing is part of resolving * the side panel: a focused tab in a hidden pane is nothing the user can see, so a diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c553543dc..03828e3297 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.5.0-beta.4", - "@getpaseo/protocol": "0.5.0-beta.4", - "@getpaseo/server": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", + "@getpaseo/protocol": "0.5.1", + "@getpaseo/server": "0.5.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index f4948b3206..8c777fc1c4 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -120,6 +120,52 @@ describe("Hub HTTP client", () => { assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); }); + it("reads strict provider-native setup resources", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body: { + github: [ + { + slug: "getpaseo", + accountLogin: "getpaseo", + accountType: "Organization", + repositories: ["getpaseo/paseo"], + }, + ], + discord: [{ guildId: "guild-123", guildName: "Paseo" }], + slack: [{ teamId: "team-123", teamName: "Paseo" }], + }, + }), + requests, + ); + + const resources = await new HubHttpClient().listSetupResources(origin, "secret"); + + assert.equal(resources.slack[0]?.teamId, "team-123"); + assert.equal(resources.discord[0]?.guildId, "guild-123"); + assert.deepEqual(requests[0], { url: "/api/v1/setup-resources", body: "" }); + }); + + it.each([ + { github: [], discord: [], slack: [{ teamId: "team-123", teamName: "Paseo", slug: "wrong" }] }, + { github: [], discord: [], slack: [{ teamId: 123, teamName: "Paseo" }] }, + ])("rejects malformed or unknown setup resource fields", async (body) => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body, + }), + requests, + ); + + await assert.rejects(new HubHttpClient().listSetupResources(origin, "secret"), { + code: "HUB_INVALID_RESPONSE", + }); + }); + it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index 0857bd1971..e2950748a9 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -87,10 +87,68 @@ describe("Hub commands", () => { assert.deepEqual(events, [ "progress:Logging in to https://hub.paseo.sh", "authorize:https://hub.paseo.sh", + "progress:Logged in", ]); assert.equal(result.data.origin, "https://hub.paseo.sh"); }); + it("interactive login continues through the injected guided setup coordinator without invoking a CLI command", async () => { + const credentials = new MemoryCredentials(); + const events: string[] = []; + + await runHubLogin( + "https://hub.test", + {}, + { + env: {}, + credentials, + flow: { + authorize: async () => { + events.push("login"); + return "paseo_cli_prefix_durable-secret"; + }, + }, + isInteractive: () => true, + continueGuidedSetup: async (origin) => { + events.push(`connect:${origin}`); + events.push("init"); + events.push("deploy"); + }, + reporter: { progress: (message) => events.push(`progress:${message}`) }, + }, + ); + + assert.deepEqual(events, [ + "progress:Logging in to https://hub.test", + "login", + "progress:Logged in", + "connect:https://hub.test", + "init", + "deploy", + ]); + }); + + it("JSON and noninteractive login remain login-only", async () => { + for (const [options, interactive] of [ + [{ json: true }, true], + [{}, false], + ] as const) { + const credentials = new MemoryCredentials(); + let continuationCount = 0; + await runHubLogin(undefined, options, { + env: {}, + credentials, + flow: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + isInteractive: () => interactive, + continueGuidedSetup: async () => { + continuationCount += 1; + }, + reporter: quietReporter, + }); + assert.equal(continuationCount, 0); + } + }); + it("connect exchanges authority once and gives only the enrollment token to the daemon", async () => { const credentials = new MemoryCredentials(); credentials.save({ origin: "https://hub.test", credential: "stored-human-secret" }); @@ -484,6 +542,10 @@ class FakeDaemon implements HubDaemonClient { return { status: hubStatus("connected", this.origin) }; } + async getProvidersSnapshot() { + return { entries: [] }; + } + async disconnectHub(force: boolean) { this.disconnects += 1; this.disconnectForces.push(force); diff --git a/packages/cli/src/commands/hub/daemon-client.ts b/packages/cli/src/commands/hub/daemon-client.ts index 88ced89182..c7eefbd60a 100644 --- a/packages/cli/src/commands/hub/daemon-client.ts +++ b/packages/cli/src/commands/hub/daemon-client.ts @@ -1,4 +1,5 @@ import { connectToDaemon } from "../../utils/client.js"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; export interface HubStatus { state: string; @@ -9,10 +10,17 @@ export interface HubStatus { lastError: string | null; } +export interface HubProvidersSnapshotOptions { + cwd?: string; +} + export interface HubDaemonClient { connectHub(url: string, token: string): Promise<{ status: HubStatus }>; getHubStatus(): Promise<{ status: HubStatus }>; disconnectHub(force: boolean): Promise<{ status: HubStatus; warning?: string }>; + getProvidersSnapshot( + options?: HubProvidersSnapshotOptions, + ): Promise<{ entries: ProviderSnapshotEntry[] }>; close(): Promise; } diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index 3086d648f2..3f87ab3dc0 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -12,6 +12,8 @@ import { type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, + setupResourcesSchema, + type HubSetupResources, type HubProject, type HubValidationResult, } from "./internal/contracts.js"; @@ -22,6 +24,7 @@ export type { CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, + HubSetupResources, HubProject, HubValidationResult, } from "./internal/contracts.js"; @@ -95,6 +98,18 @@ export class HubHttpClient { }); } + listSetupResources(origin: string, apiKey: string): Promise { + return requestHub({ + origin, + path: "/api/v1/setup-resources", + method: "GET", + apiKey, + successStatus: 200, + schema: setupResourcesSchema, + failureMessage: "Hub guided setup resource listing failed", + }); + } + installConfiguration(input: HubConfigurationInput): Promise { return requestHub({ origin: input.origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index 82b7e3ca58..2efa77aff3 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -54,6 +54,25 @@ export const configurationResourcesSchema = z }) .strict(); +export const setupResourcesSchema = z + .object({ + github: z.array( + z + .object({ + slug: z.string().min(1), + accountLogin: z.string().min(1), + accountType: z.string().min(1), + repositories: z.array(z.string().min(1)), + }) + .strict(), + ), + discord: z.array( + z.object({ guildId: z.string().min(1), guildName: z.string().min(1) }).strict(), + ), + slack: z.array(z.object({ teamId: z.string().min(1), teamName: z.string().min(1) }).strict()), + }) + .strict(); + export const installResponseSchema = z .object({ projectSlug: z.string().min(1), @@ -75,5 +94,6 @@ export type CliAuthorization = z.infer; export type CliAuthorizationPoll = z.infer; export type HubProject = z.infer; export type HubConfigurationResources = z.infer; +export type HubSetupResources = z.infer; export type HubInstallResult = z.infer; export type HubValidationResult = z.infer; diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 3edd48ef3f..d4921835de 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -18,7 +18,7 @@ import { addHubProjectsCommand } from "./projects.js"; import { processHubReporter, type HubReporter } from "./reporter.js"; import { hubStatusResult } from "./status-output.js"; import { addHubResolutionHelp } from "./help.js"; -import { addHubInitCommand } from "./init.js"; +import { addHubInitCommand, continueHubGuidedSetup } from "./init.js"; interface HubCommandEnvironment { env: Readonly>; @@ -56,6 +56,8 @@ export function createHubCommand(overrides: Partial = {}) credentials: environment.credentials, flow: environment.login, reporter: environment.reporter, + isInteractive: environment.isInteractive, + continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), }); addHubInitCommand(hub, environment); addHubConnectCommand(hub, { diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts new file mode 100644 index 0000000000..f43b873935 --- /dev/null +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -0,0 +1,445 @@ +import { strict as assert } from "node:assert"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, it } from "vitest"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { HubCredentialStore, StoredHubCredential } from "./credentials.js"; +import type { HubDaemonClient, HubStatus } from "./daemon-client.js"; +import type { HubHttpClient } from "./hub-client/index.js"; +import { + continueHubGuidedSetup, + runHubGuidedSetup, + type HubGuidedSetupEnvironment, +} from "./init.js"; +import { runHubLogin } from "./login.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("Hub guided setup continuation", () => { + it("uses the login origin and connected daemon to validate, write, and deploy without replaying setup prompts", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + const daemon = new SetupDaemon(); + const prompts = new PromptAnswers([true, true], ["codex", "gpt-5", "full-access"], ["U123"]); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; + const environment = setupEnvironment(cwd, credentials, daemon, prompts, calls); + + await runHubLogin( + "https://hub.test", + {}, + { + env: {}, + credentials, + flow: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + isInteractive: () => true, + continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), + reporter: { progress() {} }, + }, + ); + + assert.deepEqual(prompts.confirmations, [ + "Connect this daemon to this Hub?", + "Initialize and deploy a starter workflow?", + ]); + assert.deepEqual(prompts.selections, [ + "Starter agent provider", + "Starter agent model", + "Starter agent mode", + ]); + assert.deepEqual(prompts.selectionOptions, [ + ["Codex"], + ["GPT-5 (suggested)", "GPT-5 mini"], + ["Read only", "Full access (suggested)"], + ]); + assert.deepEqual(prompts.messages, [ + "Hub app connections ready for this workflow:\nSlack — Paseo\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run `paseo hub init` again.", + "Using Slack — Paseo", + ]); + assert.deepEqual(calls, [ + { operation: "token", origin: "https://hub.test" }, + { operation: "projects", origin: "https://hub.test" }, + { operation: "setup", origin: "https://hub.test" }, + { operation: "resources", origin: "https://hub.test" }, + { + operation: "validate", + origin: "https://hub.test", + files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"], + }, + { + operation: "install", + origin: "https://hub.test", + files: [".paseo/hub.yml", ".paseo/workflows/slack-help.yml"], + }, + ]); + assert.equal(daemon.connections, 1); + assert.deepEqual(daemon.snapshotCwds, [cwd]); + const hub = await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"); + assert.match(hub, /daemon: macbook/u); + assert.match(hub, /provider: codex\n model: gpt-5\n mode: full-access/u); + }); + + it("prints exact actionable resume commands for login continuation declines", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon(); + const connectDeclined = new PromptAnswers([false], [], []); + + await continueHubGuidedSetup( + "https://hub.test", + setupEnvironment(cwd, credentials, daemon, connectDeclined, []), + ); + assert.deepEqual(connectDeclined.messages, [ + "Skipped daemon connection. Run: paseo hub connect https://hub.test; then paseo hub init", + ]); + + const initDeclined = new PromptAnswers([true, false], [], []); + await continueHubGuidedSetup( + "https://hub.test", + setupEnvironment(cwd, credentials, daemon, initDeclined, []), + ); + assert.deepEqual(initDeclined.messages, ["Skipped starter workflow. Run: paseo hub init"]); + }); + + it("keeps login successful when no Hub app connection can initialize a workflow", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + const daemon = new SetupDaemon(); + const prompts = new PromptAnswers([true, true], [], []); + + await runHubLogin( + "https://hub.test", + {}, + { + env: {}, + credentials, + flow: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + isInteractive: () => true, + continueGuidedSetup: (origin) => + continueHubGuidedSetup( + origin, + setupEnvironment(cwd, credentials, daemon, prompts, [], { + setupResources: { github: [], slack: [], discord: [] }, + }), + ), + reporter: { progress() {} }, + }, + ); + + assert.deepEqual(prompts.messages, [ + "No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", + ]); + }); + + it("keeps hub init's existing replacement confirmation", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + const prompts = new PromptAnswers([false], [], []); + + await assert.rejects( + runHubGuidedSetup( + setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []), + ), + /Existing .paseo\/ bundle left unchanged/u, + ); + assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]); + }); + + it("writes the explicitly selected Claude mode when the daemon has no default", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon([ + { + provider: "claude", + status: "ready", + enabled: true, + models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }], + modes: [{ id: "auto", label: "Auto" }], + defaultModeId: null, + }, + ]); + + const prompts = new PromptAnswers([true], ["claude", "sonnet", "auto"], ["U123"]); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; + + await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, calls), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }); + assert.deepEqual(prompts.selectionOptions.slice(0, 3), [ + ["claude"], + ["Sonnet (suggested)"], + ["Auto"], + ]); + assert.match( + await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), + /provider: claude\n model: sonnet\n mode: auto/u, + ); + assert.deepEqual( + calls.slice(-2).map(({ operation }) => operation), + ["validate", "install"], + ); + }); + + it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon(); + const prompts = new PromptAnswers([], [], []); + + await assert.rejects( + runHubGuidedSetup( + setupEnvironment(cwd, credentials, daemon, prompts, [], { + setupResources: { github: [], slack: [], discord: [] }, + }), + { origin: "https://hub.test", daemonId: "daemon-1", deploy: true }, + ), + /No Hub app connection is ready for this workflow/u, + ); + + assert.equal(daemon.snapshotCwds.length, 0); + await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" }); + }); + + it("waits for fresh-daemon provider discovery before offering runtime choices", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const ready = new SetupDaemon().providerEntries; + const daemon = new SetupDaemon(ready, [ + [{ provider: "codex", status: "loading", enabled: true }], + ready, + ]); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }); + + assert.equal(daemon.snapshotCwds.length, 2); + assert.deepEqual(prompts.selections.slice(0, 3), [ + "Starter agent provider", + "Starter agent model", + "Starter agent mode", + ]); + }); +}); + +function setupEnvironment( + cwd: string, + credentials: HubCredentialStore, + daemon: SetupDaemon, + prompts: PromptAnswers, + calls: Array<{ operation: string; origin: string; files?: readonly string[] }>, + options: { + setupResources?: Awaited>; + } = {}, +): HubGuidedSetupEnvironment { + const hub = { + async issueEnrollmentToken(origin: string) { + calls.push({ operation: "token", origin }); + return "one-time-enrollment-token-with-enough-length"; + }, + async listProjects(origin: string) { + calls.push({ operation: "projects", origin }); + return [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "studio", name: "Studio" }]; + }, + async listSetupResources(origin: string) { + calls.push({ operation: "setup", origin }); + return ( + options.setupResources ?? { + github: [], + discord: [], + slack: [{ teamId: "T123", teamName: "Paseo" }], + } + ); + }, + async listConfigurationResources(origin: string) { + calls.push({ operation: "resources", origin }); + return { daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], slack: [] }; + }, + async validateConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + calls.push({ + operation: "validate", + origin: input.origin, + files: input.files.map(({ path: filePath }) => filePath), + }); + return { projectSlug: "studio", valid: true }; + }, + async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + calls.push({ + operation: "install", + origin: input.origin, + files: input.files.map(({ path: filePath }) => filePath), + }); + return { + projectSlug: "studio", + version: 1, + versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + active: true, + }; + }, + } as HubHttpClient; + return { + env: {}, + credentials, + hub, + login: { authorize: async () => "paseo_cli_prefix_durable-secret" }, + daemon: { connect: async () => daemon }, + reporter: { progress() {} }, + cwd: () => cwd, + isInteractive: () => true, + prompts, + }; +} + +class PromptAnswers { + readonly confirmations: string[] = []; + readonly selections: string[] = []; + readonly selectionOptions: string[][] = []; + readonly messages: string[] = []; + + constructor( + private readonly confirmAnswers: boolean[], + private readonly selectAnswers: string[], + private readonly textAnswers: string[], + ) {} + + async confirm(message: string): Promise { + this.confirmations.push(message); + return this.confirmAnswers.shift() ?? false; + } + + async select(options: { + message: string; + options?: { label: string; hint?: string }[]; + }): Promise { + this.selections.push(options.message); + this.selectionOptions.push( + options.options?.map( + (option) => `${option.label}${option.hint ? ` (${option.hint})` : ""}`, + ) ?? [], + ); + return this.selectAnswers.shift() ?? ""; + } + + async text(): Promise { + return this.textAnswers.shift() ?? ""; + } + + message(value: string): void { + this.messages.push(value); + } +} + +class MemoryCredentials implements HubCredentialStore { + private record: StoredHubCredential | null = null; + + active(): StoredHubCredential | null { + return this.record; + } + + get(origin: string): StoredHubCredential | null { + return this.record?.origin === origin ? this.record : null; + } + + save(credential: StoredHubCredential): void { + this.record = credential; + } + + logoutActive(): StoredHubCredential | null { + const record = this.record; + this.record = null; + return record; + } +} + +class SetupDaemon implements HubDaemonClient { + connections = 0; + readonly snapshotCwds: Array = []; + private origin: string | null = null; + + constructor( + readonly providerEntries: readonly ProviderSnapshotEntry[] = [ + { + provider: "codex", + status: "ready" as const, + enabled: true, + label: "Codex", + models: [ + { provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true }, + { provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" }, + ], + modes: [ + { id: "read-only", label: "Read only" }, + { id: "full-access", label: "Full access" }, + ], + defaultModeId: "full-access", + }, + ], + private readonly providerEntrySequence?: readonly (readonly ProviderSnapshotEntry[])[], + ) {} + + async connectHub(origin: string) { + this.connections += 1; + this.origin = origin; + return { status: status(origin) }; + } + + async getHubStatus() { + return { status: this.origin === null ? disconnectedStatus() : status(this.origin) }; + } + + async getProvidersSnapshot(options?: { cwd?: string }) { + this.snapshotCwds.push(options?.cwd); + const index = Math.min( + this.snapshotCwds.length - 1, + (this.providerEntrySequence?.length ?? 1) - 1, + ); + return { entries: this.providerEntrySequence?.[index] ?? this.providerEntries }; + } + + async disconnectHub() { + return { status: disconnectedStatus() }; + } + + async close() {} +} + +function status(origin: string): HubStatus { + return { + state: "connected", + daemonId: "daemon-1", + hubOrigin: origin, + scopes: [], + connectedAt: null, + lastError: null, + }; +} + +function disconnectedStatus(): HubStatus { + return { + state: "not_connected", + daemonId: null, + hubOrigin: null, + scopes: [], + connectedAt: null, + lastError: null, + }; +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-flow-")); + directories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts index afc91a690f..aa4ce9d244 100644 --- a/packages/cli/src/commands/hub/init-plan.test.ts +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -7,6 +7,7 @@ import { discoverHubBundle } from "./deploy-bundle.js"; import { runHubDeploy } from "./deploy.js"; import { createHubInitScaffold, + hubLoginResumeCommand, planHubInitOpening, resolveHubInitConnection, resolveHubInitProjects, @@ -26,6 +27,12 @@ afterEach(async () => { }); describe("Hub init planning", () => { + it("prints direct resumable commands for declined login continuations", () => { + expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe( + "paseo hub connect https://hub.test", + ); + expect(hubLoginResumeCommand("init", "https://hub.test")).toBe("paseo hub init"); + }); it("includes login only when there is no active login", () => { expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ replaceExisting: false, @@ -86,10 +93,25 @@ describe("Hub init planning", () => { }); describe("Hub init scaffold", () => { + it("omits mode for a provider that explicitly exposes none", () => { + const scaffold = createHubInitScaffold({ + cwd: "/workspace", + daemonSlug: "build-studio", + agent: { provider: "pi", model: "default" }, + provider: "slack", + providerFilters: { workspace: "T123456", user: "U123456" }, + }); + + expect(YAML.parse(scaffold.hub)).toMatchObject({ + agents: { starter: { provider: "pi", model: "default" } }, + }); + expect(YAML.parse(scaffold.hub).agents.starter.mode).toBeUndefined(); + }); + it.each([ ["github", { repo: "getpaseo/paseo", user: "boudra" }], - ["slack", { workspace: "paseo", user: "boudra" }], - ["discord", { guild: "paseo", user: "boudra" }], + ["slack", { workspace: "T123456", user: "U123456" }], + ["discord", { guild: "123456789", user: "987654321" }], ] satisfies readonly [HubInitProvider, Record][])( "creates a closed %s trigger that the deploy discovery path accepts", async (provider, providerFilters) => { @@ -97,6 +119,11 @@ describe("Hub init scaffold", () => { const scaffold = createHubInitScaffold({ cwd, daemonSlug: "build-studio", + agent: { + provider: "codex", + model: "gpt-5", + mode: "full-access", + }, provider, providerFilters, }); @@ -114,7 +141,12 @@ describe("Hub init scaffold", () => { filters: { from_users: string[]; channels?: string[] }; }; expect(parsed.filters.from_users).toEqual([providerFilters.user]); + if (provider === "slack") expect(parsed.filters.workspace).toBe("T123456"); + if (provider === "discord") expect(parsed.filters.guild).toBe("123456789"); expect(parsed.filters.channels).toBeUndefined(); + expect(YAML.parse(scaffold.hub)).toMatchObject({ + agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } }, + }); let validatedPaths: readonly string[] = []; const result = await runHubDeploy( diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts index 88e90de38f..e2764ccbff 100644 --- a/packages/cli/src/commands/hub/init-plan.ts +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -3,6 +3,7 @@ import YAML from "yaml"; import type { HubDeployBundle } from "./deploy-bundle.js"; import type { HubProject } from "./hub-client/index.js"; import type { HubStatus } from "./daemon-client.js"; +import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js"; export type HubInitProvider = "github" | "slack" | "discord"; @@ -25,6 +26,7 @@ export interface HubInitOpeningPlan { export interface HubInitScaffoldInput { cwd: string; daemonSlug: string; + agent: HubStarterAgentRuntime; provider: HubInitProvider; providerFilters: Readonly>; } @@ -36,6 +38,10 @@ export interface HubInitScaffold { testAction: string; } +export function hubLoginResumeCommand(step: "connect" | "init", origin: string): string { + return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init"; +} + export function planHubInitOpening(input: { loggedIn: boolean; paseoDirectoryExists: boolean; @@ -97,9 +103,10 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff }, }, agents: { - codex: { - provider: "codex", - mode: "full-access", + starter: { + provider: input.agent.provider, + model: input.agent.model, + ...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }), }, }, }, @@ -180,7 +187,7 @@ function workflow(input: { environment: input.environment, max_runtime: "90m", idle_timeout: "10m", - agent: "codex", + agent: "starter", prompt: [ { text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n\n\${{ paseo.prompt }}\n\n`, diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index c86f79b4ec..15c86d5238 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -21,28 +21,44 @@ import type { HubCredentialStore } from "./credentials.js"; import type { HubDaemonConnection } from "./daemon-client.js"; import { withHubDaemon } from "./daemon-client.js"; import { HubCommandError } from "./error.js"; -import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js"; +import type { + HubConfigurationResources, + HubHttpClient, + HubProject, + HubSetupResources, +} from "./hub-client/index.js"; import { createHubInitBundle, createHubInitScaffold, + hubLoginResumeCommand, planHubInitOpening, resolveHubInitConnection, resolveHubInitProjects, - type HubInitProvider, } from "./init-plan.js"; import type { CliLoginFlow } from "./login-flow.js"; -import { runHubLogin } from "./login.js"; import { runHubConnect } from "./connect.js"; import { runHubDeployBundle } from "./deploy.js"; import { runHubProjects } from "./projects.js"; import type { HubReporter } from "./reporter.js"; import { normalizeHubOrigin } from "./origin.js"; +import { + selectedStarterAgentRuntime, + starterAgentProviderSnapshotState, + suggestedStarterAgentChoice, + type HubStarterAgentProvider, + type HubStarterAgentRuntime, +} from "./starter-agent-runtime.js"; +import { + availableStarterTriggerConnections, + type HubStarterTriggerConnection, +} from "./starter-trigger.js"; const execFileAsync = promisify(execFile); const DAEMON_READY_TIMEOUT_MS = 60_000; const DAEMON_READY_POLL_MS = 250; +const PROVIDER_READY_TIMEOUT_MS = 60_000; -interface HubInitEnvironment { +export interface HubGuidedSetupEnvironment { env: Readonly>; credentials: HubCredentialStore; hub: HubHttpClient; @@ -50,6 +66,19 @@ interface HubInitEnvironment { daemon: HubDaemonConnection; reporter: HubReporter; cwd(): string; + isInteractive?(): boolean; + prompts?: { + confirm(message: string, initialValue: boolean): Promise; + select(options: Parameters>[0]): Promise; + text(options: Parameters[0]): Promise; + message(value: string): void; + }; +} + +interface HubGuidedSetupState { + origin?: string; + daemonId?: string; + deploy?: boolean; } class HubInitCancelledError extends Error {} @@ -61,7 +90,7 @@ function initErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void { +export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { parent .command("init") .description("Create and optionally deploy a safe starter Hub bundle") @@ -79,8 +108,15 @@ export function addHubInitCommand(parent: Command, environment: HubInitEnvironme }); } -export async function runHubInit(environment: HubInitEnvironment): Promise { - requireInteractiveTerminal(); +export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise { + await runHubGuidedSetup(environment); +} + +export async function runHubGuidedSetup( + environment: HubGuidedSetupEnvironment, + state: HubGuidedSetupState = {}, +): Promise { + requireInteractiveTerminal(environment); intro("Set up Paseo Hub"); const cwd = environment.cwd(); @@ -91,29 +127,27 @@ export async function runHubInit(environment: HubInitEnvironment): Promise }); if ( opening.replaceExisting && - !(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false)) + !(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false)) ) { throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); } - const origin = await ensureLogin(activeLogin?.origin, environment); - const daemonId = await ensureDaemonConnection(origin, environment); + const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment)); + const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment)); const project = await chooseProject(origin, environment); - const resources = await loadConfigurationResources(origin, environment); - const daemon = resources.daemons.find(({ id }) => id === daemonId); - if (daemon === undefined) { - throw new HubCommandError( - "HUB_DAEMON_RESOURCE_MISSING", - "The connected daemon is not available in this Hub organization. Reconnect it and try again.", - ); - } + const resources = await loadSetupResources(origin, environment); + const triggerConnections = await resolveStarterTriggerConnections(resources, cwd); + reportStarterTriggerConnections(environment, triggerConnections); + const trigger = await chooseStarterTriggerConnection(environment, triggerConnections); + const daemon = await loadDaemonResource(origin, daemonId, environment); log.success(`Connected as ${daemon.slug}`); - const provider = await chooseProvider(); - const providerFilters = await collectProviderFilters(provider, resources, cwd); + const agent = await chooseStarterAgentRuntime(environment, cwd); + const providerFilters = await collectProviderIdentity(trigger, environment); const scaffold = createHubInitScaffold({ cwd, daemonSlug: daemon.slug, - provider, + agent, + provider: trigger.provider, providerFilters, }); @@ -132,7 +166,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise await writeScaffold(cwd, scaffold, opening.replaceExisting); log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); - const deploy = await requiredConfirm("Deploy now?", true); + const deploy = state.deploy ?? (await requiredConfirm(environment, "Deploy now?", true)); if (deploy) { await withSpinner("Deploying bundle", async () => { await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { @@ -145,7 +179,7 @@ export async function runHubInit(environment: HubInitEnvironment): Promise }); log.success("Deployed"); } else { - log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); } const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); @@ -153,11 +187,46 @@ export async function runHubInit(environment: HubInitEnvironment): Promise outro(deploy ? "Hub is ready" : "Hub bundle is ready"); } +export async function continueHubGuidedSetup( + origin: string, + environment: HubGuidedSetupEnvironment, +): Promise { + if (!(await requiredConfirm(environment, "Connect this daemon to this Hub?", true))) { + reportMessage( + environment, + `Skipped daemon connection. Run: ${hubLoginResumeCommand("connect", origin)}; then paseo hub init`, + ); + return; + } + const daemonId = await ensureDaemonConnection(origin, environment, true); + if (!(await requiredConfirm(environment, "Initialize and deploy a starter workflow?", true))) { + reportMessage( + environment, + `Skipped starter workflow. Run: ${hubLoginResumeCommand("init", origin)}`, + ); + return; + } + try { + await runHubGuidedSetup(environment, { origin, daemonId, deploy: true }); + } catch (error) { + if (error instanceof HubInitCancelledError) { + if (environment.prompts === undefined) { + log.message(error.message); + outro("Connect an app to continue"); + } else { + environment.prompts.message(error.message); + } + return; + } + throw error; + } +} + async function ensureLogin( activeOrigin: string | undefined, - environment: HubInitEnvironment, + environment: HubGuidedSetupEnvironment, ): Promise { - const endpoint = await requiredSelect({ + const endpoint = await requiredSelect(environment, { message: "Hub endpoint", initialValue: activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom", @@ -169,7 +238,7 @@ async function ensureLogin( const origin = endpoint === "hosted" ? DEFAULT_HUB_ORIGIN - : await requiredText({ + : await requiredText(environment, { message: "Custom Hub URL", initialValue: activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN @@ -189,23 +258,17 @@ async function ensureLogin( log.success(`Logged in to ${normalizedOrigin}`); return normalizedOrigin; } - await runHubLogin( - normalizedOrigin, - {}, - { - env: environment.env, - credentials: environment.credentials, - flow: environment.login, - reporter: environment.reporter, - }, - ); + environment.reporter.progress(`Logging in to ${normalizedOrigin}`); + const credential = await environment.login.authorize(normalizedOrigin); + environment.credentials.save({ origin: normalizedOrigin, credential }); log.success(`Logged in to ${normalizedOrigin}`); return normalizedOrigin; } async function ensureDaemonConnection( origin: string, - environment: HubInitEnvironment, + environment: HubGuidedSetupEnvironment, + confirmed = false, ): Promise { const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => daemon.getHubStatus().then((response) => response.status), @@ -223,9 +286,19 @@ async function ensureDaemonConnection( `This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`, ); } - if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) { + if ( + !confirmed && + !(await requiredConfirm(environment, `Connect this daemon to ${origin}?`, true)) + ) { throw new HubInitCancelledError("A connected daemon is required to create the bundle."); } + return connectDaemon(origin, environment); +} + +async function connectDaemon( + origin: string, + environment: HubGuidedSetupEnvironment, +): Promise { await runHubConnect( origin, {}, @@ -276,7 +349,10 @@ async function waitForDaemonReady( ); } -async function chooseProject(origin: string, environment: HubInitEnvironment): Promise { +async function chooseProject( + origin: string, + environment: HubGuidedSetupEnvironment, +): Promise { const result = await runHubProjects( { hub: origin }, { @@ -295,7 +371,7 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P if (resolution.kind === "selected") { return resolution.project; } - const slug = await requiredSelect({ + const slug = await requiredSelect(environment, { message: "Project", options: resolution.projects.map((project) => ({ value: project.slug, @@ -313,78 +389,201 @@ async function chooseProject(origin: string, environment: HubInitEnvironment): P return project; } -async function chooseProvider(): Promise { - return requiredSelect({ - message: "Trigger provider", - options: [ - { value: "github", label: "GitHub", hint: "issue or pull request comment" }, - { value: "slack", label: "Slack", hint: "channel mention" }, - { value: "discord", label: "Discord", hint: "channel mention" }, - ], - }); +async function resolveStarterTriggerConnections( + resources: HubSetupResources, + cwd: string, +): Promise { + const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd); + const repository = remote === undefined ? undefined : githubRepositoryFromRemote(remote); + const connections = availableStarterTriggerConnections(resources, repository); + if (connections.length === 0) { + throw new HubInitCancelledError( + "No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", + ); + } + return connections; } -async function collectProviderFilters( - provider: HubInitProvider, - resources: HubConfigurationResources, +function reportStarterTriggerConnections( + environment: HubGuidedSetupEnvironment, + connections: readonly HubStarterTriggerConnection[], +): void { + const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`; + if (environment.prompts === undefined) { + note(details, "Hub app connections ready for this workflow"); + return; + } + environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`); +} + +async function chooseStarterTriggerConnection( + environment: HubGuidedSetupEnvironment, + connections: readonly HubStarterTriggerConnection[], +): Promise { + if (connections.length === 1) { + const connection = connections[0]!; + reportMessage(environment, `Using ${connection.label}`); + return connection; + } + const id = await requiredSelect(environment, { + message: "Trigger connection", + options: connections.map((connection) => ({ value: connection.id, label: connection.label })), + }); + const connection = connections.find((candidate) => candidate.id === id); + if (connection === undefined) { + throw new HubCommandError( + "HUB_PROVIDER_CONNECTION_INVALID", + "The selected Hub app connection is no longer available. Run paseo hub init again.", + ); + } + return connection; +} + +async function chooseStarterAgentRuntime( + environment: HubGuidedSetupEnvironment, cwd: string, +): Promise { + const providers = await waitForStarterAgentProviders(environment, cwd); + const provider = await chooseStarterAgentProvider(environment, providers); + const model = await chooseStarterAgentModel(environment, provider); + const mode = await chooseStarterAgentMode(environment, provider); + const runtime = selectedStarterAgentRuntime(provider, model, mode); + if (runtime === undefined) { + throw new HubCommandError( + "HUB_AGENT_RUNTIME_SELECTION_INVALID", + "The selected starter agent runtime is no longer available. Run paseo hub init again.", + ); + } + return runtime; +} + +async function waitForStarterAgentProviders( + environment: HubGuidedSetupEnvironment, + cwd: string, +): Promise { + return withSpinner("Discovering agent runtimes", async (reporter) => + withHubDaemon(environment.daemon, undefined, async (daemon) => { + const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS; + while (true) { + const snapshot = await daemon.getProvidersSnapshot({ cwd }); + const state = starterAgentProviderSnapshotState(snapshot.entries); + if (state.kind === "ready") return state.providers; + if (state.kind === "unavailable") { + throw new HubCommandError( + "HUB_AGENT_RUNTIME_REQUIRED", + "No usable agent runtime is available from this daemon. Configure an enabled provider with a selectable model, then run paseo hub init again.", + ); + } + if (Date.now() >= deadline) { + throw new HubCommandError( + "HUB_AGENT_RUNTIME_TIMEOUT", + "Agent runtime discovery did not finish within 60 seconds. Check the daemon's provider configuration, then run paseo hub init again.", + ); + } + reporter.progress("Waiting for agent runtime discovery"); + await delay(DAEMON_READY_POLL_MS); + } + }), + ); +} + +async function chooseStarterAgentProvider( + environment: HubGuidedSetupEnvironment, + providers: readonly HubStarterAgentProvider[], +): Promise { + const selected = await requiredSelect(environment, { + message: "Starter agent provider", + options: providers.map((provider) => ({ + value: provider.id, + label: provider.label, + })), + }); + const provider = providers.find((candidate) => candidate.id === selected); + if (provider === undefined) throw invalidStarterAgentSelection(); + return provider; +} + +async function chooseStarterAgentModel( + environment: HubGuidedSetupEnvironment, + provider: HubStarterAgentProvider, +): Promise { + const suggested = suggestedStarterAgentChoice(provider.models); + const selected = await requiredSelect(environment, { + message: "Starter agent model", + ...(suggested === undefined ? {} : { initialValue: suggested.id }), + options: provider.models.map((model) => ({ + value: model.id, + label: model.label, + ...(model.suggested ? { hint: "suggested" } : {}), + })), + }); + if (!provider.models.some((model) => model.id === selected)) throw invalidStarterAgentSelection(); + return selected; +} + +async function chooseStarterAgentMode( + environment: HubGuidedSetupEnvironment, + provider: HubStarterAgentProvider, +): Promise { + if (provider.modes.length === 0) return undefined; + const suggested = suggestedStarterAgentChoice(provider.modes); + const selected = await requiredSelect(environment, { + message: "Starter agent mode", + ...(suggested === undefined ? {} : { initialValue: suggested.id }), + options: provider.modes.map((mode) => ({ + value: mode.id, + label: mode.label, + ...(mode.suggested ? { hint: "suggested" } : {}), + })), + }); + if (!provider.modes.some((mode) => mode.id === selected)) throw invalidStarterAgentSelection(); + return selected; +} + +function invalidStarterAgentSelection(): HubCommandError { + return new HubCommandError( + "HUB_AGENT_RUNTIME_SELECTION_INVALID", + "The selected starter agent runtime is no longer available. Run paseo hub init again.", + ); +} + +async function collectProviderIdentity( + trigger: HubStarterTriggerConnection, + environment: HubGuidedSetupEnvironment, ): Promise>> { - if (provider === "github") { - const [login, originRemote] = await Promise.all([ - readGhValue(["api", "user", "--jq", ".login"]), - readCommandValue("git", ["remote", "get-url", "origin"], cwd), - ]); - const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote); - if (repo === undefined) { - throw new HubCommandError( - "HUB_GITHUB_REPOSITORY_UNDETECTED", - "Could not detect a GitHub repository from the origin remote.", - ); - } - if (!resources.github.some(({ repositories }) => repositories.includes(repo))) { - const connected = resources.github.flatMap(({ repositories }) => repositories); - throw new HubCommandError( - "HUB_GITHUB_REPOSITORY_NOT_CONNECTED", - `${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`, - ); - } + if (trigger.provider === "github") { + const login = await readGhValue(["api", "user", "--jq", ".login"]); return { - user: await requiredText({ + ...trigger.filters, + user: await requiredText(environment, { message: "Your GitHub username (only this user can trigger the bot)", initialValue: login, }), - repo, }; } - if (provider === "slack") { + if (trigger.provider === "slack") { return { - workspace: await chooseConnection( - "Slack workspace", - resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })), - ), - user: await requiredText({ - message: "Your Slack username (only this user can trigger the bot)", + ...trigger.filters, + user: await requiredText(environment, { + message: "Your Slack member ID (only this user can trigger the bot)", }), }; } return { - guild: await chooseConnection( - "Discord server", - resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })), - ), - user: await requiredText({ - message: "Your Discord username (only this user can trigger the bot)", + ...trigger.filters, + user: await requiredText(environment, { + message: "Your Discord user ID (only this user can trigger the bot)", }), }; } -async function loadConfigurationResources( +async function loadSetupResources( origin: string, - environment: HubInitEnvironment, -): Promise { + environment: HubGuidedSetupEnvironment, +): Promise { try { return await withSpinner("Loading Hub connections", () => - environment.hub.listConfigurationResources( + environment.hub.listSetupResources( origin, resolveHubCredential({ options: { origin }, @@ -398,28 +597,35 @@ async function loadConfigurationResources( if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { throw new HubCommandError( "HUB_UPDATE_REQUIRED", - "This Hub does not support guided setup. Update the Hub and try again.", + "This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.", ); } throw error; } } -async function chooseConnection( - message: string, - connections: readonly { slug: string; label: string }[], -): Promise { - if (connections.length === 0) { +async function loadDaemonResource( + origin: string, + daemonId: string, + environment: HubGuidedSetupEnvironment, +): Promise { + const resources = await environment.hub.listConfigurationResources( + origin, + resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }), + ); + const daemon = resources.daemons.find(({ id }) => id === daemonId); + if (daemon === undefined) { throw new HubCommandError( - "HUB_PROVIDER_CONNECTION_REQUIRED", - `No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`, + "HUB_DAEMON_RESOURCE_MISSING", + "The connected daemon is not available in this Hub organization. Reconnect it and try again.", ); } - if (connections.length === 1) return connections[0]!.slug; - return requiredSelect({ - message, - options: connections.map(({ slug, label }) => ({ value: slug, label })), - }); + return daemon; } async function writeScaffold( @@ -523,36 +729,62 @@ async function withSpinner( } } -async function requiredText(options: Parameters[0]): Promise { - const answer = await text({ +async function requiredText( + environment: HubGuidedSetupEnvironment, + options: Parameters[0], +): Promise { + const request = { ...options, - validate(value) { + validate(value: string | undefined) { const input = value ?? ""; const customError = options.validate?.(input); if (customError !== undefined) return customError; return input.trim().length === 0 ? "A value is required" : undefined; }, - }); + }; + const answer = + environment.prompts === undefined + ? await text(request) + : await environment.prompts.text(request); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); return answer.trim(); } -async function requiredConfirm(message: string, initialValue: boolean): Promise { - const answer = await confirm({ message, initialValue }); +async function requiredConfirm( + environment: HubGuidedSetupEnvironment, + message: string, + initialValue: boolean, +): Promise { + const answer = + environment.prompts === undefined + ? await confirm({ message, initialValue }) + : await environment.prompts.confirm(message, initialValue); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); return answer; } async function requiredSelect( + environment: HubGuidedSetupEnvironment, options: Parameters>[0], ): Promise { - const answer = await select(options); + const answer = + environment.prompts === undefined + ? await select(options) + : ((await environment.prompts.select(options as Parameters>[0])) as T); if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); return answer; } -function requireInteractiveTerminal(): void { - if (!process.stdin.isTTY || !process.stdout.isTTY) { +function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void { + if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) { throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); } } + +function reportMessage(environment: HubGuidedSetupEnvironment, message: string): void { + if (environment.prompts === undefined) { + log.message(message); + return; + } + environment.prompts.message(message); +} diff --git a/packages/cli/src/commands/hub/login.ts b/packages/cli/src/commands/hub/login.ts index d8f2277099..e77329094c 100644 --- a/packages/cli/src/commands/hub/login.ts +++ b/packages/cli/src/commands/hub/login.ts @@ -25,6 +25,8 @@ interface HubLoginDependencies { credentials: HubCredentialStore; flow: Pick; reporter: HubReporter; + isInteractive?(): boolean; + continueGuidedSetup?(origin: string): Promise; } export async function runHubLogin( @@ -40,6 +42,14 @@ export async function runHubLogin( reportHubProgress(dependencies.reporter, options, `Logging in to ${origin}`); const credential = await dependencies.flow.authorize(origin); dependencies.credentials.save({ origin, credential }); + reportHubProgress(dependencies.reporter, options, "Logged in"); + if ( + !options.json && + dependencies.isInteractive?.() && + dependencies.continueGuidedSetup !== undefined + ) { + await dependencies.continueGuidedSetup(origin); + } return { type: "single", data: { origin, status: "logged_in" }, schema }; } diff --git a/packages/cli/src/commands/hub/starter-agent-runtime.test.ts b/packages/cli/src/commands/hub/starter-agent-runtime.test.ts new file mode 100644 index 0000000000..da7beaecec --- /dev/null +++ b/packages/cli/src/commands/hub/starter-agent-runtime.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + availableStarterAgentProviders, + selectedStarterAgentRuntime, + starterAgentProviderSnapshotState, + suggestedStarterAgentChoice, +} from "./starter-agent-runtime.js"; + +describe("starter agent runtime choices", () => { + it.each([ + { + name: "uses a complete daemon default as a staged suggestion", + entries: [ + { + provider: "codex", + status: "ready" as const, + enabled: true, + label: "Codex", + models: [ + { provider: "codex", id: "gpt-5", label: "GPT-5", isDefault: true }, + { provider: "codex", id: "gpt-5-mini", label: "GPT-5 mini" }, + ], + modes: [ + { id: "read-only", label: "Read only" }, + { id: "full-access", label: "Full access" }, + ], + defaultModeId: "full-access", + }, + ], + expected: [ + { + id: "codex", + label: "Codex", + models: [ + { id: "gpt-5", label: "GPT-5", suggested: true }, + { id: "gpt-5-mini", label: "GPT-5 mini", suggested: false }, + ], + modes: [ + { id: "read-only", label: "Read only", suggested: false }, + { id: "full-access", label: "Full access", suggested: true }, + ], + }, + ], + }, + { + name: "keeps Claude selectable when it has modes but no default mode", + entries: [ + { + provider: "claude", + status: "ready" as const, + enabled: true, + label: "Claude", + models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }], + modes: [{ id: "auto", label: "Auto" }], + defaultModeId: null, + }, + ], + expected: [ + { + id: "claude", + label: "Claude", + models: [{ id: "sonnet", label: "Sonnet", suggested: true }], + modes: [{ id: "auto", label: "Auto", suggested: false }], + }, + ], + }, + { + name: "treats an invalid default mode as no suggestion", + entries: [ + { + provider: "claude", + status: "ready" as const, + enabled: true, + models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }], + modes: [{ id: "auto", label: "Auto" }], + defaultModeId: "missing", + }, + ], + expected: [ + { + id: "claude", + label: "claude", + models: [{ id: "sonnet", label: "Sonnet", suggested: true }], + modes: [{ id: "auto", label: "Auto", suggested: false }], + }, + ], + }, + { + name: "excludes unavailable, disabled, and model-less providers", + entries: [ + { provider: "claude", status: "unavailable" as const, enabled: true }, + { provider: "codex", status: "ready" as const, enabled: false, models: [] }, + { provider: "opencode", status: "ready" as const, enabled: true, models: [] }, + ], + expected: [], + }, + ])("$name", ({ entries, expected }) => { + expect(availableStarterAgentProviders(entries)).toEqual(expected); + }); + + it("creates mode-less definitions for providers that expose no modes", () => { + const [provider] = availableStarterAgentProviders([ + { + provider: "pi", + status: "ready", + enabled: true, + models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }], + modes: [], + }, + ]); + + expect(provider).toBeDefined(); + expect(selectedStarterAgentRuntime(provider!, "default")).toEqual({ + provider: "pi", + model: "default", + }); + expect(suggestedStarterAgentChoice(provider!.modes)).toBeUndefined(); + }); + + it("distinguishes discovery in progress from a terminally unusable snapshot", () => { + expect(starterAgentProviderSnapshotState([])).toEqual({ kind: "loading" }); + expect( + starterAgentProviderSnapshotState([{ provider: "claude", status: "loading", enabled: true }]), + ).toEqual({ kind: "loading" }); + expect( + starterAgentProviderSnapshotState([ + { provider: "claude", status: "unavailable", enabled: true }, + ]), + ).toEqual({ kind: "unavailable" }); + }); +}); diff --git a/packages/cli/src/commands/hub/starter-agent-runtime.ts b/packages/cli/src/commands/hub/starter-agent-runtime.ts new file mode 100644 index 0000000000..e583cf337e --- /dev/null +++ b/packages/cli/src/commands/hub/starter-agent-runtime.ts @@ -0,0 +1,88 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; + +export interface HubStarterAgentRuntime { + provider: string; + model: string; + mode?: string; +} + +export interface HubStarterAgentProvider { + id: string; + label: string; + models: readonly HubStarterAgentModel[]; + modes: readonly HubStarterAgentMode[]; +} + +export interface HubStarterAgentModel { + id: string; + label: string; + suggested: boolean; +} + +export interface HubStarterAgentMode { + id: string; + label: string; + suggested: boolean; +} + +export type HubStarterAgentProviderSnapshotState = + | { kind: "ready"; providers: readonly HubStarterAgentProvider[] } + | { kind: "loading" } + | { kind: "unavailable" }; + +export function availableStarterAgentProviders( + entries: readonly ProviderSnapshotEntry[], +): HubStarterAgentProvider[] { + return entries.flatMap((entry) => { + if (entry.status !== "ready" || !entry.enabled) return []; + const models = (entry.models ?? []) + .filter((model) => model.isSelectable !== false) + .map((model) => ({ id: model.id, label: model.label, suggested: model.isDefault === true })); + if (models.length === 0) return []; + + const modes = (entry.modes ?? []).map((mode) => ({ + id: mode.id, + label: mode.label, + suggested: mode.id === entry.defaultModeId, + })); + return [ + { + id: entry.provider, + label: entry.label ?? entry.provider, + models, + modes, + }, + ]; + }); +} + +export function starterAgentProviderSnapshotState( + entries: readonly ProviderSnapshotEntry[], +): HubStarterAgentProviderSnapshotState { + const providers = availableStarterAgentProviders(entries); + if (providers.length > 0) return { kind: "ready", providers }; + if (entries.length === 0) return { kind: "loading" }; + if (entries.some((entry) => entry.enabled && entry.status === "loading")) { + return { kind: "loading" }; + } + return { kind: "unavailable" }; +} + +export function suggestedStarterAgentChoice( + choices: readonly T[], +): T | undefined { + return choices.find((choice) => choice.suggested); +} + +export function selectedStarterAgentRuntime( + provider: HubStarterAgentProvider, + modelId: string, + modeId?: string, +): HubStarterAgentRuntime | undefined { + if (!provider.models.some((model) => model.id === modelId)) return undefined; + if (provider.modes.length === 0) { + return modeId === undefined ? { provider: provider.id, model: modelId } : undefined; + } + if (modeId === undefined || !provider.modes.some((mode) => mode.id === modeId)) return undefined; + return { provider: provider.id, model: modelId, mode: modeId }; +} diff --git a/packages/cli/src/commands/hub/starter-trigger.test.ts b/packages/cli/src/commands/hub/starter-trigger.test.ts new file mode 100644 index 0000000000..570ea0005d --- /dev/null +++ b/packages/cli/src/commands/hub/starter-trigger.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { availableStarterTriggerConnections } from "./starter-trigger.js"; + +describe("starter trigger connections", () => { + it("returns only concrete connections that can back the generated workflow", () => { + expect( + availableStarterTriggerConnections( + { + github: [ + { + slug: "github-getpaseo", + accountLogin: "getpaseo", + accountType: "Organization", + repositories: ["getpaseo/paseo"], + }, + ], + slack: [{ teamId: "T123", teamName: "Paseo" }], + discord: [{ guildId: "456", guildName: "Paseo Discord" }], + }, + "getpaseo/paseo", + ), + ).toEqual([ + { + id: "github:getpaseo/paseo", + label: "GitHub — getpaseo/paseo", + provider: "github", + filters: { repo: "getpaseo/paseo" }, + }, + { + id: "slack:T123", + label: "Slack — Paseo", + provider: "slack", + filters: { workspace: "T123" }, + }, + { + id: "discord:456", + label: "Discord — Paseo Discord", + provider: "discord", + filters: { guild: "456" }, + }, + ]); + }); + + it("does not offer GitHub when the current repository is not connected", () => { + expect( + availableStarterTriggerConnections( + { + github: [ + { + slug: "github-getpaseo", + accountLogin: "getpaseo", + accountType: "Organization", + repositories: ["getpaseo/hub"], + }, + ], + slack: [], + discord: [], + }, + "getpaseo/paseo", + ), + ).toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/hub/starter-trigger.ts b/packages/cli/src/commands/hub/starter-trigger.ts new file mode 100644 index 0000000000..c327beb32a --- /dev/null +++ b/packages/cli/src/commands/hub/starter-trigger.ts @@ -0,0 +1,40 @@ +import type { HubSetupResources } from "./hub-client/index.js"; +import type { HubInitProvider } from "./init-plan.js"; + +export interface HubStarterTriggerConnection { + id: string; + label: string; + provider: HubInitProvider; + filters: Readonly>; +} + +export function availableStarterTriggerConnections( + resources: HubSetupResources, + githubRepository?: string, +): HubStarterTriggerConnection[] { + return [ + ...(githubRepository !== undefined && + resources.github.some(({ repositories }) => repositories.includes(githubRepository)) + ? [ + { + id: `github:${githubRepository}`, + label: `GitHub — ${githubRepository}`, + provider: "github" as const, + filters: { repo: githubRepository }, + }, + ] + : []), + ...resources.slack.map(({ teamId, teamName }) => ({ + id: `slack:${teamId}`, + label: `Slack — ${teamName}`, + provider: "slack" as const, + filters: { workspace: teamId }, + })), + ...resources.discord.map(({ guildId, guildName }) => ({ + id: `discord:${guildId}`, + label: `Discord — ${guildName}`, + provider: "discord" as const, + filters: { guild: guildId }, + })), + ]; +} diff --git a/packages/cli/src/output/pairing.test.ts b/packages/cli/src/output/pairing.test.ts index 00ed6b0a85..89fc31d85e 100644 --- a/packages/cli/src/output/pairing.test.ts +++ b/packages/cli/src/output/pairing.test.ts @@ -10,6 +10,9 @@ describe("formatPairingInstructions", () => { expect(output).toContain(QR); expect(output.split("\n")).toContain(URL); + expect(output).toContain( + "Treat this pairing link like a password. Anyone with it can access this daemon.", + ); }); it("does not print a QR that would reach the terminal edge", () => { diff --git a/packages/cli/src/output/pairing.ts b/packages/cli/src/output/pairing.ts index 6d0da5bf6f..741c8fd056 100644 --- a/packages/cli/src/output/pairing.ts +++ b/packages/cli/src/output/pairing.ts @@ -33,5 +33,5 @@ function formatQr(qr: string | null, columns: number | undefined): string { } export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string { - return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`; + return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`; } diff --git a/packages/client/package.json b/packages/client/package.json index df617d9060..747488ee57 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.5.0-beta.4", - "@getpaseo/relay": "0.5.0-beta.4", + "@getpaseo/protocol": "0.5.1", + "@getpaseo/relay": "0.5.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 140eb6aa41..f2ed2125a8 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them", expect(new TextDecoder().decode(result.bytes)).toBe("hello"); }); +test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_file_budget_compat", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10); + expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "file_explorer_request", + cwd: "/tmp/project", + path: "large.txt", + mode: "file", + acceptBinary: true, + maxBytes: 10, + requestId: "req-budget", + }, + }); + + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-budget", + metadata: { + mime: "text/plain", + size: 100, + encoding: "utf-8", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }), + ); + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-budget", + payload: new Uint8Array(100), + }), + ); + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }), + ); + + await expect(responsePromise).rejects.toThrow("File is too large to display"); +}); + test("uploadFile sends metadata request and file bytes as binary chunks", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5e07dc1f24..0921f01360 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity { interface PendingBinaryFileRead { cwd: string; path: string; + maxBytes?: number; } interface BinaryFileTransferState extends PendingBinaryFileRead { @@ -4308,6 +4309,7 @@ export class DaemonClient { mode: "list" | "file", requestId?: string, acceptBinary = false, + maxBytes?: number, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, @@ -4317,6 +4319,7 @@ export class DaemonClient { path, mode, ...(acceptBinary ? { acceptBinary: true } : {}), + ...(maxBytes ? { maxBytes } : {}), }, responseType: "file_explorer_response", }); @@ -4337,11 +4340,23 @@ export class DaemonClient { return payload.directory; } - async readFile(cwd: string, path: string, requestId?: string): Promise { + async readFile( + cwd: string, + path: string, + requestId?: string, + maxBytes?: number, + ): Promise { const resolvedRequestId = this.createRequestId(requestId); - this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path }); + this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes }); try { - const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true); + const payload = await this.requestFileExplorer( + cwd, + path, + "file", + resolvedRequestId, + true, + maxBytes, + ); if (payload.error) { throw new Error(payload.error); } @@ -5761,10 +5776,33 @@ export class DaemonClient { } if (frame.opcode === FileTransferOpcode.FileChunk) { + // COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0. + // Old daemons stream despite maxBytes; discard before client-side accumulation. + if (transfer.maxBytes && transfer.size > transfer.maxBytes) { + return; + } transfer.chunks.push(frame.payload); return; } + // COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0. + if (transfer.maxBytes && transfer.size > transfer.maxBytes) { + this.activeBinaryFileTransfers.delete(frame.requestId); + this.handleSessionMessage({ + type: "file_explorer_response", + payload: { + cwd: transfer.cwd, + path: transfer.path, + mode: "file", + directory: null, + file: null, + error: "File is too large to display", + requestId: frame.requestId, + }, + }); + return; + } + const bytes = concatByteChunks(transfer.chunks, transfer.size); this.activeBinaryFileTransfers.delete(frame.requestId); this.completedBinaryFileReads.set(frame.requestId, { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2380aa1cac..3bc582a883 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.5.0-beta.4", + "version": "0.5.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 9586d6b5ce..462cda1f29 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index d7383420fc..954caaa53b 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.5.0-beta.4", + "version": "0.5.1", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ce51ff757b..50149cebaa 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -39,7 +39,7 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -48,7 +48,7 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", "react": "19.1.0", "zod": "^4.4.3" } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 6d0a709024..85ac2054b9 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a7419b475b..a9b46a6fc9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2568,6 +2568,7 @@ export const FileExplorerRequestSchema = z.object({ mode: z.enum(["list", "file"]), requestId: z.string(), acceptBinary: z.boolean().optional(), + maxBytes: z.number().int().positive().optional(), }); export const FileVersionSchema = z.discriminatedUnion("status", [ diff --git a/packages/relay/package.json b/packages/relay/package.json index 958b9cdee4..24535c27ac 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts index 8db9a15f71..2978e04a38 100644 --- a/packages/relay/src/crypto.test.ts +++ b/packages/relay/src/crypto.test.ts @@ -12,6 +12,10 @@ function decryptText(sharedKey: Uint8Array, ciphertext: ArrayBuffer): string { return new TextDecoder().decode(decrypt(sharedKey, ciphertext)); } +function bytesFromHex(hex: string): Uint8Array { + return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + describe("crypto", () => { describe("generateKeyPair", () => { it("generates a valid keypair", () => { @@ -36,6 +40,22 @@ describe("crypto", () => { const reExported = exportPublicKey(imported); expect(reExported).toBe(exported); }); + + it.each([ + ["malformed", "!".repeat(43) + "="], + ["noncanonical", `${"A".repeat(42)}B=`], + ])("rejects %s base64", (_description, encoded) => { + expect(importPublicKey.bind(null, encoded)).toThrowError("Invalid public key encoding"); + }); + + it.each([ + ["short", new Uint8Array(31)], + ["oversized", new Uint8Array(33)], + ])("rejects a %s decoded key", (_description, key) => { + expect(importPublicKey.bind(null, Buffer.from(key).toString("base64"))).toThrowError( + "Invalid public key length (expected 32)", + ); + }); }); describe("deriveSharedKey", () => { @@ -63,6 +83,34 @@ describe("crypto", () => { expect(decrypted).toBe(testMessage); }); + + // Unsupported peer public keys. + it.each([ + ["unsupported key 1", "00".repeat(32)], + ["unsupported key 2", `01${"00".repeat(31)}`], + ["unsupported key 3", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"], + ["unsupported key 4", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"], + ["unsupported key 5", `ec${"ff".repeat(30)}7f`], + ["unsupported key 6", `ed${"ff".repeat(30)}7f`], + ["unsupported key 7", `ee${"ff".repeat(30)}7f`], + ])("rejects the %s peer public key", (_description, publicKeyHex) => { + const { secretKey } = generateKeyPair(); + + expect(deriveSharedKey.bind(null, secretKey, bytesFromHex(publicKeyHex))).toThrowError( + "Invalid peer public key", + ); + }); + + it.each([ + ["short", new Uint8Array(31)], + ["oversized", new Uint8Array(33)], + ])("rejects a %s peer public key", (_description, publicKey) => { + const { secretKey } = generateKeyPair(); + + expect(deriveSharedKey.bind(null, secretKey, publicKey)).toThrowError( + "Invalid peer public key length (expected 32)", + ); + }); }); describe("encrypt / decrypt", () => { diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts index 298f8dea46..40dc41ae24 100644 --- a/packages/relay/src/crypto.ts +++ b/packages/relay/src/crypto.ts @@ -23,6 +23,7 @@ export interface KeyPair { export type SharedKey = Uint8Array; // 32 bytes (box.before) const NONCE_LENGTH = nacl.box.nonceLength; // 24 +const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength); let prngReady = false; @@ -68,6 +69,22 @@ function decodeBase64(base64: string): Uint8Array { return toByteArray(base64); } +function decodePublicKeyBase64(base64: string): Uint8Array { + if ( + typeof base64 !== "string" || + base64.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64) + ) { + throw new Error("Invalid public key encoding"); + } + + const bytes = decodeBase64(base64); + if (encodeBase64(bytes) !== base64) { + throw new Error("Invalid public key encoding"); + } + return bytes; +} + function toUint8(data: string | ArrayBuffer): Uint8Array { return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); } @@ -92,7 +109,7 @@ export function exportPublicKey(publicKey: Uint8Array): string { } export function importPublicKey(base64: string): Uint8Array { - const bytes = decodeBase64(base64); + const bytes = decodePublicKeyBase64(base64); if (bytes.byteLength !== nacl.box.publicKeyLength) { throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`); } @@ -121,6 +138,14 @@ export function deriveSharedKey(ourSecretKey: Uint8Array, peerPublicKey: Uint8Ar if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) { throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`); } + + const rawSharedResult = nacl.scalarMult(ourSecretKey, peerPublicKey); + const isAllZero = nacl.verify(rawSharedResult, ZERO_X25519_SHARED_RESULT); + rawSharedResult.fill(0); + if (isAllZero) { + throw new Error("Invalid peer public key"); + } + return nacl.box.before(peerPublicKey, ourSecretKey); } diff --git a/packages/server/package.json b/packages/server/package.json index f19b30ddd4..3e2e323a0d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.5.0-beta.4", + "version": "0.5.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -69,16 +69,16 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.220", + "@anthropic-ai/claude-agent-sdk": "0.3.220", "@anthropic-ai/sdk": "^0.104.2", "@babel/parser": "^7.29.8", - "@getpaseo/client": "0.5.0-beta.4", - "@getpaseo/highlight": "0.5.0-beta.4", - "@getpaseo/plugin": "0.5.0-beta.4", - "@getpaseo/protocol": "0.5.0-beta.4", - "@getpaseo/relay": "0.5.0-beta.4", + "@getpaseo/client": "0.5.1", + "@getpaseo/highlight": "0.5.1", + "@getpaseo/plugin": "0.5.1", + "@getpaseo/protocol": "0.5.1", + "@getpaseo/relay": "0.5.1", "@isaacs/ttlcache": "^2.1.4", - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", "@xterm/headless": "^6.0.0", "ai": "5.0.78", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index a815298f4d..07cd6ae72a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1061,6 +1061,7 @@ test("isolated rewind falls back from steering to the normal replacement path", activeTurnBehavior: "steer", runOptions: { clientMessageId: "rewind-client" }, }); + await manager.waitForAgentRunStart(agentId); expect(session.interruptCount).toBe(2); expect(session.startPrompts).toContain("/rewind submitted-message-id"); expect(manager.getTimeline(agentId)).toContainEqual( @@ -7163,6 +7164,63 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist ); }); +test("acknowledged cancellation settles a pending run before it has a turn id", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cancel-pending-start-")); + const startEntered = deferred(); + let rejectStart!: (error: Error) => void; + const stalledStart = new Promise<{ turnId: string }>((_resolve, reject) => { + rejectStart = reject; + }); + + class PendingStartSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + startEntered.resolve(); + return await stalledStart; + } + + override async interrupt(): Promise {} + } + + class PendingStartClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new PendingStartSession(config); + } + } + + const manager = new AgentManager({ + clients: { codex: new PendingStartClient() }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + idFactory: () => "00000000-0000-4000-8000-000000000132", + }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const firstRun = manager.runAgent(agent.id, "start a turn"); + void firstRun.catch(() => undefined); + await startEntered.promise; + + expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle"); + expect(manager.hasInFlightRun(agent.id)).toBe(true); + + await expect(manager.cancelAgentRun(agent.id)).resolves.toEqual({ status: "settled" }); + expect(manager.hasInFlightRun(agent.id)).toBe(false); + expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle"); + + rejectStart(new Error("released canceled start")); + await expect(firstRun).rejects.toThrow("released canceled start"); + expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle"); + } finally { + rejectStart(new Error("test cleanup")); + if (manager.getAgent("00000000-0000-4000-8000-000000000132")) { + await manager.closeAgent("00000000-0000-4000-8000-000000000132"); + } + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 509d6f4982..7ddcc5b34b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -66,7 +66,11 @@ import { AgentStreamCoalescer, } from "./agent-stream-coalescer.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; -import { AgentRunState, type ForegroundTurnWaiter } from "./agent-run-state.js"; +import { + AgentRunState, + type ForegroundTurnWaiter, + type PendingForegroundRun, +} from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; @@ -2141,6 +2145,37 @@ export class AgentManager { }); } + private async startPendingForegroundTurn(params: { + agent: ActiveManagedAgent; + agentId: string; + pendingRun: PendingForegroundRun; + prompt: AgentPromptInput; + options?: AgentRunOptions; + }): Promise { + const { agent, agentId, pendingRun, prompt, options } = params; + try { + const result = await agent.session.startTurn(prompt, options); + if (pendingRun.settled) { + throw new Error(`Agent ${agentId} run was canceled before its turn started`); + } + return result.turnId; + } catch (error) { + if (pendingRun.settled) { + throw error; + } + agent.pendingReplacement = false; + const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; + await this.handleStreamEvent(agent, { + type: "turn_failed", + provider: agent.provider, + error: errorMsg, + }); + this.finalizeForegroundTurn(agent); + this.runs.settleForegroundRun(agentId, pendingRun.token); + throw error; + } + } + streamAgent( agentId: string, prompt: AgentPromptInput, @@ -2185,21 +2220,13 @@ export class AgentManager { const streamForwarder = async function* streamForwarder(this: AgentManager) { let turnId: string; let turnStream: ReturnType | null = null; - try { - const result = await agent.session.startTurn(prompt, options); - turnId = result.turnId; - } catch (error) { - agent.pendingReplacement = false; - const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; - await this.handleStreamEvent(agent, { - type: "turn_failed", - provider: agent.provider, - error: errorMsg, - }); - this.finalizeForegroundTurn(agent); - this.runs.settleForegroundRun(agentId, pendingRun.token); - throw error; - } + turnId = await this.startPendingForegroundTurn({ + agent, + agentId, + pendingRun, + prompt, + options, + }); if (isReplacement) { agent.pendingReplacement = false; @@ -2719,6 +2746,17 @@ export class AgentManager { turnId: run.turnId, }); await run.settledPromise; + } else if (settlement === "timed_out" && run.kind === "foreground") { + this.logger.warn( + { agentId, kind: run.kind }, + "cancelAgentRun: acknowledged pending turn still active after timeout, clearing it", + ); + this.runs.settleForegroundRun(agentId, run.token); + if (!agent.pendingReplacement) { + agent.lifecycle = "idle"; + this.touchUpdatedAt(agent); + this.emitState(agent); + } } else if (settlement === "timed_out" && run.kind === "autonomous") { this.logger.warn( { agentId, kind: run.kind }, diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 89b175d131..864813a140 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,5 +1,9 @@ import { expect, it, test, vi } from "vitest"; import pino, { type Logger } from "pino"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; @@ -8,8 +12,15 @@ import { formatSystemNotificationPrompt, isSystemInjectedEnvelope, setupFinishNotification, + waitForAgentRunStartWithTimeout, } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +import type { + AgentClient, + AgentRunResult, + AgentSession, + AgentStreamEvent, +} from "./agent-sdk-types.js"; interface CapturedLogger { logger: Logger; @@ -520,3 +531,234 @@ it("does not notify archived callers", async () => { expect(streamAgentSpy).not.toHaveBeenCalled(); expect(replaceAgentRunSpy).not.toHaveBeenCalled(); }); + +// Deliberately independent literals rather than the production constants these tests +// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests +// green if that constant were shortened back under a provider's startup budget. +const EXPECTED_RUN_START_BUDGET_MS = 60_000; +// The slowest provider startup budget the run-start wait has to sit outside of today +// (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS). +const SLOWEST_PROVIDER_STARTUP_BUDGET_MS = 30_000; + +const RUN_START_TEST_CAPABILITIES = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsSessionListing: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +} as const; + +/** + * Provider session whose turn start is held open for a configurable span, so the real + * AgentManager run-state transition (pendingRun.started -> lifecycle "running" -> + * agent_state) is what the run-start wait observes. `startDelayMs: null` never starts. + */ +class SlowStartAgentSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = RUN_START_TEST_CAPABILITIES; + readonly id = randomUUID(); + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private releaseStartTurn!: () => void; + private readonly released = new Promise((resolve) => { + this.releaseStartTurn = resolve; + }); + + constructor(private readonly startDelayMs: number | null) {} + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + /** Teardown hook so a never-starting turn cannot wedge the suite. */ + release(): void { + this.releaseStartTurn(); + } + + async startTurn(): Promise<{ turnId: string }> { + await new Promise((resolve) => { + if (this.startDelayMs !== null) { + setTimeout(resolve, this.startDelayMs); + } + void this.released.then(resolve); + }); + const turnId = "turn-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + pushEvent(event: AgentStreamEvent): void { + for (const callback of this.subscribers) { + callback(event); + } + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { provider: this.provider, sessionId: this.id, model: null, modeId: null }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise {} + + async close(): Promise {} +} + +class SlowStartAgentClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = RUN_START_TEST_CAPABILITIES; + readonly sessions: SlowStartAgentSession[] = []; + + constructor(private readonly startDelayMs: number | null) {} + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + const session = new SlowStartAgentSession(this.startDelayMs); + this.sessions.push(session); + return session; + } + + async fetchCatalog() { + return { models: [], modes: [] }; + } + + async resumeSession(): Promise { + return await this.createSession(); + } +} + +/** + * Real AgentManager driving a real agent, so the run-start wait exercises the production + * run-state and agent_state subscription path rather than a replaced method. + */ +async function createRunStartScenario(startDelayMs: number | null): Promise<{ + agentManager: AgentManager; + agentId: string; + startRun: () => Promise; + cleanup: () => Promise; +}> { + const workdir = mkdtempSync(join(tmpdir(), "agent-run-start-budget-")); + const client = new SlowStartAgentClient(startDelayMs); + const agentManager = new AgentManager({ + clients: { codex: client }, + logger: createTestLogger(), + }); + const snapshot = await agentManager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + let drained: Promise = Promise.resolve(); + return { + agentManager, + agentId: snapshot.id, + // streamAgent registers the pending run synchronously, so the wait always observes it. + startRun: async () => { + const run = agentManager.streamAgent(snapshot.id, "start the run"); + drained = (async () => { + for await (const _event of run) { + // Drain whatever the turn produces. + } + })().catch(() => undefined); + }, + cleanup: async () => { + // Release any turn still held open, then close. The drain is deliberately not + // awaited: depending on how far the turn got, the stream ends either from the + // release or from the close, and teardown must not depend on which. + for (const session of client.sessions) { + session.release(); + } + await agentManager.closeAgent(snapshot.id).catch(() => undefined); + void drained; + rmSync(workdir, { recursive: true, force: true }); + }, + }; +} + +test("waiting for a run start outlasts the slowest provider startup budget", async () => { + // A provider is still allowed to be starting here, so the outer wait must not abort it. + const scenario = await createRunStartScenario(SLOWEST_PROVIDER_STARTUP_BUDGET_MS + 5_000); + vi.useFakeTimers(); + + try { + await scenario.startRun(); + const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId); + let settled = false; + const markSettled = () => { + settled = true; + }; + void wait.then(markSettled, markSettled); + + await vi.advanceTimersByTimeAsync(SLOWEST_PROVIDER_STARTUP_BUDGET_MS); + expect(settled).toBe(false); + expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).not.toBe("running"); + + await vi.advanceTimersByTimeAsync(5_000); + await expect(wait).resolves.toBeUndefined(); + expect(scenario.agentManager.getAgent(scenario.agentId)?.lifecycle).toBe("running"); + } finally { + vi.useRealTimers(); + await scenario.cleanup(); + } +}); + +test("waiting for a run start still gives up at the run start budget", async () => { + const scenario = await createRunStartScenario(null); + vi.useFakeTimers(); + + try { + await scenario.startRun(); + const wait = waitForAgentRunStartWithTimeout(scenario.agentManager, scenario.agentId); + const rejection = expect(wait).rejects.toThrow( + "codex run did not start within 60 seconds (phase: run start)", + ); + let settled = false; + const markSettled = () => { + settled = true; + }; + void wait.then(markSettled, markSettled); + + await vi.advanceTimersByTimeAsync(EXPECTED_RUN_START_BUDGET_MS - 1); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await rejection; + expect(settled).toBe(true); + } finally { + vi.useRealTimers(); + await scenario.cleanup(); + } +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index f78cdf0ef1..96f5ce5ee3 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -212,14 +212,35 @@ export interface StartCreatedAgentInitialPromptParams { logger: Logger; } -const AGENT_RUN_START_TIMEOUT_MS = 15_000; +/** + * Outer bound on a run reaching "started" after dispatch. + * + * This wraps provider startup, so it MUST stay larger than the slowest provider's own + * startup budget — otherwise it aborts a start the provider was still allowed to be + * working on, and the provider's budget can never apply. OpenCode is the slowest today: + * up to 30s for the server to boot (OPENCODE_SERVER_STARTUP_TIMEOUT_MS) and then a + * session.create on the same budget, so this is deliberately set well above 30s. + * + * Not derived from the provider constant on purpose: this module is provider-agnostic + * and must not depend on a specific provider's internals. + */ +const AGENT_RUN_START_TIMEOUT_MS = 60_000; export async function waitForAgentRunStartWithTimeout( agentManager: AgentManager, agentId: string, ): Promise { + const provider = agentManager.getAgent(agentId)?.provider ?? "provider"; const startAbort = new AbortController(); - const startTimeout = setTimeout(() => startAbort.abort("timeout"), AGENT_RUN_START_TIMEOUT_MS); + const startTimeout = setTimeout( + () => + startAbort.abort( + new Error( + `${provider} run did not start within ${AGENT_RUN_START_TIMEOUT_MS / 1000} seconds (phase: run start)`, + ), + ), + AGENT_RUN_START_TIMEOUT_MS, + ); try { await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index b94043c960..3a01764209 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -653,6 +653,11 @@ export interface AgentSession { response: AgentPermissionResponse, ): Promise; describePersistence(): AgentPersistenceHandle | null; + /** + * Resolve once every foreground turn that predates this call can no longer run or become active. + * Calling while already idle is a successful no-op. Reject only when foreground ownership is + * still uncertain. + */ interrupt(): Promise; /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index aa81e035b5..63a45be061 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -111,6 +111,62 @@ test("unexpected Codex app-server exit fails the active run and agent", async () } }); +test("replacement self-heals when Codex reports that the tracked turn is already idle", async () => { + const workdir = mkdtempSync(join(tmpdir(), "codex-already-idle-replace-")); + const appServer = createFakeCodexAppServer({ + "turn/interrupt": () => ({ + __jsonRpcError: { code: -32600, message: "no active turn to interrupt" }, + }), + }); + const manager = new AgentManager({ + clients: { codex: new ProcessExitCodexClient([appServer]) }, + logger, + }); + let agentId: string | null = null; + + try { + const agent = await manager.createAgent( + { provider: "codex", cwd: workdir, modeId: "auto", model: "gpt-5.4" }, + undefined, + { workspaceId: undefined }, + ); + agentId = agent.id; + const firstRun = manager.runAgent(agent.id, "keep working"); + const firstTurnStart = await appServer.waitForTurnStart(); + appServer.startsTurn({ + threadId: String(firstTurnStart.threadId), + turnId: "native-turn-already-idle", + }); + await manager.waitForAgentRunStart(agent.id); + + const replacementStream = await manager.replaceAgentRun(agent.id, "replacement prompt"); + const replacementEvents = (async () => { + const events = []; + for await (const event of replacementStream) { + events.push(event); + } + return events; + })(); + + await expect + .poll(() => appServer.requests().filter((message) => message.method === "turn/start").length) + .toBe(2); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn-replacement" }); + appServer.completeTurn({ threadId: "thread-1" }); + + await expect(firstRun).resolves.toMatchObject({ canceled: true }); + await expect(replacementEvents).resolves.toContainEqual( + expect.objectContaining({ type: "turn_completed" }), + ); + expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle"); + } finally { + if (agentId && manager.getAgent(agentId)) { + await manager.closeAgent(agentId); + } + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("intentional agent close stays clean while a Codex turn is active", async () => { const workdir = mkdtempSync(join(tmpdir(), "codex-process-close-")); const appServer = createFakeCodexAppServer(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 2f8c75413d..d0a9dd3dde 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -3354,6 +3354,34 @@ describe("Codex app-server provider", () => { } }); + test("treats Codex already having no active turn as an acknowledged interrupt", async () => { + const appServer = createFakeCodexAppServer({ + "turn/interrupt": () => ({ + __jsonRpcError: { code: -32600, message: "no active turn to interrupt" }, + }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Wait for the child."); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "turn-already-idle" }); + + await expect(session.interrupt()).resolves.toBeUndefined(); + + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("waits for Codex to identify an accepted turn before interrupting it", async () => { const interruptedTurns: unknown[] = []; const appServer = createFakeCodexAppServer({ @@ -3386,7 +3414,7 @@ describe("Codex app-server provider", () => { } }); - test("does not interrupt after the accepted turn terminates before identification", async () => { + test("acknowledges interruption when the accepted turn terminates before identification", async () => { const interruptedTurns: unknown[] = []; const appServer = createFakeCodexAppServer({ "turn/interrupt": async (params) => { @@ -3407,9 +3435,7 @@ describe("Codex app-server provider", () => { const interruptPromise = session.interrupt(); appServer.completeTurn(); - await expect(interruptPromise).rejects.toThrow( - "Cannot interrupt Codex before turn/started identifies the active turn", - ); + await expect(interruptPromise).resolves.toBeUndefined(); await resultPromise; expect(interruptedTurns).toEqual([]); appServer.assertNoErrors(); @@ -3418,7 +3444,7 @@ describe("Codex app-server provider", () => { } }); - test("rejects an interrupt before Codex initializes the thread", async () => { + test("acknowledges interruption before Codex initializes a thread", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( createConfig({ cwd: "/workspace/project" }), @@ -3427,13 +3453,53 @@ describe("Codex app-server provider", () => { async () => appServer.child, ); - await expect(session.interrupt()).rejects.toThrow( - "Cannot interrupt Codex before the active thread is initialized", - ); + await expect(session.interrupt()).resolves.toBeUndefined(); await session.close(); }); + test("cancels a cold start before it can issue a native turn", async () => { + const threadStart = deferred<{ thread: { id: string } }>(); + const appServer = createFakeCodexAppServer({ + "thread/start": () => threadStart.promise, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const resultPromise = session.run("Start after the delayed thread."); + + try { + await appServer.waitForRequest("thread/start"); + let interruptSettled = false; + const interruptPromise = session.interrupt().then(() => { + interruptSettled = true; + return undefined; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(interruptSettled).toBe(false); + + threadStart.resolve({ thread: { id: "thread-1" } }); + await expect(interruptPromise).resolves.toBeUndefined(); + await expect(resultPromise).rejects.toThrow("interrupted before reaching Codex"); + expect(appServer.requests()).not.toContainEqual( + expect.objectContaining({ method: "turn/start" }), + ); + appServer.assertNoErrors(); + } finally { + threadStart.resolve({ thread: { id: "thread-1" } }); + const nativeStart = await appServer.waitForTurnStart().catch(() => null); + if (nativeStart) { + appServer.startsTurn({ threadId: "thread-1", turnId: "cleanup-turn" }); + appServer.completeTurn(); + } + await resultPromise.catch(() => undefined); + await session.close(); + } + }); + test("interrupts an autonomous Codex turn identified by live notifications", async () => { const session = createSession(); const requests: Array<{ method: string; params: unknown }> = []; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 859318ffe6..f7e06cc005 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -857,6 +857,14 @@ function isDefinitiveCodexSteerRejection(error: unknown): boolean { ); } +function isCodexAlreadyIdleInterrupt(error: unknown): boolean { + return ( + error instanceof CodexAppServerRpcError && + error.code === -32600 && + error.message === "no active turn to interrupt" + ); +} + // Codex app-server API response types interface CodexReasoningEffortEntry { reasoningEffort?: string; @@ -3227,6 +3235,11 @@ export class CodexAppServerAgentSession implements AgentSession { promise: Promise; resolve: (turnId: string | null) => void; } | null = null; + private pendingForegroundStart: { + promise: Promise; + resolve: () => void; + cancelRequested: boolean; + } | null = null; private client: CodexAppServerClient | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; @@ -4081,10 +4094,20 @@ export class CodexAppServerAgentSession implements AgentSession { prompt: AgentPromptInput, options?: AgentRunOptions, ): Promise<{ turnId: string }> { - if (this.activeForegroundTurnId) { + if (this.activeForegroundTurnId || this.pendingForegroundStart) { throw new Error("A foreground turn is already active"); } + let resolveStart!: () => void; + const pendingStart = { + promise: new Promise((resolve) => { + resolveStart = resolve; + }), + resolve: () => resolveStart(), + cancelRequested: false, + }; + this.pendingForegroundStart = pendingStart; + this.dismissPendingPlanApprovals("Dismissed by a new prompt"); try { @@ -4129,6 +4152,9 @@ export class CodexAppServerAgentSession implements AgentSession { hasDeveloperInstructions: turnStart.hasDeveloperInstructions, hasCodexConfig: turnStart.hasCodexConfig, }); + if (pendingStart.cancelRequested) { + throw new Error("Codex turn start was interrupted before reaching Codex"); + } await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS); return { turnId }; } catch (error) { @@ -4137,6 +4163,11 @@ export class CodexAppServerAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.activeClientMessageId = null; throw error; + } finally { + if (this.pendingForegroundStart === pendingStart) { + this.pendingForegroundStart = null; + } + pendingStart.resolve(); } } @@ -4617,7 +4648,19 @@ export class CodexAppServerAgentSession implements AgentSession { } async interrupt(): Promise { + const pendingStart = this.pendingForegroundStart; + if (pendingStart) { + pendingStart.cancelRequested = true; + await pendingStart.promise; + } if (!this.client || !this.currentThreadId) { + if ( + !this.activeForegroundTurnId && + !this.currentTurnId && + !this.pendingForegroundTurnIdentification + ) { + return; + } throw new Error("Cannot interrupt Codex before the active thread is initialized"); } let turnId = this.currentTurnId; @@ -4632,17 +4675,31 @@ export class CodexAppServerAgentSession implements AgentSession { ) { turnId = await pendingIdentification.promise; } + if (!turnId && !this.activeForegroundTurnId && !this.currentTurnId) { + return; + } if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) { throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); } - await this.client.request( - "turn/interrupt", - { - threadId: this.currentThreadId, - turnId, - }, - INTERRUPT_TIMEOUT_MS, - ); + try { + await this.client.request( + "turn/interrupt", + { + threadId: this.currentThreadId, + turnId, + }, + INTERRUPT_TIMEOUT_MS, + ); + } catch (error) { + if (!isCodexAlreadyIdleInterrupt(error)) { + throw error; + } + this.activeForegroundTurnId = null; + this.activeClientMessageId = null; + this.currentTurnId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; + } } async close(): Promise { diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 807f9151c7..8e4cdd9f6e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -343,6 +343,81 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 60_000); + test("creates a session when session.create needs more than ten seconds", async () => { + vi.useFakeTimers(); + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + const slowCreate = createTestDeferred(); + openCode.sessionCreateImplementation = async () => { + await slowCreate.promise; + return { data: { id: "session-1" } }; + }; + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + + try { + const creation = client.createSession(buildConfig(cwd)); + let settled = false; + const markSettled = () => { + settled = true; + }; + void creation.then(markSettled, markSettled); + + // Under CPU contention a per-directory bootstrap has been measured well past ten + // seconds, which the previous budget failed outright. + await vi.advanceTimersByTimeAsync(20_000); + expect(settled).toBe(false); + + slowCreate.resolve(); + const session = await creation; + expect(session.provider).toBe("opencode"); + expect(openCode.calls.sessionCreate).toHaveLength(1); + await session.close(); + } finally { + vi.useRealTimers(); + slowCreate.resolve(); + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("bounds session.create at the server startup budget", async () => { + vi.useFakeTimers(); + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + openCode.sessionCreateImplementation = () => new Promise(() => undefined); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + + try { + const creation = client.createSession(buildConfig(cwd)); + const rejection = expect(creation).rejects.toThrow("OpenCode session.create timed out"); + let settled = false; + const markSettled = () => { + settled = true; + }; + void creation.then(markSettled, markSettled); + + await vi.advanceTimersByTimeAsync(EXPECTED_STARTUP_BUDGET_MS - 1); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await rejection; + expect(settled).toBe(true); + expect(runtime.acquisitions.at(-1)?.releaseCount).toBe(1); + } finally { + vi.useRealTimers(); + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("archives and unarchives the durable native session through client hooks", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c17afc3554..e48266a8b3 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1396,10 +1396,15 @@ export class OpenCodeAgentClient implements AgentClient { }); try { + // Creating the first session for a directory is part of OpenCode coming up, so it + // shares the server startup budget instead of a shorter one that fails agent + // creation on contended cold starts. const response = await withTimeout( client.session.create({ directory: openCodeConfig.cwd }), - 10_000, - "OpenCode session.create timed out after 10s", + OPENCODE_SERVER_STARTUP_TIMEOUT_MS, + `OpenCode session.create timed out after ${Math.round( + OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000, + )}s`, ); if (response.error) { diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 2f1a29d820..444a5a07b2 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -145,6 +145,7 @@ export class TestOpenCodeClient { sessionCommandEvents: unknown[] = [idleEvent()]; sessionCommandResponse: OpenCodeResponse = {}; sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } }; + sessionCreateImplementation: ((parameters: unknown) => Promise) | null = null; sessionDeleteResponse: OpenCodeResponse = {}; sessionChildrenResponses: OpenCodeResponse[] = []; sessionChildrenImplementation: @@ -294,6 +295,9 @@ export class TestOpenCodeClient { }, create: async (parameters: unknown) => { this.calls.sessionCreate.push(parameters); + if (this.sessionCreateImplementation) { + return await this.sessionCreateImplementation(parameters); + } return this.sessionCreateResponse; }, delete: async (parameters: unknown) => { diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 327098f2dc..3edede83d3 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -82,6 +82,11 @@ function decodeCiphertext(text: string): ArrayBuffer { return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } +// Fixed transport key for an unsupported peer public key. +const UNSUPPORTED_PEER_TRANSPORT_KEY = Uint8Array.from( + Buffer.from("351f86faa3b988468a850122b65b0acece9c4826806aeee63de9c0da2bd7f91e", "hex"), +); + function parseEncryptedJson(sharedKey: Uint8Array, text: string): unknown { const plaintext = new TextDecoder().decode(decrypt(sharedKey, decodeCiphertext(text))); return JSON.parse(plaintext); @@ -161,35 +166,48 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis throw new Error(`Relay WebSocket endpoint not ready on port ${port} within ${timeout}ms`); } +async function waitForCapturedLog( + lines: string[], + predicate: (line: string) => boolean, + timeout = 1000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (lines.some(predicate)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for captured log. Recent logs:\n${lines.slice(-20).join("")}`); +} + (shouldRunRelayE2e ? describe : describe.skip)("Relay transport (E2EE) - daemon E2E", () => { let relayPort: number; let relayProcess: ChildProcess | null = null; let relayStdoutLines: string[] = []; - const startRelay = async () => { + const startRelay = async (options: { useLocalRelay?: boolean } = {}) => { relayStdoutLines = []; relayPort = await getAvailablePort(); const relayDir = path.resolve(process.cwd(), "../relay"); - relayProcess = spawn( - "npx", - [ - "wrangler", - "dev", - "--local", - "--ip", - "127.0.0.1", - "--port", - String(relayPort), - "--live-reload=false", - "--show-interactive-dev-session=false", - ], - { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }, - ); + const relayArgs = [ + "wrangler", + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + String(relayPort), + "--live-reload=false", + "--show-interactive-dev-session=false", + ]; + if (options.useLocalRelay) { + relayArgs.push("--var", "PASEO_RELAY_UPSTREAM:"); + } + relayProcess = spawn("npx", relayArgs, { + cwd: relayDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); relayProcess.stdout?.on("data", (data: Buffer) => { const lines = data @@ -364,6 +382,122 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis } }, 90000); + test("daemon closes a relay client that sends an unsupported handshake key", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay({ useLocalRelay: true }); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId } = decodeOfferFromFragmentUrl(offerUrl); + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + ); + + let receivedServerInfo = false; + const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); + const outcome = await new Promise< + { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } + >((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); + }, 20_000); + + ws.on("open", async () => { + try { + await waitForCapturedLog(lines, isRelayDataConnectedLog); + ws.send( + JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(new Uint8Array(32)), + }), + ); + ws.send( + encodeCiphertext( + encrypt( + UNSUPPORTED_PEER_TRANSPORT_KEY, + JSON.stringify({ + type: "hello", + clientId: "cid_relay_unsupported_key", + clientType: "cli", + protocolVersion: 1, + }), + ), + ), + ); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + + ws.on("message", (data) => { + try { + const parsed = WSOutboundMessageSchema.parse( + parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), + ); + if ( + parsed.type === "session" && + parsed.message.type === "status" && + parsed.message.payload?.status === "server_info" + ) { + receivedServerInfo = true; + clearTimeout(timeout); + reject( + new Error("daemon sent server_info after accepting an unsupported handshake key"), + ); + } + } catch { + // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. + } + }); + ws.on("close", (code, reason) => { + clearTimeout(timeout); + resolve({ type: "closed", code, reason: reason.toString() }); + }); + ws.on("error", (error) => { + clearTimeout(timeout); + resolve({ type: "errored", error }); + }); + }); + + expect(receivedServerInfo).toBe(false); + if (outcome.type === "closed") { + expect(outcome.code).toBeGreaterThan(0); + } else { + expect(outcome.error).toBeInstanceOf(Error); + } + + const isRejectedHandshakeLog = (line: string) => + line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); + await waitForCapturedLog(lines, isRejectedHandshakeLog); + expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } finally { + await daemon.close(); + await stopRelay(); + } + }, 90000); + test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index d176e03bf8..6d27509fbe 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -372,6 +372,30 @@ describe("WorkspaceFilesSession", () => { ]); }); + test("rejects an over-budget file before opening a binary transfer", async () => { + const cwd = makeDir("workspace-files-read-budget-"); + writeFileSync(join(cwd, "notes.txt"), "hello world"); + const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true }); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: "notes.txt", + mode: "file", + requestId: "req-read-budget", + acceptBinary: true, + maxBytes: 5, + }); + + expect(binary).toEqual([]); + expect(emitted).toEqual([ + expect.objectContaining({ + type: "file_explorer_response", + payload: expect.objectContaining({ error: "File is too large to display" }), + }), + ]); + }); + test("streams a real file larger than the socket limit as paced ordered chunks", async () => { const cwd = makeDir("workspace-files-large-binary-"); const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index c74de3acae..0f833479c2 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -263,6 +263,12 @@ export class WorkspaceFilesSession { source, ); } else { + if (request.maxBytes) { + const file = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath }); + if (file.size > request.maxBytes) { + throw new Error("File is too large to display"); + } + } if (request.acceptBinary && this.host.hasBinaryChannel()) { await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => { await this.host.emitBinary( diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts index be922d0858..867f52dc52 100644 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ b/packages/server/src/services/quota-fetcher/providers/claude.ts @@ -22,7 +22,6 @@ import { const execFileAsync = promisify(execFile); const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; const CLAUDE_OAUTH_BETA = "oauth-2025-04-20"; -const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; const ClaudeCredentialsSchema = z.object({ @@ -71,21 +70,14 @@ const ClaudeUsageResponseSchema = z.object({ .nullish(), }); -const ClaudeTokenRefreshSchema = z.object({ - access_token: z.string().optional(), - refresh_token: z.string().optional(), -}); - type ClaudeCredentials = z.infer; type ClaudeUsageResponse = z.infer; -type ClaudeTokenRefresh = z.infer; type ClaudeLimit = z.infer; const SCOPED_WEEKLY_KIND = "weekly_scoped"; interface ClaudeCredentialRecord { oauth: { accessToken: string } & NonNullable; - filePath: string | null; } interface ClaudeQuotaProviderOptions { @@ -370,30 +362,13 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { return unavailableUsage(this); } - const { oauth, filePath } = credentials; + const { oauth } = credentials; const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); - let resp = await this.callClaudeApi(oauth.accessToken); + const resp = await this.callClaudeApi(oauth.accessToken); if (resp === "NEEDS_AUTH") { - if (!filePath || !oauth.refreshToken) { - return unavailableUsage(this); - } - - const refreshed = await this.refreshClaudeToken(oauth.refreshToken); - if (!refreshed?.access_token) { - return unavailableUsage(this); - } - - await this.saveClaudeCredentials(filePath, { - ...oauth, - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token ?? oauth.refreshToken, - }); - - resp = await this.callClaudeApi(refreshed.access_token); - if (resp === "NEEDS_AUTH") { - return unavailableUsage(this); - } + // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. + return unavailableUsage(this); } const scoped = reconcileScopedLimits( @@ -469,7 +444,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { ); const oauth = creds.claudeAiOauth; if (oauth?.accessToken) { - return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: credPath }; + return { oauth: { ...oauth, accessToken: oauth.accessToken } }; } } catch { // Fall through to the macOS Keychain below. @@ -480,7 +455,7 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { const creds = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); const oauth = creds.success ? creds.data.claudeAiOauth : undefined; if (oauth?.accessToken) { - return { oauth: { ...oauth, accessToken: oauth.accessToken }, filePath: null }; + return { oauth: { ...oauth, accessToken: oauth.accessToken } }; } } @@ -499,38 +474,4 @@ export class ClaudeQuotaProvider implements ProviderUsageFetcher { if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); return ClaudeUsageResponseSchema.parse(await res.json()); } - - private async refreshClaudeToken(refreshToken: string): Promise { - const res = await fetchProviderApi( - this.fetchApi, - "https://platform.claude.com/v1/oauth/token", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLAUDE_CLIENT_ID, - scope: "user:profile user:inference user:sessions:claude_code user:mcp_servers", - }), - }, - ); - if (!res.ok) return null; - return ClaudeTokenRefreshSchema.parse(await res.json()); - } - - private async saveClaudeCredentials( - credPath: string, - oauth: ClaudeCredentials["claudeAiOauth"], - ): Promise { - try { - const existing = ClaudeCredentialsSchema.parse( - JSON.parse(await fs.readFile(credPath, "utf8")), - ); - existing.claudeAiOauth = oauth; - await fs.writeFile(credPath, JSON.stringify(existing, null, 2), { mode: 0o600 }); - } catch { - // Non-fatal; Claude Code can refresh again on its own next time. - } - } } diff --git a/packages/server/src/services/quota-fetcher/providers/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts index a0607fcf2f..78a1f7d26b 100644 --- a/packages/server/src/services/quota-fetcher/providers/codex.ts +++ b/packages/server/src/services/quota-fetcher/providers/codex.ts @@ -18,8 +18,6 @@ import { windowFromUsedPct, } from "../usage.js"; -const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; - const CodexAuthSchema = z.object({ tokens: z .object({ @@ -58,20 +56,9 @@ const CodexUsageResponseSchema = z.object({ .nullish(), }); -const CodexTokenRefreshSchema = z.object({ - access_token: z.string().optional(), - refresh_token: z.string().optional(), -}); - type CodexAuth = z.infer; type CodexWindow = z.infer; type CodexUsageResponse = z.infer; -type CodexTokenRefresh = z.infer; - -interface CodexAuthRecord { - auth: CodexAuth; - path: string; -} interface CodexQuotaProviderOptions { logger: Logger; @@ -102,30 +89,18 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { } async fetchUsage(): Promise { - const authRecord = await this.readCodexAuth(); - const auth = authRecord?.auth; + const auth = await this.readCodexAuth(); const accessToken = auth?.tokens?.access_token; - if (!authRecord || !auth || !accessToken) { + if (!auth || !accessToken) { return unavailableUsage(this); } - const { refresh_token, account_id } = auth.tokens ?? {}; - let resp = await this.callCodexApi(accessToken, account_id); + const { account_id } = auth.tokens ?? {}; + const resp = await this.callCodexApi(accessToken, account_id); if (resp === "NEEDS_AUTH") { - if (!refresh_token) { - return unavailableUsage(this); - } - const refreshed = await this.refreshCodexToken(refresh_token); - if (!refreshed?.access_token) { - return unavailableUsage(this); - } - - await this.saveCodexAuth(authRecord.path, auth, refreshed); - resp = await this.callCodexApi(refreshed.access_token, account_id); - if (resp === "NEEDS_AUTH") { - return unavailableUsage(this); - } + // Read-only on credentials; the Codex CLI owns refresh. See docs/providers.md. + return unavailableUsage(this); } return this.toUsage(resp); @@ -194,7 +169,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { }; } - private async readCodexAuth(): Promise { + private async readCodexAuth(): Promise { const candidates = [ ...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []), join(homedir(), ".config", "codex", "auth.json"), @@ -204,7 +179,7 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { if (!existsSync(path)) continue; try { const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - if (auth.tokens?.access_token) return { auth, path }; + if (auth.tokens?.access_token) return auth; } catch { continue; } @@ -236,39 +211,4 @@ export class CodexQuotaProvider implements ProviderUsageFetcher { if (text.trim().startsWith("<")) return "NEEDS_AUTH"; return CodexUsageResponseSchema.parse(JSON.parse(text)); } - - private async refreshCodexToken(refreshToken: string): Promise { - const params = new URLSearchParams({ - grant_type: "refresh_token", - client_id: CODEX_CLIENT_ID, - refresh_token: refreshToken, - }); - const res = await fetchProviderApi(this.fetchApi, "https://auth.openai.com/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - if (!res.ok) return null; - return CodexTokenRefreshSchema.parse(await res.json()); - } - - private async saveCodexAuth( - authPath: string, - original: CodexAuth, - refreshed: CodexTokenRefresh, - ): Promise { - try { - const updated: CodexAuth = { - ...original, - tokens: { - ...original.tokens, - access_token: refreshed.access_token ?? original.tokens?.access_token, - refresh_token: refreshed.refresh_token ?? original.tokens?.refresh_token, - }, - }; - await fs.writeFile(authPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); - } catch { - // Non-fatal; the next call can refresh again. - } - } } diff --git a/packages/server/src/services/quota-fetcher/providers/kimi.ts b/packages/server/src/services/quota-fetcher/providers/kimi.ts index 84157315dc..1e62fd6eeb 100644 --- a/packages/server/src/services/quota-fetcher/providers/kimi.ts +++ b/packages/server/src/services/quota-fetcher/providers/kimi.ts @@ -1,7 +1,6 @@ -import { randomUUID } from "node:crypto"; import { existsSync, promises as fs } from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { join } from "node:path"; import type { Logger } from "pino"; import { z } from "zod"; import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js"; @@ -15,9 +14,7 @@ import { windowFromUsedPct, } from "../usage.js"; -const KIMI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; -const KIMI_TOKEN_URL = "https://auth.kimi.com/api/oauth/token"; const KimiUsageFieldsSchema = z.object({ limit: ApiOptionalStringSchema, @@ -249,27 +246,8 @@ const KimiAuthSchema = z }) .passthrough(); -const KimiTokenRefreshSchema = z.object({ - access_token: z.string(), - refresh_token: z.string().nullish(), - expires_in: ApiNumberSchema.nullish(), - scope: z.string().nullish(), - token_type: z.string().nullish(), -}); - type KimiAuth = z.infer; -type KimiTokenRefresh = z.infer; - -interface KimiCredentialRecord { - credentials: KimiAuth & { access_token: string }; - filePath: string | null; -} - -interface SaveRefreshedCredentialsOptions { - filePath: string; - refreshTokenUsed: string; - refreshed: KimiTokenRefresh; -} +type KimiCredentials = KimiAuth & { access_token: string }; interface KimiQuotaProviderOptions { logger: Logger; @@ -295,9 +273,10 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { const credentials = await this.readCredentials(); if (!credentials) return unavailableUsage(this); - const res = await this.fetchUsageResponse(credentials); + const res = await this.callUsageApi(credentials.access_token); if (!res.ok) { + // Read-only on credentials; the Kimi CLI owns refresh. See docs/providers.md. this.logger.debug({ status: res.status }, "Kimi usage fetch failed"); return unavailableUsage(this); } @@ -316,27 +295,6 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { }; } - private async fetchUsageResponse(record: KimiCredentialRecord): Promise { - const { credentials, filePath } = record; - const res = await this.callUsageApi(credentials.access_token); - - if (res.status !== 401 || !filePath || !credentials.refresh_token) return res; - - const latest = await this.readCredentialFile(filePath); - const retried = - latest?.access_token && latest.access_token !== credentials.access_token - ? await this.callUsageApi(latest.access_token) - : res; - if (retried.status !== 401) return retried; - - const refreshTokenUsed = latest?.refresh_token ?? credentials.refresh_token; - const refreshed = await this.refreshToken(refreshTokenUsed); - if (!refreshed) return retried; - - await this.saveRefreshedCredentials({ filePath, refreshTokenUsed, refreshed }); - return this.callUsageApi(refreshed.access_token); - } - private async callUsageApi(token: string): Promise { return fetchProviderApi(this.fetchApi, KIMI_USAGE_URL, { headers: { @@ -346,95 +304,16 @@ export class KimiQuotaProvider implements ProviderUsageFetcher { }); } - private async refreshToken(refreshToken: string): Promise { - const body = new URLSearchParams({ - client_id: KIMI_CLIENT_ID, - grant_type: "refresh_token", - refresh_token: refreshToken, - }); - const res = await fetchProviderApi(this.fetchApi, KIMI_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - if (!res.ok) { - this.logger.debug({ status: res.status }, "Kimi token refresh failed"); - return null; - } - const parsed = KimiTokenRefreshSchema.safeParse(await res.json()); - return parsed.success ? parsed.data : null; - } - - private async saveRefreshedCredentials(options: SaveRefreshedCredentialsOptions): Promise { - const { filePath, refreshTokenUsed, refreshed } = options; - const existing = await this.readCredentialFile(filePath); - if (!existing) return; - - // The Kimi CLI owns this file too. If its refresh token no longer matches the one we - // just spent, the CLI rotated the credentials while our refresh was in flight and its - // copy is newer than ours — writing our merge would strand the CLI on dead tokens. - if (existing.refresh_token !== refreshTokenUsed) { - this.logger.debug("Kimi credentials rotated during refresh; keeping the file on disk"); - return; - } - - const expiresIn = refreshed.expires_in ?? existing.expires_in; - const merged: KimiAuth = { - ...existing, - access_token: refreshed.access_token, - refresh_token: refreshed.refresh_token ?? existing.refresh_token, - expires_in: expiresIn, - expires_at: - expiresIn == null ? existing.expires_at : Math.floor(Date.now() / 1000) + expiresIn, - scope: refreshed.scope ?? existing.scope, - token_type: refreshed.token_type ?? existing.token_type, - }; - - const tempPath = join( - dirname(filePath), - `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, - ); - try { - await fs.writeFile(tempPath, JSON.stringify(merged, null, 2), { mode: 0o600 }); - - // writeFile above is async I/O that gives the CLI a window to rotate the file on - // its own. Re-read right before the rename and confirm nothing changed since the - // guard above — the check-then-write gap this closes is what the file-level guard - // couldn't cover, since it ran before the write, not right before the replace. - const beforeRename = await this.readCredentialFile(filePath); - if (!this.credentialsUnchangedSince(existing, beforeRename)) { - this.logger.debug("Kimi credentials rotated during refresh; discarding stale merge"); - await fs.rm(tempPath, { force: true }).catch(() => undefined); - return; - } - - await fs.rename(tempPath, filePath); - } catch (error) { - this.logger.warn({ err: error }, "Failed to persist refreshed Kimi credentials"); - await fs.rm(tempPath, { force: true }).catch(() => undefined); - } - } - - private credentialsUnchangedSince(baseline: KimiAuth, candidate: KimiAuth | null): boolean { - return ( - candidate?.access_token === baseline.access_token && - candidate?.refresh_token === baseline.refresh_token - ); - } - - private async readCredentials(): Promise { + private async readCredentials(): Promise { const environmentToken = process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"]; if (environmentToken) { - return { credentials: { access_token: environmentToken }, filePath: null }; + return { access_token: environmentToken }; } for (const path of this.credentialPaths()) { const credentials = await this.readCredentialFile(path); if (credentials?.access_token) { - return { - credentials: { ...credentials, access_token: credentials.access_token }, - filePath: path, - }; + return { ...credentials, access_token: credentials.access_token }; } } return null; diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 47d095988f..dc324f1621 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -1,6 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs"; -import { promises as fsPromises } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -528,43 +527,27 @@ describe("real provider usage fetchers", () => { expect(fetchApi).not.toHaveBeenCalled(); }); - it("refreshes Claude access tokens on 401 and retries", async () => { + it("returns unavailable on 401 without refreshing or rewriting credentials", async () => { writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); + const credPath = join(claudeHome, ".credentials.json"); + const before = readFileSync(credPath, "utf8"); let usageCalls = 0; fetchApi = vi.fn(async (url: RequestInfo | URL) => { const endpoint = url.toString(); if (endpoint === "https://api.anthropic.com/api/oauth/usage") { usageCalls += 1; - if (usageCalls === 1) return new Response(null, { status: 401 }); - return jsonResponse(makeClaudeResponse()); - } - if (endpoint === "https://platform.claude.com/v1/oauth/token") { - return jsonResponse({ access_token: "at_refreshed", refresh_token: "rt_new" }); + return new Response(null, { status: 401 }); } + // The read-only fetcher must never hit the OAuth token endpoint. throw new Error(`Unmocked: ${endpoint}`); }) as never; const result = await service().listUsage(); - expect(findProvider(result, "claude").status).toBe("available"); - expect(usageCalls).toBe(2); - }); - - it("returns unavailable Claude usage when 401 persists after refresh", async () => { - writeClaudeCredentials(claudeHome, "at_bad", "rt_bad"); - fetchApi = mockFetch( - new Map([ - ["https://api.anthropic.com/api/oauth/usage", () => new Response(null, { status: 401 })], - [ - "https://platform.claude.com/v1/oauth/token", - () => jsonResponse({ access_token: "at_still_bad", refresh_token: "rt_still_bad" }), - ], - ]), - ); - - const result = await service().listUsage(); - expect(findProvider(result, "claude").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The credentials file must be left untouched for the Claude CLI to own. + expect(readFileSync(credPath, "utf8")).toBe(before); }); it("does not refresh Claude tokens read from the macOS Keychain", async () => { @@ -630,7 +613,6 @@ describe("real provider usage fetchers", () => { "https://chatgpt.com/backend-api/wham/usage", () => new Response("Login", { status: 200 }), ], - ["https://auth.openai.com/oauth/token", () => new Response(null, { status: 401 })], ]), ); @@ -639,42 +621,42 @@ describe("real provider usage fetchers", () => { expect(findProvider(result, "codex").status).toBe("unavailable"); }); - it("persists refreshed Codex tokens to the auth file that was read", async () => { - const alternateCodexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-alt-")); - process.env["CODEX_HOME"] = alternateCodexHome; - writeFileSync(join(alternateCodexHome, "auth.json"), JSON.stringify({ tokens: {} })); - writeCodexAuth(codexHome, "at_codex_stale", "rt_codex_valid"); - - let usageCalls = 0; - fetchApi = mockFetch( - new Map([ - [ - "https://chatgpt.com/backend-api/wham/usage", - () => { - usageCalls += 1; - if (usageCalls === 1) return new Response(null, { status: 401 }); - return jsonResponse(makeCodexResponse()); - }, - ], - [ - "https://auth.openai.com/oauth/token", - () => jsonResponse({ access_token: "at_codex_fresh", refresh_token: "rt_codex_fresh" }), - ], - ]), + it("returns unavailable on 401 without refreshing or rewriting auth.json", async () => { + // Regression: the fetcher used to refresh the token and rewrite auth.json + // through a schema that dropped id_token (and OPENAI_API_KEY/last_refresh), + // leaving the file unparseable by the Codex CLI and forcing a re-login. + const authPath = join(codexHome, "auth.json"); + writeFileSync( + authPath, + JSON.stringify({ + OPENAI_API_KEY: null, + tokens: { + id_token: "id_codex", + access_token: "at_codex_stale", + refresh_token: "rt_codex_valid", + account_id: "acct_codex", + }, + last_refresh: "2026-07-04T20:35:00Z", + }), ); + const before = readFileSync(authPath, "utf8"); + let usageCalls = 0; + fetchApi = vi.fn(async (url: RequestInfo | URL) => { + const endpoint = url.toString(); + if (endpoint === "https://chatgpt.com/backend-api/wham/usage") { + usageCalls += 1; + return new Response(null, { status: 401 }); + } + // The read-only fetcher must never hit the OAuth token endpoint. + throw new Error(`Unmocked: ${endpoint}`); + }) as never; - try { - const result = await service().listUsage(); + const result = await service().listUsage(); - const refreshedAuth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8")); - const untouchedAuth = JSON.parse(readFileSync(join(alternateCodexHome, "auth.json"), "utf8")); - expect(findProvider(result, "codex").status).toBe("available"); - expect(refreshedAuth.tokens.access_token).toBe("at_codex_fresh"); - expect(refreshedAuth.tokens.refresh_token).toBe("rt_codex_fresh"); - expect(untouchedAuth.tokens.access_token).toBeUndefined(); - } finally { - rmSync(alternateCodexHome, { recursive: true, force: true }); - } + expect(findProvider(result, "codex").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The auth file must be left byte-for-byte untouched for the Codex CLI to own. + expect(readFileSync(authPath, "utf8")).toBe(before); }); it("fetches Copilot usage from COPILOT_TOKEN", async () => { @@ -1103,181 +1085,27 @@ describe("real provider usage fetchers", () => { expect(kimi.status).toBe("available"); }); - it("persists refreshed Kimi tokens to the credential file that was read", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired", { - preserved_field: "keep-me", - }); - const authorization: Array = []; + it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => { + writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); + const credPath = kimiCredentialPath(join(homeDir, ".kimi-code")); + const before = readFileSync(credPath, "utf8"); let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + fetchApi = vi.fn(async (url: RequestInfo | URL) => { const endpoint = url.toString(); if (endpoint === "https://api.kimi.com/coding/v1/usages") { usageCalls += 1; - authorization.push( - (init?.headers as Record | undefined)?.Authorization ?? null, - ); - if (usageCalls === 1) return new Response(null, { status: 401 }); - return jsonResponse({ usage: { limit: "100", remaining: "74" } }); + return new Response(null, { status: 401 }); } - if (endpoint === "https://auth.kimi.com/api/oauth/token") { - expect(init?.method).toBe("POST"); - expect(init?.body?.toString()).toContain("grant_type=refresh_token"); - expect(init?.body?.toString()).toContain("refresh_token=rt_kimi"); - return jsonResponse({ - access_token: "at_kimi_fresh", - refresh_token: "rt_kimi_rotated", - expires_in: 900, - }); - } - throw new Error(`Unmocked fetch: ${endpoint}`); - }) as never; - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - const persisted = JSON.parse( - readFileSync(kimiCredentialPath(join(homeDir, ".kimi-code")), "utf8"), - ); - - expect(findProvider(result, "kimi").status).toBe("available"); - expect(authorization).toEqual(["Bearer at_kimi_expired", "Bearer at_kimi_fresh"]); - expect(persisted).toMatchObject({ - access_token: "at_kimi_fresh", - refresh_token: "rt_kimi_rotated", - expires_in: 900, - scope: "kimi-code", - preserved_field: "keep-me", - }); - expect(persisted.expires_at).toBeGreaterThan(Date.now() / 1000); - }); - - it("prefers a Kimi token rewritten on disk over refreshing again", async () => { - const kimiHome = join(homeDir, ".kimi-code"); - writeKimiCredentials(kimiHome, "at_kimi_old"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { - const endpoint = url.toString(); - if (endpoint === "https://api.kimi.com/coding/v1/usages") { - usageCalls += 1; - if (usageCalls === 1) { - writeKimiCredentials(kimiHome, "at_kimi_rewritten"); - return new Response(null, { status: 401 }); - } - expect((init?.headers as Record | undefined)?.Authorization).toBe( - "Bearer at_kimi_rewritten", - ); - return jsonResponse({ usage: { limit: "100", remaining: "50" } }); - } - throw new Error(`Unmocked fetch: ${endpoint}`); + // The read-only fetcher must never hit the OAuth token endpoint. + throw new Error(`Unmocked: ${endpoint}`); }) as never; const result = await service({ kimiHomeDir: homeDir }).listUsage(); - expect(findProvider(result, "kimi").status).toBe("available"); - expect(usageCalls).toBe(2); - }); - - it("keeps Kimi credentials rotated by the CLI during the refresh", async () => { - const kimiHome = join(homeDir, ".kimi-code"); - writeKimiCredentials(kimiHome, "at_kimi_expired"); - fetchApi = mockFetch( - new Map([ - ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], - [ - "https://auth.kimi.com/api/oauth/token", - () => { - writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" }); - return jsonResponse({ - access_token: "at_kimi_fresh", - refresh_token: "rt_kimi_rotated", - expires_in: 900, - }); - }, - ], - ]), - ); - - await service({ kimiHomeDir: homeDir }).listUsage(); - const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8")); - - expect(persisted).toMatchObject({ - access_token: "at_kimi_cli", - refresh_token: "rt_kimi_cli", - }); - }); - - it("discards a refreshed Kimi merge if the CLI rotates credentials after the guard but before the rename", async () => { - const kimiHome = join(homeDir, ".kimi-code"); - writeKimiCredentials(kimiHome, "at_kimi_expired"); - fetchApi = mockFetch( - new Map([ - ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], - [ - "https://auth.kimi.com/api/oauth/token", - () => - jsonResponse({ - access_token: "at_kimi_fresh", - refresh_token: "rt_kimi_fresh", - expires_in: 900, - }), - ], - ]), - ); - - // The credential-file guard runs, passes, and only then does the CLI rotate the file - // — simulated by mutating the file as a side effect of the temp-file write, which is - // the async I/O step that sits between the guard read and the final rename. - const realWriteFile = fsPromises.writeFile; - const writeFileSpy = vi - .spyOn(fsPromises, "writeFile") - .mockImplementationOnce(async (...args: Parameters) => { - writeKimiCredentials(kimiHome, "at_kimi_cli", { refresh_token: "rt_kimi_cli" }); - return realWriteFile(...args); - }); - - await service({ kimiHomeDir: homeDir }).listUsage(); - const persisted = JSON.parse(readFileSync(kimiCredentialPath(kimiHome), "utf8")); - - expect(persisted).toMatchObject({ - access_token: "at_kimi_cli", - refresh_token: "rt_kimi_cli", - }); - - writeFileSpy.mockRestore(); - }); - - it("does not recreate a Kimi credential file deleted during the refresh", async () => { - const credentialPath = kimiCredentialPath(join(homeDir, ".kimi-code")); - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); - fetchApi = mockFetch( - new Map([ - ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], - [ - "https://auth.kimi.com/api/oauth/token", - () => { - rmSync(credentialPath, { force: true }); - return jsonResponse({ access_token: "at_kimi_fresh", expires_in: 900 }); - }, - ], - ]), - ); - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(existsSync(credentialPath)).toBe(false); - }); - - it("returns unavailable Kimi usage when the token refresh is rejected", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); - fetchApi = mockFetch( - new Map([ - ["https://api.kimi.com/coding/v1/usages", () => new Response(null, { status: 401 })], - ["https://auth.kimi.com/api/oauth/token", () => new Response(null, { status: 400 })], - ]), - ); - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - expect(findProvider(result, "kimi").status).toBe("unavailable"); + expect(usageCalls).toBe(1); + // The credentials file must be left untouched for the Kimi CLI to own. + expect(readFileSync(credPath, "utf8")).toBe(before); }); it("does not refresh Kimi tokens read from the environment", async () => { diff --git a/packages/website/package.json b/packages/website/package.json index df90b5796e..f364b38ab9 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.5.0-beta.4", + "version": "0.5.1", "private": true, "type": "module", "scripts": { diff --git a/patches/react-native-svg+15.15.3.patch b/patches/react-native-svg+15.15.3.patch new file mode 100644 index 0000000000..b6b4a85a90 --- /dev/null +++ b/patches/react-native-svg+15.15.3.patch @@ -0,0 +1,61 @@ +diff --git a/node_modules/react-native-svg/lib/commonjs/lib/extract/extractTransform.js b/node_modules/react-native-svg/lib/commonjs/lib/extract/extractTransform.js +index f22cca3..0a73681 100644 +--- a/node_modules/react-native-svg/lib/commonjs/lib/extract/extractTransform.js ++++ b/node_modules/react-native-svg/lib/commonjs/lib/extract/extractTransform.js +@@ -152,7 +152,12 @@ function extractTransform(props) { + } + function extractTransformSvgView(props) { + if (typeof props.transform === 'string') { +- return (0, _transformToRn.parse)(props.transform); ++ try { ++ return (0, _transformToRn.parse)(props.transform); ++ } catch (e) { ++ console.error(e); ++ return undefined; ++ } + } + return props.transform; + } +diff --git a/node_modules/react-native-svg/lib/module/lib/extract/extractTransform.js b/node_modules/react-native-svg/lib/module/lib/extract/extractTransform.js +index c9d3d6a..085586c 100644 +--- a/node_modules/react-native-svg/lib/module/lib/extract/extractTransform.js ++++ b/node_modules/react-native-svg/lib/module/lib/extract/extractTransform.js +@@ -142,7 +142,12 @@ export default function extractTransform(props) { + } + export function extractTransformSvgView(props) { + if (typeof props.transform === 'string') { +- return parseTransformSvgToRnStyle(props.transform); ++ try { ++ return parseTransformSvgToRnStyle(props.transform); ++ } catch (e) { ++ console.error(e); ++ return undefined; ++ } + } + return props.transform; + } +diff --git a/node_modules/react-native-svg/src/lib/extract/extractTransform.ts b/node_modules/react-native-svg/src/lib/extract/extractTransform.ts +index 0c854d6..7dc2828 100644 +--- a/node_modules/react-native-svg/src/lib/extract/extractTransform.ts ++++ b/node_modules/react-native-svg/src/lib/extract/extractTransform.ts +@@ -216,7 +216,19 @@ export function extractTransformSvgView( + props: TransformsStyle + ): TransformsStyle['transform'] { + if (typeof props.transform === 'string') { +- return parseTransformSvgToRnStyle(props.transform); ++ // A root is a real RN view, so a string transform has to be rewritten into RN's ++ // style syntax. The grammar behind this parser only accepts SVG transform functions and ++ // throws on anything else - including ordinary CSS (`none`, `rotate(-90deg)`). Every other ++ // extractor in this file already swallows that error; this one did not, so one unrecognised ++ // string escaped as an uncaught SyntaxError during render and took the whole app down. ++ // Untrusted SVG reaches this path (a repository's own favicon.svg/logo.svg is rendered ++ // through SvgXml/SvgCss), so degrade to "no transform" like the rest of the library. ++ try { ++ return parseTransformSvgToRnStyle(props.transform); ++ } catch (e) { ++ console.error(e); ++ return undefined; ++ } + } + return props.transform as TransformsStyle['transform']; + } diff --git a/public-docs/cli.md b/public-docs/cli.md index 1a017dd7a6..bac0102ecc 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -263,6 +263,7 @@ Use `PASEO_HOME` to run multiple isolated daemon instances. ```bash paseo hub login [url] # Approve and store organization-scoped CLI access +paseo hub init # Guided setup: scaffold and deploy a starter bundle here paseo hub connect [url] # Enroll this daemon using CLI access paseo hub projects # List projects in the authenticated organization paseo hub status # Show the current Hub relationship @@ -276,7 +277,13 @@ Run deploy from the project root. It reads `.paseo/hub.yml`, every direct `.pase Pass `-p, --project ` to select the target project. `--dry-run` performs the same discovery and server validation without recording or activating a revision. Both outputs include the resolved Hub, project, and discovered workflow count. -`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. The stored login is separate from the daemon relationship created by `connect`. Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved. +`login` opens the Hub approval page and stores a durable organization-scoped CLI credential under `PASEO_HOME`. In an interactive terminal it then asks whether to connect this daemon and whether to initialize and deploy a starter workflow, both defaulting to yes. Declining the connection prints `paseo hub connect ; then paseo hub init`, because the connection alone does not produce a bundle; declining only the starter prints `paseo hub init`. `--json` and non-TTY login remain login-only and never prompt. The stored login is separate from the daemon relationship created by `connect`. + +`init` runs the same guided setup on its own and requires a TTY. It connects the daemon, uses the organization's only project or asks which one, and lists the Hub app connections that can back a starter workflow. One usable connection is selected automatically; with several, you choose a **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files. + +Setup then asks which agent provider, model, and mode the starter should run, choosing from what the connected daemon reports. A provider is offered only when the daemon has it enabled with a selectable model. Suggested model and mode entries are the daemon's defaults; no provider is suggested merely because it appears first. The mode question is skipped for providers that expose no modes and asked explicitly when the daemon has modes but no default. Finally, setup asks for the identity that gates the chosen connection: a GitHub username, a Slack member ID, or a Discord user ID. It writes `.paseo/hub.yml` and `.paseo/workflows/-help.yml`, validates them against Hub, and deploys. An existing `.paseo/` directory is replaced only after you confirm. See the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle). + +Interactive logout checks the same-origin daemon relationship and asks whether to disconnect before deleting the login. Declining removes only the login. JSON and noninteractive logout never prompt or disconnect implicitly; `--disconnect-daemon` is the explicit automation path, and `--force` applies to that daemon disconnection. If a requested disconnection fails, the login is preserved. Every command resolves and normalizes its destination before Hub or daemon work. Origin precedence is an explicit command origin or `--hub`, then `PASEO_HUB_URL`, then the active stored login origin, then the hosted default `https://hub.paseo.sh`. The hosted default never overrides an active login. Credential precedence is `--api-key `, then `PASEO_HUB_API_KEY`, then a stored login for the exact resolved origin. A stored credential is never sent to a different origin. API keys passed through flags or the environment are not stored. diff --git a/public-docs/hub/activity.md b/public-docs/hub/activity.md index 5abe91b2cf..a73bb447cd 100644 --- a/public-docs/hub/activity.md +++ b/public-docs/hub/activity.md @@ -29,7 +29,7 @@ Work down this list. 1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures. 2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type. -3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name. +3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name. See [find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) and [find your Discord IDs](/docs/hub/triggers/discord#find-your-discord-ids). 4. **Did the invocation match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must be mentioned, and `pattern` or its legacy `contains` alias must prefix the text after the mention. 5. **Is the configuration you think is active actually active?** The Configuration tab shows the active revision and the last sync attempt. A failed push leaves the old revision serving. 6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`. diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md index f579995275..6df97c29e1 100644 --- a/public-docs/hub/configuration/index.md +++ b/public-docs/hub/configuration/index.md @@ -23,6 +23,60 @@ A project configuration is one versioned bundle: [single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists. +## Generated starter bundle + +`paseo hub init`, and the guided setup that interactive `paseo hub login` offers, write two files for the directory you run them in: + +```yaml +# .paseo/hub.yml +environments: + my-macbook: + kind: daemon + daemon: my-macbook + cwd: /Users/you/code/your-repo +agents: + starter: + provider: codex + model: gpt-5 + mode: full-access +``` + +The environment is named after the connected daemon and points at the directory setup ran in. `provider`, `model`, and `mode` are the runtime you chose from what that daemon reported, so the starter agent is a complete selection before its first run. `mode` is omitted for a provider that exposes none. + +```yaml +# .paseo/workflows/slack-help.yml +name: slack-help +on: slack.mention +max_runtime: 2h +filters: + workspace: T01234567 + from_users: + - U01234567 +steps: + - id: work + environment: my-macbook + max_runtime: 90m + idle_timeout: 10m + agent: starter + prompt: + - text: | + Answer with hub.reply, then complete this request and call hub.finish_execution when done. + + + ${{ paseo.prompt }} + + allow_outputs: + - type: slack.reply + max: 1 + required: true +``` + +`filters` carries the identity each provider matches on: the Slack team and member ID above, the Discord guild and user ID for a Discord starter, and `owner/name` plus your login for GitHub. + +A Discord starter is `discord-help.yml` and carries `discord.reply`, the counterpart of the `slack.reply` above. A GitHub starter is `github-help.yml` and declares no reply output; GitHub has no reply capability, so a step that must comment needs a [`github` block](/docs/hub/github) instead. + +The generated workflow allows one user in one workspace. Read [Hub security](/docs/hub/security) before widening it. + ## Sources A configuration comes from one source: diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index 24ba7b5c69..a9cc81267d 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -18,7 +18,11 @@ Log in from the machine first: paseo hub login https://hub.example.com ``` -The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`. Then enroll the daemon: +The CLI prints a URL and a verification code and opens your browser. The approved login is stored under `PASEO_HOME`. + +In an interactive terminal, login then offers to finish setup: whether to connect this daemon, and whether to initialize and deploy a starter workflow. Both default to yes. Declining the connection prints `paseo hub connect ; then paseo hub init`, since connecting alone leaves the project without a workflow. Declining only the starter prints `paseo hub init`. `--json` or non-TTY login only logs in. [Quickstart](/docs/hub/quickstart) walks through the questions. + +Enroll the daemon on its own when you declined, or when the machine is already logged in: ```sh paseo hub connect diff --git a/public-docs/hub/index.md b/public-docs/hub/index.md index 4fcfc0b4db..128555cfc5 100644 --- a/public-docs/hub/index.md +++ b/public-docs/hub/index.md @@ -28,18 +28,16 @@ Your daemons keep running agents where they always did. Hub decides when to ask ## What lives in your repository -`paseo hub init` creates a project resource file for environments and agents, plus a safe starter workflow: +Guided setup creates a project resource file for environments and agents, plus one safe starter workflow: ```text .paseo/ ├── hub.yml └── workflows/ - ├── slack-help.yml - └── partials/ - └── answer.md + └── slack-help.yml ``` -Deploy the bundle, mention the bot, and an agent starts on your machine. [Quickstart](/docs/hub/quickstart) runs the guided setup; [Workflows](/docs/hub/workflows) covers routing and provider-specific replies. +Guided setup deploys the bundle, and mentioning the bot starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter bundle](/docs/hub/configuration#generated-starter-bundle) shows what it wrote, and [Workflows](/docs/hub/workflows) covers routing, prompt partials, and provider-specific replies. ## Reading order diff --git a/public-docs/hub/quickstart.md b/public-docs/hub/quickstart.md index aa298c625d..1f9031c1c0 100644 --- a/public-docs/hub/quickstart.md +++ b/public-docs/hub/quickstart.md @@ -8,7 +8,7 @@ category: Hub # Hub quickstart -Run Hub locally, connect it to Slack without a public server, and answer a mention with an agent on your machine. +Run Hub on your machine, connect it to Slack without a public server, and answer a mention with an agent in your repository. Hub's browser setup hands off to your terminal, and guided setup writes and deploys the workflow for you. You need [Paseo installed and running](/docs), Node.js, and a Slack workspace where you can create an app. @@ -18,13 +18,13 @@ You need [Paseo installed and running](/docs), Node.js, and a Slack workspace wh npx @getpaseo/hub ``` -Open . Hub uses an embedded database by default, so this first run needs no database, environment variables, or Docker. +Open the address it prints, normally , and create the operator account Hub asks for. -Create the operator account when Hub welcomes you. +The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database, your organization, and a **Default** project. You never create a project by hand. ## 2. Connect Slack -The next screen explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and does not need a public address or HTTPS. +**Set up your apps** explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and needs no public address or HTTPS. Paste the App-level token and Bot token back into Hub, then choose **Connect Slack**. Invite the bot to the channel where you will use it: @@ -32,35 +32,63 @@ Paste the App-level token and Bot token back into Hub, then choose **Connect Sla /invite @Paseo ``` -You can skip GitHub and Discord for now. Their setup remains available under **Apps**. +GitHub and Discord can wait. Their setup stays available under **Apps**. -## 3. Initialize the project +## 3. Connect the machine your code is on -From the repository where the agent should work: +**Connect a daemon** shows one command with this Hub's address already in it: ```sh -paseo hub init +paseo hub login http://localhost:3000 ``` -Choose **Custom endpoint…** and enter `http://localhost:3000`, then choose Slack. The guided setup: +Run it on the machine where your code lives, in the repository the agent should work in. Guided setup records that directory as the workflow's working directory. -- signs you in through the browser; -- connects the local Paseo daemon; -- uses the default project created during first-run setup; -- selects the connected Slack workspace and asks for your Slack username; -- writes `.paseo/hub.yml` and `.paseo/workflows/slack-help.yml`; -- validates the generated bundle and offers to deploy it. +Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. **Continue** and **Do this later** both land in the Default project. -Accept the default **Deploy now?** choice. The generated workflow accepts mentions only from the username you entered. +## 4. Answer the setup questions -## 4. Mention the bot +Your terminal confirms the login, then picks up where the browser left off. Most questions arrive with a default or a suggested answer; only your Slack member ID has to be typed. -Mention the bot in the channel: +| Question | What it wants | +| ----------------------------------------- | --------------------------------------------------------------------------------------- | +| Connect this daemon to this Hub? | Yes. This enrolls the machine you are on. | +| Initialize and deploy a starter workflow? | Yes. | +| Starter agent provider, model, and mode | What your daemon reports it can run. Suggested model and mode entries are its defaults. | +| Your Slack member ID | `U01234567`, the only account allowed to trigger the bot. | + +Setup lists the app connections ready for this workflow. Because you connected one Slack workspace in step 2, it selects that connection automatically instead of asking you to choose Slack. If several usable connections exist, setup asks for the **Trigger connection**. If none is ready, it sends you to **Hub → Apps** and stops before asking about the agent or writing files. + +The agent provider list contains only runtimes the daemon can use; it does not suggest one arbitrarily. Suggested model and mode entries are defaults reported by the daemon. A provider that has modes but no default mode is still offered; setup asks you to pick the mode instead of guessing one. + +[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) has the two clicks that copy your member ID. The Slack workspace comes from the app you connected in step 2, so setup does not ask for it. + +Setup then validates the bundle, writes it, and deploys: ```text -@Paseo explain what this project does +.paseo/ +├── hub.yml +└── workflows/ + └── slack-help.yml ``` -Hub starts the agent on your daemon and posts its reply in the Slack thread. Open the project's **Activity** tab if nothing runs. +If `.paseo/` already exists, setup asks before replacing it. Declining the daemon connection prints `paseo hub connect ; then paseo hub init` — both commands, because connecting alone does not create the workflow. Declining only the starter workflow prints `paseo hub init`. -Hub keeps its local state in your user data directory (normally `~/.local/share/paseo-hub`). [Self-hosting](/docs/hub/self-hosting) covers PostgreSQL, public URLs, environment-managed apps, Docker, and cloud deployment. [Configuration](/docs/hub/configuration) explains the generated files and manual deployment. +## 5. Mention the bot + +In the channel you invited the bot to: + +```text +@Paseo have a look +``` + +Hub starts the agent on your daemon and posts its reply in the Slack thread. The terminal prints the project's Activity URL, where the run appears. If nothing runs, [Activity](/docs/hub/activity) tells a filtered mention from one that never matched a workflow. + +## Next + +- [How Hub works](/docs/hub/concepts) — how an event becomes a workflow run on your daemon. +- [Generated starter bundle](/docs/hub/configuration#generated-starter-bundle) — the two files setup wrote, field by field. +- [Workflows](/docs/hub/workflows) — routing, prompts, and provider replies. +- [Hub security](/docs/hub/security) — read this before widening `from_users` or giving an agent GitHub authority. + +Hub keeps its local state in your user data directory, normally `~/.local/share/paseo-hub`. [Self-hosting](/docs/hub/self-hosting) covers deployment and advanced configuration when you outgrow the local run. diff --git a/public-docs/hub/triggers/discord.md b/public-docs/hub/triggers/discord.md index a5b46b4972..ce5acda951 100644 --- a/public-docs/hub/triggers/discord.md +++ b/public-docs/hub/triggers/discord.md @@ -34,8 +34,14 @@ steps: - { type: discord.reply, max: 1, required: true } ``` -Turn on Discord Developer Mode to copy IDs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass. +Discord filters use IDs, not server names, display names, or Hub connection slugs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass. The reply posts in the triggering thread or channel. `discord.reply` grants `hub.reply`, but does not rewrite the prompt. A Discord trigger grants no GitHub credential; add a [`github` block](/docs/hub/github) to the step that needs one. Leading declared inputs follow the mention. Hub exposes the remaining text as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows). + +## Find your Discord IDs + +Turn on **Settings → Advanced → Developer Mode**, then right-click to **Copy Server ID** for `guild`, **Copy Channel ID** for `channels`, and **Copy User ID** for `from_users`. All three are numeric snowflakes; quote them so YAML keeps them as strings. + +[Guided setup](/docs/hub/quickstart) fills `guild` from the Discord app you connected and asks you for your user ID. diff --git a/public-docs/hub/triggers/slack.md b/public-docs/hub/triggers/slack.md index b6e24c6e7c..8c8797caf4 100644 --- a/public-docs/hub/triggers/slack.md +++ b/public-docs/hub/triggers/slack.md @@ -34,7 +34,7 @@ steps: - { type: slack.reply, max: 1, required: true } ``` -Slack filters use IDs, not display names. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass. +Slack filters use IDs, not display names or Hub connection slugs. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass. The reply posts in the triggering thread. A root message gets a thread; a threaded message stays there. `slack.reply` grants `hub.reply`, but does not add reply instructions to the prompt. @@ -45,3 +45,13 @@ Leading declared inputs follow the mention: ``` Hub consumes consecutive declared headers and exposes the remainder as `${{ paseo.prompt }}`. See [Workflows](/docs/hub/workflows). + +## Find your Slack IDs + +| Filter | Where to copy it | +| ------------ | ------------------------------------------------------------------------------------------- | +| `workspace` | Open Slack in a browser. The team ID is the `T…` segment of the URL. | +| `from_users` | Your avatar → **Profile** → **⋮** → **Copy member ID**. Member IDs start with `U`. | +| `channels` | Channel name → **About**. The channel ID is at the bottom of the panel and starts with `C`. | + +[Guided setup](/docs/hub/quickstart) fills `workspace` from the Slack app you connected and asks you for your member ID, so a generated starter already carries both. diff --git a/public-docs/security.md b/public-docs/security.md index 52363a10fa..bfe0993bdd 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -52,8 +52,6 @@ The daemon requires a valid cryptographic handshake before processing any comman The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password, don't share it publicly. -If you believe a pairing offer has been compromised, restart the daemon to generate a new session ID and rotate the relay pairing. - ## Direct connections By default, the daemon listens on `127.0.0.1:6767` (localhost only). This is safe for local CLI usage but not reachable from your phone or other devices. diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 63506abc7a..2a762c7a5f 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -20,6 +20,10 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+", }, + { + nodeModulesPath: "node_modules/react-native-svg", + patchPrefix: "react-native-svg+", + }, { nodeModulesPath: "node_modules/@mattermost/react-native-paste-input", patchPrefix: "@mattermost+react-native-paste-input+",