Compare commits

...

43 Commits

Author SHA1 Message Date
Mohamed Boudra
7c02b9ec7c fix(search): address workspace search review feedback 2026-07-01 19:07:53 +02:00
Mohamed Boudra
9905b0da31 fix(search): include dot-prefixed workspace entries
Workspace suggestions now use the indexed search backend directly, so dotfiles and dot directories are included without a second ignore layer. Packaged desktop smoke covers the native search path.
2026-06-30 23:46:32 +02:00
Mohamed Boudra
db03b1f3fd docs: link paseo-vscode extension 2026-06-30 16:18:03 +02:00
Mohamed Boudra
cb486c3a5a Speed up app Playwright CI (#1830)
* ci(playwright): shard app e2e in CI

Run the app Playwright suite across isolated CI shards and keep restarted E2E daemons on the same speech-disabled setup path as global setup.

* test(app): share disabled speech e2e env
2026-06-30 12:37:47 +02:00
paseo-ai[bot]
7dad7a377c fix: update lockfile signatures and Nix hash [skip ci] 2026-06-30 10:05:14 +00:00
Mohamed Boudra
e32c50f2d7 chore(release): cut 0.1.102 2026-06-30 12:00:31 +02:00
Mohamed Boudra
e3eb633582 docs: update changelog for 0.1.102 2026-06-30 11:23:41 +02:00
Mohamed Boudra
beac4544fd chore: update ACP provider catalog 2026-06-30 11:22:12 +02:00
Mohamed Boudra
c2271ff0ca Place turn footers after trailing tools (#1827)
* fix(app): place turn footers after trailing tools

Completed turn footers now render on the last visible item before the next user message while still using the latest assistant message for footer content and timing.

* test(app): cover split turn footer placement
2026-06-30 10:51:53 +02:00
Mohamed Boudra
f91806defe Use separate OpenAI endpoints for speech-to-text and text-to-speech (#1823)
* feat(voice): configure OpenAI STT and TTS endpoints separately

Replace providers.openai.voice (a single apiKey/baseUrl shared by
speech-to-text and text-to-speech) with independent providers.openai.stt
and providers.openai.tts, each carrying its own apiKey/baseUrl. STT and
TTS now resolve fully independently, so they can point at different
OpenAI-compatible endpoints. The env equivalents OPENAI_VOICE_API_KEY /
OPENAI_VOICE_BASE_URL split into OPENAI_STT_* and OPENAI_TTS_*.

No backcompat: the voice key is removed and no longer read. Each feature
still falls back to providers.openai.apiKey/baseUrl, then OPENAI_API_KEY/
OPENAI_BASE_URL. Composer dictation resolves from the STT endpoint.

* fix(voice): keep daemon bootable and respect global OpenAI key

Two issues from review of the STT/TTS endpoint split:

- A config from an older release that still sets providers.openai.voice
  crashed daemon startup, because the strict schema rejects the now-unknown
  key. Strip it before parsing (alongside the existing local.autoDownload
  strip) so the daemon boots; the value is discarded, not migrated.
- An empty endpoint env var (e.g. a copied .env.example leaving
  OPENAI_STT_API_KEY= blank) shadowed the OPENAI_API_KEY fallback, so
  speech was reported as missing credentials despite a configured global
  key. firstDefined now skips empty/whitespace strings.

* fix(voice): isolate STT and TTS option parsing per endpoint

An STT-only OpenAI setup could be broken by a stale or invalid TTS env
var (e.g. a leftover TTS_VOICE/TTS_MODEL), because the single resolution
schema validated both endpoints' option groups before the per-endpoint
gate. Split into endpoint-key, STT-option, and TTS-option schemas and
parse each option group only when that endpoint has credentials, so an
unused endpoint's bad env can no longer take down the configured one.

* fix(voice): tag voice-config shim and update direct-daemon test callers

- Mark the providers.openai.voice strip with a COMPAT(openaiVoiceConfig)
  comment + removal date so it shows up in the back-compat cleanup
  inventory, per repo convention.
- Update the tests that build the daemon directly with a resolved OpenAI
  config (bootstrap smoke + the real-API voice/daemon e2e suites) to the
  new { stt, tts } shape; the old top-level { apiKey } is no longer read,
  and these files are excluded from typecheck so the break was silent.

* fix(voice): update voice-roundtrip debug script to new OpenAI config shape

Last direct daemon caller still passing the removed top-level
openai: { apiKey }; the debug script lives outside tsconfig.scripts.json
so the stale shape wasn't caught by typecheck. Use { stt, tts }.
2026-06-30 10:00:27 +02:00
Mohamed Boudra
e9431517a0 Show host in search results and filter sidebar by multiple hosts (#1825)
* feat(app): show host in command center and multi-select host filter

Command-center agent results now show which host the workspace lives on,
shown only when more than one host is connected (matching the sidebar).

The sidebar display-preferences filter becomes multi-select — pick any
number of hosts instead of one-or-all — and its host rows now carry a
status dot on the left like the other host pickers. Persisted filter
state migrates from the old single host to a host list.

* test(app): extract host-filter and command-center e2e DSL helpers

Move the mechanical menu/search interactions out of the new spec bodies
into focused helpers so the tests read as user intent.

* fix(app): drop the icon from the host filter "All hosts" item

* fix(app): make multi-host e2e seeding order-independent

The offline-host seed relied on Playwright running the test's init script
after the fixture's registry reset — an ordering Playwright does not
guarantee. Write the full registry and set the fixture's disable-once flag,
then reload, so the multi-host seed survives regardless of script order.

Also tag the pre-v2 hostFilter migration reader with COMPAT so the cleanup
sweep can find it.
2026-06-30 09:40:24 +02:00
Xisheng Parker Zhao
4e28b0941b fix(server): surface Kiro CLI slash commands and skills over ACP (#1792)
* fix(server): surface Kiro CLI slash commands and skills over ACP

Kiro CLI advertises its slash commands and skills through the
`_kiro.dev/commands/available` ACP extension notification rather than the
standard `available_commands_update` session update. ACPAgentSession only
trace-logged extension notifications and dropped the payload, so the
composer never showed any Kiro commands or skills.

- Map the extension payload onto the shared slash-command cache:
  `commands` (built-in, names arrive with a leading "/") and `prompts`
  (skills/prompts, tagged with a `skill:` serverName) are normalized to
  Paseo's AgentSlashCommand shape, stripping the leading slash and pulling
  the argument hint from `meta.hint`.
- Add KiroACPAgentClient (mirrors CursorACPAgentClient) so the provider
  waits for the first async command batch before listCommands() resolves,
  since Kiro emits the notification shortly after session/new.

* fix(server): settle on empty Kiro batch; inject Kiro commands via constructor option

Addresses review feedback on #1792.

Keep vendor-specific providers out of the generic acp-agent.ts, following
the same pattern as Cursor/Copilot: provider behavior is injected through
ACPAgentClient constructor options rather than special-cased in the base
class. Adds an optional extensionCommandsParser option on the generic ACP
client/session; the base extNotification() invokes it and routes any parsed
commands through a generic applyResolvedCommands() helper. Kiro supplies
parseKiroExtensionCommands (recognizes _kiro.dev/commands/available); the
base class no longer references any Kiro method string or payload shape.

Also fixes an empty-batch hang: applyResolvedCommands() always settles the
initial-commands gate, so a Kiro user with no slash commands no longer
blocks listCommands() for the full 10s timeout. Adds a regression test.
2026-06-30 09:36:48 +02:00
Mohamed Boudra
7c6152663e Show found desktop updates after manual checks (#1815)
* fix(desktop): report found app updates during manual checks

Manual checks could reuse cached update state while the renderer discarded pending update details. Keep found update versions visible while downloads prepare, and reserve install affordances for ready updates.

* test(desktop): cover manual update retry after errors

* fix(desktop): keep ready updates ready on recheck
2026-06-30 08:12:15 +02:00
paseo-ai[bot]
5fc53c576e fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 22:07:09 +00:00
Mohamed Boudra
8eb9640f28 chore(release): cut 0.1.102-beta.2 2026-06-30 00:02:06 +02:00
Mohamed Boudra
0798f997c3 docs: update changelog for 0.1.102-beta.2 2026-06-29 23:59:58 +02:00
Mohamed Boudra
68a169b29d Update ACP provider catalog 2026-06-29 23:27:27 +02:00
Mohamed Boudra
b571874852 Update fork menu tooltip and icons
- Change tooltip label from "Fork message" to "Fork chat"

- Use Split icon for both "Fork in a new tab" and "Fork in a new workspace" menu items
2026-06-29 23:25:05 +02:00
Mohamed Boudra
ad803005c8 feat(app): show the connection in use on host switcher rows
The old marker only ever tagged the local host as "Local" based on its
server id. Each row now shows the connection actually in use, inline
after the host name — the TCP or relay endpoint (default :443/:80
stripped), or "Local" for socket/pipe transports.
2026-06-29 23:11:29 +02:00
Mohamed Boudra
86775c18f3 fix(app): hide turn actions during active tool calls
Tool rows can arrive before a running turn resumes text. Treat those rows as part of the active turn so copy/fork actions wait until the turn closes.
2026-06-29 23:11:29 +02:00
Mohamed Boudra
cf2fccd01d Keep agent lists working when project records go stale (#1812)
* fix(server): keep agent fetch RPCs resilient

Agent directory projection assumed every workspace still had a parent project. A stale workspace/project record could reject the whole fetch_agents RPC, so missing placement now behaves like no placement and direct fetches can still return the agent.

* test(server): cover stale agent history fetches
2026-06-29 23:00:40 +02:00
Mohamed Boudra
811f52395e Fix Windows image previews (#1811)
* fix(images): preserve Windows preview paths

Provider image markdown used escaped backslash paths on Windows, which markdown parsing could turn into encoded drive paths that the app treated as workspace-relative. Emit drive-letter paths as file URIs and normalize existing encoded local paths before file preview.

* test(server): accept file URI image sources

Windows provider image output now renders drive-letter paths as file URIs. Decode those markdown sources before filesystem assertions so the image rendering tests keep checking the materialized file.

* fix(images): keep plain local paths literal

Limit legacy URI decoding to markdown-encoded Windows drive paths so ordinary local paths with percent sequences remain unchanged. Document why provider image markdown matching still accepts old doubled-backslash history.
2026-06-29 20:49:13 +00:00
Mohamed Boudra
df36a958f8 Make daemon status report health without loading agents (#1810)
* fix(cli): avoid agent inventory in daemon status

* fix(cli): keep daemon status reachable on detail failure
2026-06-29 20:40:11 +00:00
Mohamed Boudra
111fdb81fd Show desktop update check feedback (#1808)
* fix(desktop): show app update check feedback

Manual desktop app update checks now leave visible status feedback even when the shared update state is pending or available. Updater check and preparation errors are carried through the existing result path so the settings row and callout can show the failure instead of only logging it.

* fix(desktop): make update retries perform fresh checks

Manual retries now clear runtime errors emitted by a failed check so the next click calls the updater again. Background checks also skip while a visible manual check is active, and last-checked update copy uses complete localized strings.

* fix(desktop): punctuate update check timestamp copy

* fix(desktop): share runtime update errors

* fix(desktop): preserve update preparation errors

* fix(desktop): settle update check review races

* fix(desktop): settle quiet update check errors

* fix(desktop): handle overlapping update checks

* fix(desktop): preserve preparation errors during checks
2026-06-29 21:00:42 +02:00
yz
efd7ab3420 Fix macOS packaged CLI daemon Dock icons (#1759)
* Fix macOS CLI daemon relaunch path

* test(cli): mock helper existence in daemon launch test

* fix(desktop): launch packaged CLI through Helper

The packaged macOS CLI shim entered through the main app executable, so daemon supervision inherited app lifecycle behavior and surfaced Dock icons. Make Helper the required macOS CLI runtime, keep daemon relaunches on process.execPath, and cover cold bundled CLI daemon starts in release smoke.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-29 20:45:53 +02:00
Mohamed Boudra
f94ec0e430 Keep New Workspace on the current project (#1806)
* fix(app): keep New Workspace on the active project

New Workspace preselection was falling back to stale remembered host state when multiple hosts existed. Pass durable active-project context from workspace entry points and centralize initial host selection so stale offline hosts cannot steal the form while startup restore remains last-workspace only.

* fix(app): address New Workspace review feedback

* fix(app): stabilize New Workspace host selection

* fix(app): preserve New Workspace host fallback

* test(app): loosen stale-host project assertion

* fix(app): prefer reachable new workspace hosts

* fix(app): avoid stale offline workspace preselect

* fix(app): prefer online project hosts
2026-06-29 20:44:18 +02:00
Mohamed Boudra
0614618d2f Merge branch 'main' of github.com:getpaseo/paseo 2026-06-29 20:24:57 +02:00
Mohamed Boudra
f31bc2b8b7 Fix pushed state for checked-out PR worktrees (#1804)
* fix(worktree): track checkout PR branches correctly

Checkout-PR worktrees could point branch tracking at a PR push remote without fetching a matching remote-tracking ref. Configure same-repo PRs to track origin, make fork PR remotes fetchable before assigning upstream, and push fork PR branches to their configured remote.

* fix(worktree): scope PR push routing to Paseo targets

Review caught that using ordinary upstream config for Push could send normal branches to non-origin remotes and mishandle deduped PR branch names. Store a Paseo-specific push target during PR worktree creation, then have Push honor only that marker before falling back to origin.

* fix(worktree): use git upstream for PR pushes

* fix(worktree): allow PR checkout without head upstream

* fix(worktree): preserve fork PR git push target

* fix(worktree): preserve PR push targets

* fix(worktree): preserve PR push status

* fix(worktree): set upstream after push-only PR push
2026-06-29 20:21:19 +02:00
paseo-ai[bot]
31d14ec8a7 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-29 17:43:59 +00:00
Mohamed Boudra
a1afdfee51 Fix Claude subagent narration leaking into chat (#1807)
* fix(claude): keep subagent text out of parent transcript

Sidechain tool activity is folded into the parent Task/Agent row, but sidechain assistant text was still going through the top-level text assembler. That made the chat show subagent narration without its matching tool rows.

* test(claude): simplify subagent sidechain assertion
2026-06-29 19:40:15 +02:00
Mohamed Boudra
b5559dc1de Keep streamed chat images in order (#1805)
* fix(app): preserve assistant stream order

Assistant head flushing only considered item kind, so a new identified assistant message could promote ahead of earlier live rows. Flush the live head when assistant message ids diverge so live rows remain an ordered suffix.

* refactor(app): reuse assistant message id helper
2026-06-29 16:38:52 +00:00
Mohamed Boudra
85acaceb16 Fork assistant turns into new drafts (#1788)
* feat(chat): fork assistant turns into new drafts

Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace.

* fix(chat): clean up fork draft handling

* fix(chat): address fork draft review feedback

* fix(server): build fork context from bounded timeline

* fix(app): restore assistant turn footer in streams

* fix(app): preserve fork draft setup

* fix(app): tighten assistant fork footers

* fix(app): lock active fork draft handoff

* fix(app): preserve attachment-only draft retries

* fix(app): unify composer attachment scopes

* fix(app): align chat history attachment labels

* fix(app): centralize attachment pill content

* fix(app): trim fork context for workspace naming
2026-06-29 18:29:59 +02:00
Mohamed Boudra
e63a971968 Make daemon shutdowns easier to diagnose (#1790)
* fix(daemon): log stop reasons and client identity

Record websocket client identity, process memory/uptime, and shutdown reasons across CLI, desktop, supervisor, and worker paths so daemon drops can be traced from the triggering client to worker termination.

* fix(daemon): keep shutdown diagnostics in sync

Test drift: supervisor and relay tests still asserted the old log text and metadata shape after shutdown diagnostics started logging structured reasons and relay connection ids. Update those assertions to the new diagnostic contract.

Also centralize client lifecycle reason normalization and derive desktop daemon stop reasons from one tuple so future changes cannot silently drift.
2026-06-29 23:31:11 +08:00
Mohamed Boudra
b613bea9f6 Default client RPC waits to 60 seconds (#1789)
* fix(client): wait longer for session responses

* fix(client): default RPC waits to sixty seconds

* fix(app): preserve detached stream scroll on delayed history

Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.

Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.

* fix(client): keep helper waits within caller deadlines

Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
2026-06-29 17:23:18 +02:00
Mohamed Boudra
57800a0f17 Restore the "Drop files here" backdrop when dragging files (#1801)
Decouples file drag-and-drop into a FileDropZone provider (drag target + backdrop) and a useFileDrop consumer hook, with drag state on a Reanimated shared value so dragging triggers no React re-renders.

Fixes the collapsed/invisible backdrop (the drop zone had been moved into the composer with flex:0, collapsing the overlay to zero height) and restores whole-surface coverage across the agent panel, New Workspace screen, draft workspace tab, and workspace-setup dialog. The backdrop and copy cursor only appear when a drop will actually be accepted (a consumer is mounted and not submitting).

Refs #520
2026-06-29 16:55:45 +02:00
Mohamed Boudra
0d1c8db87d fix(settings): align host switcher with sidebar rows
The host picker reused the composer trigger's content-width pill, so its
label sat 6px left of the section rows and the chevron hugged the label
instead of the trailing edge. Add a `block` mode to ComboboxTrigger that
fills the width and uses the sidebar-row gap, and size the status dot to
the icon footprint so the label and chevron line up with the rows below.
2026-06-29 12:16:20 +02:00
agamotto
6a23bbd121 Fix Windows OpenCode process cleanup 2026-06-28 19:11:22 +07:00
Mohamed Boudra
3288e1cfb9 Stop agent prompts renaming existing workspaces 2026-06-28 19:10:59 +07:00
Mohamed Boudra
cb212b4e5c Fix mobile launch with a saved workspace 2026-06-28 19:10:39 +07:00
Mohamed Boudra
4968969c87 Preserve chat scroll-away after delayed history 2026-06-28 19:10:12 +07:00
Mohamed Boudra
96573d0bcc Keep slash commands visible after New Workspace (#1760)
* fix(app): keep slash commands visible after New Workspace

Repeated returns from the app-wide New Workspace route were using dismissTo with the workspace leaf URL. That updated the root URL without popping the nested host stack, so hidden duplicate workspace deck entries could remain mounted and steal composer popover measurements.

Dispatch a root-stack POP_TO into the host workspace route instead, with a Playwright regression for the slash-command popover.

* test(app): tighten workspace autocomplete regression

Derive the expected deck count from the seeded workspaces and make the duplicate-deck assertion hard. Keep the Expo Router pop hint documented because removing it reproduces the hidden deck entries.
2026-06-28 03:01:01 +00:00
Mohamed Boudra
9b6aa99396 feat(sidebar): show host name on every row in multi-host setups (#1775)
The host name previously appeared only in project grouping mode, and only
for projects shared across hosts. It now shows on every workspace row in
both project and status grouping modes whenever the sidebar spans more than
one host, so you can always tell which machine a workspace lives on. Status
rows read "Project · Host". Single-host setups are unchanged.
2026-06-28 08:50:04 +07:00
Mohamed Boudra
276c1f48f1 ci: add Docker-only publish dispatch [skip ci] 2026-06-27 09:49:20 +00:00
277 changed files with 13198 additions and 3192 deletions

View File

@@ -241,6 +241,11 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: playwright (shard ${{ matrix.shard }}/4)
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -280,7 +285,7 @@ jobs:
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -288,7 +293,7 @@ jobs:
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-results
name: playwright-results-${{ matrix.shard }}
path: |
packages/app/test-results/
packages/app/playwright-report/

View File

@@ -329,6 +329,18 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Windows arm64 native search packages
shell: bash
run: |
set -euo pipefail
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |

View File

@@ -13,9 +13,34 @@ on:
workflow_dispatch:
inputs:
paseo_version:
description: "Paseo npm version to build for validation. Defaults to latest."
description: "Paseo version to build. Required when publish is true."
required: false
default: ""
publish:
description: "Publish the image to GHCR. Manual publishes require paseo_version."
required: false
default: "false"
type: choice
options:
- "false"
- "true"
source_build:
description: "Build from the checked-out source tree instead of npm."
required: false
default: "auto"
type: choice
options:
- auto
- "false"
- "true"
publish_latest:
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
required: false
default: "false"
type: choice
options:
- "false"
- "true"
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
@@ -41,6 +66,9 @@ jobs:
- id: values
env:
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
INPUT_PUBLISH: ${{ inputs.publish }}
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
REPO_OWNER: ${{ github.repository_owner }}
REF_NAME: ${{ github.ref_name }}
run: |
@@ -51,6 +79,7 @@ jobs:
install_version="${INPUT_PASEO_VERSION:-latest}"
publish=false
source_build=false
publish_latest=false
prerelease=false
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
@@ -59,14 +88,49 @@ jobs:
if [[ "${REF_NAME}" == *-* ]]; then
prerelease=true
source_build=true
else
publish_latest=true
fi
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then
if [[ -z "${INPUT_PASEO_VERSION}" || "${INPUT_PASEO_VERSION}" == "latest" ]]; then
echo "::error::paseo_version is required for manual Docker publishes."
exit 1
fi
publish=true
fi
if [[ "${install_version}" == *-* ]]; then
prerelease=true
fi
case "${INPUT_SOURCE_BUILD:-auto}" in
true)
source_build=true
;;
false)
source_build=false
;;
auto)
if [[ "${prerelease}" == "true" ]]; then
source_build=true
fi
;;
*)
echo "::error::source_build must be auto, true, or false."
exit 1
;;
esac
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
publish_latest=true
fi
fi
check_tag="${image}:check-${GITHUB_SHA::12}"
if [[ "${prerelease}" == "true" ]]; then
publish_tags="${image}:${install_version}"
else
publish_tags="${image}:${install_version}"$'\n'"${image}:latest"
publish_tags="${image}:${install_version}"
if [[ "${publish_latest}" == "true" ]]; then
publish_tags="${publish_tags}"$'\n'"${image}:latest"
fi
{

View File

@@ -1,37 +1,49 @@
# Changelog
## 0.1.102-beta.1 - 2026-06-27
## 0.1.102 - 2026-06-30
### Added
- Use Paseo from a browser served directly by your daemon ([#1635](https://github.com/getpaseo/paseo/pull/1635))
- Fork chats into a new tab or new worktree ([#1788](https://github.com/getpaseo/paseo/pull/1788))
- See workspaces from all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538), [#1775](https://github.com/getpaseo/paseo/pull/1775), [#1825](https://github.com/getpaseo/paseo/pull/1825))
- Daemon can now serve the web UI ([#1635](https://github.com/getpaseo/paseo/pull/1635), [#1739](https://github.com/getpaseo/paseo/pull/1739))
- Run Paseo from an official Docker image ([#1740](https://github.com/getpaseo/paseo/pull/1740) by [@Herbrant](https://github.com/Herbrant))
- Update a remote host from the app when a newer daemon is available ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Dropped files can be attached from every composer ([#1750](https://github.com/getpaseo/paseo/pull/1750))
- MiniMax usage is available in provider quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- C# code blocks now have syntax highlighting ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
- Update a daemon remotely from the app ([#1513](https://github.com/getpaseo/paseo/pull/1513) by [@thedavidweng](https://github.com/thedavidweng))
- Configure separate OpenAI endpoints for speech-to-text and text-to-speech ([#1823](https://github.com/getpaseo/paseo/pull/1823))
- Drop files into any composer ([#1750](https://github.com/getpaseo/paseo/pull/1750), [#1801](https://github.com/getpaseo/paseo/pull/1801))
- Show MiniMax usage in quota views ([#1662](https://github.com/getpaseo/paseo/pull/1662) by [@ilteoood](https://github.com/ilteoood))
- Highlight C# code blocks ([#1651](https://github.com/getpaseo/paseo/pull/1651) by [@dev693](https://github.com/dev693))
### Improved
- The sidebar can show workspaces across all connected hosts ([#1538](https://github.com/getpaseo/paseo/pull/1538))
- Creating a new workspace is available from an app-wide screen ([#1746](https://github.com/getpaseo/paseo/pull/1746))
- Project search shows loading progress while results are fetched ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Pi waits longer for extension results before timing out ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- New Workspace opens from anywhere ([#1746](https://github.com/getpaseo/paseo/pull/1746), [#1806](https://github.com/getpaseo/paseo/pull/1806))
- Project search shows loading progress ([#1762](https://github.com/getpaseo/paseo/pull/1762))
- Desktop update checks show clearer status ([#1808](https://github.com/getpaseo/paseo/pull/1808), [#1815](https://github.com/getpaseo/paseo/pull/1815))
- Slow remote hosts time out less aggressively ([#1789](https://github.com/getpaseo/paseo/pull/1789))
- Pi waits longer for extension results ([#1732](https://github.com/getpaseo/paseo/pull/1732) by [@theslava](https://github.com/theslava))
- Open file tabs refresh when you revisit them ([#1699](https://github.com/getpaseo/paseo/pull/1699) by [@cleiter](https://github.com/cleiter))
- Web terminals stay smoother while scrolling ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
- ACP provider catalog updated to the latest registry versions
- Web terminals scroll more smoothly ([#1622](https://github.com/getpaseo/paseo/pull/1622) by [@TommyLike](https://github.com/TommyLike))
### Fixed
- Freshly added projects can be edited without restarting ([#1761](https://github.com/getpaseo/paseo/pull/1761) by [@huiliaoning](https://github.com/huiliaoning))
- Opening projects and directory suggestions work better in large repos ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Desktop file uploads keep their file extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Closing Claude Code sessions no longer leaves child processes running ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- ACP sessions load with the correct project folder and MCP server settings ([#1624](https://github.com/getpaseo/paseo/pull/1624) by [@theslava](https://github.com/theslava))
- Docker images serve the bundled web UI from global installs
- Docker web UI connections work when opened through either `localhost` or `127.0.0.1`
- Docker images can extract downloaded local speech models
- Large repos open more reliably ([#1620](https://github.com/getpaseo/paseo/pull/1620) by [@jms830](https://github.com/jms830))
- Mobile restores the saved workspace on launch ([#1777](https://github.com/getpaseo/paseo/pull/1777))
- Agent prompts no longer rename workspaces ([#1779](https://github.com/getpaseo/paseo/pull/1779))
- Chat stays put when delayed history arrives ([#1776](https://github.com/getpaseo/paseo/pull/1776))
- Streamed chat images stay in order ([#1805](https://github.com/getpaseo/paseo/pull/1805))
- Chat actions stay below tool output ([#1827](https://github.com/getpaseo/paseo/pull/1827))
- Claude subagent narration stays out of chat ([#1807](https://github.com/getpaseo/paseo/pull/1807))
- Kiro slash commands and skills appear in Paseo ([#1792](https://github.com/getpaseo/paseo/pull/1792) by [@park0er](https://github.com/park0er))
- Agent lists survive stale project records ([#1812](https://github.com/getpaseo/paseo/pull/1812))
- Windows image previews handle drive-letter paths ([#1811](https://github.com/getpaseo/paseo/pull/1811))
- OpenCode closes cleanly on Windows ([#1771](https://github.com/getpaseo/paseo/pull/1771) by [@agamotto](https://github.com/agamotto))
- Desktop file uploads keep their extensions ([#1741](https://github.com/getpaseo/paseo/pull/1741))
- Claude Code cleanup kills child processes ([#1540](https://github.com/getpaseo/paseo/pull/1540) by [@TommyLike](https://github.com/TommyLike))
- OpenCode no longer indexes your home directory ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang))
- Packaged macOS CLI daemon no longer shows extra Dock icons ([#1759](https://github.com/getpaseo/paseo/pull/1759) by [@yzim](https://github.com/yzim))
- `paseo daemon status` works without loading agents ([#1810](https://github.com/getpaseo/paseo/pull/1810))
- PR worktrees show pushed state correctly ([#1804](https://github.com/getpaseo/paseo/pull/1804))
## 0.1.101 - 2026-06-26

View File

@@ -33,6 +33,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
@@ -72,6 +73,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Before changing app routes, startup routing, remembered workspace restore, or active workspace selection, read [docs/expo-router.md](docs/expo-router.md).**
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
- Never run `npm run test` for an entire workspace unless explicitly asked.

View File

@@ -154,6 +154,7 @@ npm run typecheck
## コミュニティ
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
---

View File

@@ -169,6 +169,7 @@ npm run typecheck
## Community
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
---

View File

@@ -154,6 +154,7 @@ npm run typecheck
## 社区
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS

View File

@@ -169,6 +169,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**

View File

@@ -164,7 +164,12 @@ Single file, validated with `PersistedConfigSchema`.
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
},
providers: {
openai: { voice: { apiKey: string, baseUrl: string } },
openai: {
apiKey?: string,
baseUrl?: string,
stt?: { apiKey?: string, baseUrl?: string },
tts?: { apiKey?: string, baseUrl?: string }
},
local: { modelsDir: string }
},
agents: {
@@ -202,13 +207,17 @@ Set these to select OpenAI instead of local speech:
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
OpenAI voice can be configured under `providers.openai`:
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
```json
{
"providers": {
"openai": {
"voice": {
"stt": {
"apiKey": "sk-...",
"baseUrl": "https://stt.example.com/v1"
},
"tts": {
"apiKey": "sk-...",
"baseUrl": "https://api.openai.com/v1"
}
@@ -217,7 +226,7 @@ OpenAI voice can be configured under `providers.openai`:
}
```
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
Paseo uses these paths under the configured OpenAI base URL:

View File

@@ -49,37 +49,11 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
### Expo Router layout ownership
### Expo Router
Each layout owns only the routes directly inside its directory. In the root
layout, register `h/[serverId]`; do not register host leaf routes such as
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
routes with its own nested stack and relative screen names:
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
warns with `[Layout children]: No route named ...` when a layout registers
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
leave a nested index route mounted without its local dynamic params and render a
blank screen.
Do not paper over missing required route params by reading global params in the
leaf. Required dynamic params belong to the matched route. If
`useLocalSearchParams()` misses one, fix the layout ownership.
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
`.tsx` files there as routes, which produces `missing the required default
export` warnings and pollutes the route tree. Put shared route policy in
`src/navigation`, `src/utils`, or another non-route directory.
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
workspace for that host after the workspace-selection store hydrates unless the
host's hydrated workspace list proves that workspace is gone; hosts without a
remembered workspace go to `open-project`.
Keep workspace identity and retention outside native-stack `getId`/
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
Route ownership, startup restore, and native blank-screen gotchas live in
[expo-router.md](expo-router.md). Read it before changing `packages/app/src/app`,
startup routing, remembered workspace restore, or active workspace selection.
### iOS simulator preview service
@@ -195,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
defaults. The default rotation is `10m` x `3` files everywhere.
### Workspace search
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
matcher in Paseo. If ignore semantics need to change, change the backend
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
## paseo.json service scripts
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array

View File

@@ -208,6 +208,22 @@ non-publishing check. Stable `vX.Y.Z` tag pushes publish
publish only the exact prerelease tag, such as
`ghcr.io/getpaseo/paseo:0.1.102-beta.1`, and do not update `latest`.
To replace a Docker image in place without rebuilding desktop, APK, or EAS
mobile release artifacts, dispatch the Docker workflow manually instead of
pushing a `v*` release tag:
```bash
gh workflow run docker.yml \
--ref main \
-f paseo_version=0.1.102-beta.1 \
-f publish=true \
-f source_build=auto
```
Manual Docker publishes require an explicit `paseo_version`. Prerelease
versions build from the checked-out source tree by default and publish only the
exact prerelease image tag.
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
## Troubleshooting

120
docs/expo-router.md Normal file
View File

@@ -0,0 +1,120 @@
# Expo Router
Paseo's mobile route tree is fragile because Expo Router and React Navigation do
not fail loudly when a nested native route is mounted under the wrong layout. The
usual symptom is a white or blank native screen with no JavaScript crash.
Read this before changing `packages/app/src/app`, startup routing, remembered
workspace restore, or active workspace selection.
## Ownership
Each layout owns only the routes directly inside its directory.
- The root layout registers `h/[serverId]`.
- The root layout does not register host leaf routes such as
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
`h/[serverId]/index`.
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
relative screen names: `index`, `workspace/[workspaceId]/index`,
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
Expo Router warns with `[Layout children]: No route named ...` when a layout
registers grandchildren. Treat that warning as a route-tree bug. On native, that
shape can leave a nested index route mounted without its local dynamic params and
render a blank screen.
## Startup
The root `/` route chooses a host boundary. It does not jump directly into a host
leaf.
- Good: `/` -> `/h/[serverId]`
- Bad: `/` -> `/h/[serverId]/workspace/[workspaceId]`
`/h/[serverId]` is the host home route. The host index restores the last
remembered workspace for that host after the remembered selection has hydrated
and the workspace has not been proven missing. If there is no restorable
workspace, it goes to global `/open-project`.
This restore is based on the last navigated workspace, not current connection
status. Do not redirect to another online host just because the remembered host
is still connecting or offline; the workspace screen owns that offline/loading
state.
This split is deliberate. The host layout must mount first so native local
dynamic params exist before any nested workspace leaf is selected.
## App-Wide Route Hops
When app-wide routes such as `/new` navigate back into a host workspace, use
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
leaf workspace URL.
The root stack owns `h/[serverId]`; the host stack owns
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
root host route and pass the nested workspace screen, or Expo Router can append
extra hidden workspace deck entries.
Those hidden entries are not harmless: composer floating panels can measure
against the wrong deck and disappear offscreen.
## Params
Required dynamic params belong to the matched route.
Do not paper over missing required params by reading global params in the leaf.
If `useLocalSearchParams()` misses a required param, fix layout ownership or the
startup route shape.
Use the host route context for host-owned leaves that need the host id after
`h/[serverId]/_layout.tsx` has matched. Do not make a leaf recover from an
unmatched tree by guessing from global state.
## App Directory
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
`.tsx` files there as routes, which produces `missing the required default
export` warnings and pollutes the route tree.
Put shared route policy in `src/navigation`, `src/utils`, stores, or another
non-route directory.
## Native Stack
Keep workspace identity and retention outside native-stack `getId` and
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
## Regression Shape
Pure helper tests are useful but not enough. The failure mode here is native
route-tree state, so a real regression should launch native with seeded persisted
state:
1. Seed `paseo:last-workspace-route-selection` with a valid
`{ serverId, workspaceId }`.
2. Launch the native app cold.
3. Assert a real screen is visible, not the blank tree.
4. Assert no `[Layout children]` warning appears.
The pure policy tests should still enforce the boundary split:
- root startup with a saved workspace returns `/h/[serverId]`;
- host index with the same saved workspace returns
`/h/[serverId]/workspace/[workspaceId]`;
- host index with no restorable workspace returns `/open-project`.
## Checklist
Before landing route changes:
- [ ] Did you change `packages/app/src/app`? Re-read this file.
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
- [ ] Did an app-wide route return to a workspace? Use
`navigateToHostWorkspaceRoute()`.
- [ ] Did you add a route? Register it in the layout that directly owns it.
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
- [ ] Did native show a blank screen without a crash? Suspect route ownership
before stores, themes, or rendering.

View File

@@ -273,9 +273,31 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
For Docker-only retries, **do not push or force-push a `v*` release tag**.
`v*` tag pushes rebuild desktop assets, the Android APK, Docker, release notes,
and EAS mobile release builds. Use the Docker workflow dispatch instead:
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
```bash
gh workflow run docker.yml \
--ref main \
-f paseo_version=X.Y.Z-beta.N \
-f publish=true \
-f source_build=auto
```
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
desktop, APK, or EAS release builders. The Docker exception is safe because the
dispatch runs from `--ref main` and uses the explicit `paseo_version`; it does
not check out or move the `v*` release tag.
To retry a failed non-Docker release workflow, push a retry tag on the commit
you want to build. Reusing the same tag name is expected: move it with
`git tag -f ...` and push it with `--force` so the workflow rebuilds the commit
you actually want.
Prefer a tag push over `workflow_dispatch` when rebuilding desktop or APK
release assets. Prefer Docker workflow dispatch when rebuilding only the Docker
image.
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:

View File

@@ -131,6 +131,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
- For full-suite confidence, push to CI and check GitHub Actions.
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads.
## Agent authentication in tests

View File

@@ -28,6 +28,8 @@ Page limits are projected-item targets. A tool call lifecycle is one projected i
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
## Resume behavior

View File

@@ -1 +1 @@
sha256-Q8PQXknQerl0SOejZ+q/uCSqQwH541AHJ4bzu5Dap+A=
sha256-qdRCpAtiqTk5mqz+lE6xTO18PtmD1ROQuDRtqDz6reI=

632
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -109,134 +109,6 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.181.tgz",
"integrity": "sha512-xT2pXqqvCSmTwO5zctIxL4dO9vX7+rKeybvLBuEvUxub8sDyScK8s1dVstxL4ZtHQzOIDUng43qEq/kefNp+aA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.181",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.181",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.181"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.181.tgz",
"integrity": "sha512-krU5aV651tfv0IeLXo2m+YYduOdirQ+/ptv4IVXivsrjtBXHrtTTLrOaBEFuRQSLqCAMaTH3cITR+mj6ij4kMA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.181.tgz",
"integrity": "sha512-kD+eMhGu2GA1/itW1LDYYELTNnQCre5C1RpekdEtQRP1iXWt6qQ+E42RJmMutBGVLGAl1cqRXIxTHXbuOWsecQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.181.tgz",
"integrity": "sha512-DgrafZ4bfZN7mPOLraEzraU2A263K9Kg01i3PBSH4Mx5CfTUbJ6IVR1cYgwRCk2c4BwQmS0onnvxfgwAxP0dKg==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.181.tgz",
"integrity": "sha512-lI39OgTCqKAqTtNF3a6+z4ToS2QKLWQw0oZGSGCMb2k6MrPB2OqyN/JSH6nvKfxLpaQmVTowwpyR4JInHMzobw==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.181.tgz",
"integrity": "sha512-KKYcN2PKHPUOPKKWsPY1bEEkeVMiOmTRpzIleGlCPsXJLS/QnN6RgNOHIrapRmh0z9uFl24KCP5X1OUMWZqu4Q==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.181.tgz",
"integrity": "sha512-FNf6QhAZ8/7q9MfFTCEfc0nA6oOSrEyKq3Xv2Jt4iSEViXYuIC25shk4T4EvU8qoLtbS51NCxCdpD7b0zCHvFQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.181.tgz",
"integrity": "sha512-7plhWEB9/ExlzTMY9SzUy6zSoCrMP4KFBJwfmkt0plxwv8lFJqX/tEH/r4QDQxrMQnR+d6RcbgPH6g3hnu3NMQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.181",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.181.tgz",
"integrity": "sha512-CQKaMv5tYBUxtFe1XG2qhnNJrmChXf+nhi5FhbE3GElWeNaXKMOTYqflii3zOT3yhqnrbh7fz1gzhOFwALtCeA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.104.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
@@ -6282,6 +6154,141 @@
"excpretty": "build/cli.js"
}
},
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz",
"integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-darwin-x64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz",
"integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz",
"integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz",
"integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz",
"integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz",
"integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ff-labs/fff-bin-win32-arm64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz",
"integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-bin-win32-x64": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz",
"integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ff-labs/fff-node": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz",
"integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==",
"cpu": [
"x64",
"arm64"
],
"license": "MIT",
"os": [
"darwin",
"linux",
"win32"
],
"dependencies": {
"ffi-rs": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.9.6",
"@ff-labs/fff-bin-darwin-x64": "0.9.6",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6",
"@ff-labs/fff-bin-linux-arm64-musl": "0.9.6",
"@ff-labs/fff-bin-linux-x64-gnu": "0.9.6",
"@ff-labs/fff-bin-linux-x64-musl": "0.9.6",
"@ff-labs/fff-bin-win32-arm64": "0.9.6",
"@ff-labs/fff-bin-win32-x64": "0.9.6"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -13889,6 +13896,183 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@yuuang/ffi-rs-android-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz",
"integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz",
"integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz",
"integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz",
"integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz",
"integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz",
"integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz",
"integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz",
"integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz",
"integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==",
"cpu": [
"x64",
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz",
"integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
@@ -21337,6 +21521,25 @@
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
"license": "MIT"
},
"node_modules/ffi-rs": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz",
"integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==",
"license": "MIT",
"optionalDependencies": {
"@yuuang/ffi-rs-android-arm64": "1.3.2",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-darwin-x64": "1.3.2",
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-musl": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -35260,7 +35463,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36278,12 +36481,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.102-beta.1",
"@getpaseo/protocol": "0.1.102-beta.1",
"@getpaseo/server": "0.1.102-beta.1",
"@getpaseo/client": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/server": "0.1.102",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36529,10 +36732,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"@getpaseo/protocol": "0.1.102-beta.1",
"@getpaseo/relay": "0.1.102-beta.1",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36543,7 +36746,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36786,7 +36989,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37682,7 +37885,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37914,7 +38117,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37926,7 +38129,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38144,15 +38347,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.102-beta.1",
"@getpaseo/highlight": "0.1.102-beta.1",
"@getpaseo/protocol": "0.1.102-beta.1",
"@getpaseo/relay": "0.1.102-beta.1",
"@ff-labs/fff-node": "^0.9.6",
"@getpaseo/client": "0.1.102",
"@getpaseo/highlight": "0.1.102",
"@getpaseo/protocol": "0.1.102",
"@getpaseo/relay": "0.1.102",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38195,6 +38399,134 @@
"vitest": "^4.1.6"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.195.tgz",
"integrity": "sha512-FVmXu9pvOMbuBKWrF8YsYQdQ/upOpv5rS8lFAnFO5jbyXT/2hN7kEPd2vd2GJpaMvNcO/KptyQUK5AxjjTz3+w==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.195",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.195",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.195"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.195.tgz",
"integrity": "sha512-WIMM/8HRCLsTDHFTIwQvvE8WCA/oaMJtdQxsP7iNyfzIGwXbuOyU95V8vYIhZfaO2yaSpbBRncunq4CtR5H4ng==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.195.tgz",
"integrity": "sha512-RY7DB+4LXosE0MJ+XELmakfPrDN1YX4lkk9CTDm28jGCVcESRz9kAEqbyaiC48dZcmN9V1NCLutzINGdcr1TBg==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.195.tgz",
"integrity": "sha512-JuIq5Fnz/F1snl0aqi1gcuRZqPWoPNrL9dJ0DuievCxKkO8hnEz/Mmn5Zos7x1X8HE//ZnEvmQXoEQEZXonJew==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.195.tgz",
"integrity": "sha512-ZmyBA/AFzhgutcxb7dbhCm6GTjJytwNYXTxJoKE2B3A409WCYccjMqeji6vCMNxyyfylglGo5D8dVMIxW9aoug==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.195.tgz",
"integrity": "sha512-s1lNi1cL93luoqsItH+fNO4KpIhdkvnVhWGGQUQ/8ftwa2gfmcIQnOg1hG8Ks+KzeD3UUQ8L9YEVHVADnFI/9A==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.195.tgz",
"integrity": "sha512-nf8Q/LauB+ZOC6QDjxNhbsvwUtYjKYnaWJLTYFwhkmsLujePnety1AtT/1ubaUoq5AM1j297DhMlYTasa79OUA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.195.tgz",
"integrity": "sha512-hbkDE+xPIZzRWm+D+BKrH9uJH6USIZdDIlsyrIlGi3JFHoieYoA1vdUNyldSS9+F3ZqQtfPjr2Qy08IVB6akYA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.195",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.195.tgz",
"integrity": "sha512-av0piEB3X1Dzhpr8A+DqHVZ9y8s1jpn8enzwX0TKKUPBn5IqLTWC7wD6v66aoUgu4f+g4ThZirmDZA6shyPEZQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
@@ -38561,7 +38893,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -1,4 +1,4 @@
import { test } from "./fixtures";
import { test, expect } from "./fixtures";
import {
awaitAssistantMessage,
expectAgentIdle,
@@ -6,8 +6,20 @@ import {
expectTurnCopyButton,
expectScrollFollowsNewContent,
} from "./helpers/agent-stream";
import {
expectScrollStaysFixed,
readScrollMetrics,
scrollAgentChatToBottom,
scrollChatAwayFromBottom,
waitForScrollableChat,
} from "./helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
import { selectModel } from "./helpers/app";
import { clickNewChat } from "./helpers/launcher";
import { startRunningMockAgent } from "./helpers/composer";
import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360;
test.describe("Agent stream UI", () => {
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
@@ -25,6 +37,89 @@ test.describe("Agent stream UI", () => {
}
});
test("keeps the viewport fixed after the user scrolls away during a stream", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "stream-scroll-away-",
title: "Scroll-away anchor",
model: "five-minute-stream",
initialPrompt: "emit 120 agent stream updates for scroll-away setup.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, {
workspaceId: agent.workspaceId,
agentId: agent.agentId,
});
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Stream for scroll-away anchor test.");
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await awaitAssistantMessage(page);
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 30_000,
});
const baseline = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await expectScrollStaysFixed(page, baseline, { durationMs: 30_000 });
const finalMetrics = await readScrollMetrics(page);
expect(finalMetrics.contentHeight).toBeGreaterThan(baseline.contentHeight);
} finally {
await agent.cleanup();
}
});
test("keeps the viewport fixed when delayed authoritative history arrives after scroll-away", async ({
page,
withWorkspace,
}) => {
test.setTimeout(180_000);
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
const workspace = await withWorkspace({
prefix: "stream-scroll-away-delayed-history-",
});
await workspace.navigateTo();
await clickNewChat(page);
await page.getByText("Model defaults are still loading").waitFor({
state: "hidden",
timeout: 30_000,
});
await expectComposerVisible(page);
await selectModel(page, "Five minute stream");
const prompt = "Stream for delayed authoritative history scroll-away test.";
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await page.getByText(prompt, { exact: true }).first().waitFor({
state: "visible",
timeout: 30_000,
});
await timelineGate.waitForCreatedAgent();
await timelineGate.waitForDelayedResponse();
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
timeout: 30_000,
});
await awaitAssistantMessage(page);
await waitForScrollableChat(page, {
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
timeout: 45_000,
});
const baseline = await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
timelineGate.release();
await timelineGate.waitForForwardedResponse();
await expectScrollStaysFixed(page, baseline);
});
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
test.setTimeout(60_000);
const agent = await startRunningMockAgent(page, {
@@ -36,6 +131,7 @@ test.describe("Agent stream UI", () => {
await awaitAssistantMessage(page);
await expectInlineWorkingIndicator(page);
await expectAgentIdle(page, 30_000);
await scrollAgentChatToBottom(page);
await expectTurnCopyButton(page);
} finally {
await agent.cleanup();

View File

@@ -0,0 +1,121 @@
import { expect, test as base, type Page } from "./fixtures";
import { scrollAgentChatToBottom } from "./helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "./helpers/agent-stream";
import { expectComposerVisible } from "./helpers/composer";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentOptions,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
const test = base.extend<{
seedForkWorkspace: (options: MockAgentOptions) => Promise<MockAgentWorkspace>;
}>({
seedForkWorkspace: async ({ browserName: _browserName }, provide) => {
const sessions: MockAgentWorkspace[] = [];
await provide(async (options) => {
const session = await seedMockAgentWorkspace(options);
sessions.push(session);
return session;
});
await Promise.allSettled(sessions.map((session) => session.cleanup()));
},
});
async function openAssistantForkMenu(page: Page): Promise<void> {
await expect
.poll(
async () => {
await scrollAgentChatToBottom(page);
return page.getByTestId("assistant-fork-menu-trigger").count();
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0);
const trigger = page.getByTestId("assistant-fork-menu-trigger").last();
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
await expect(page.getByTestId("assistant-fork-menu-content")).toBeVisible({
timeout: 10_000,
});
}
async function expectChatHistoryPill(page: Page): Promise<void> {
const pill = page.getByTestId("composer-chat-history-attachment-pill").first();
await expect(pill).toBeVisible({ timeout: 30_000 });
await expect(pill).toContainText("Chat history");
}
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
test("forks an assistant turn into a new workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-tab-",
title: "Assistant fork tab",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
await expectChatHistoryPill(page);
});
test("forks an assistant turn into New Workspace and keeps the attachment across host changes", async ({
page,
seedForkWorkspace,
}) => {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "localhost",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: "secondary-assistant-fork-host",
label: "Secondary host",
// The host does not need to be reachable; this pins that the draft-scoped
// attachment survives changing the selected target host.
endpoint: "127.0.0.1:9",
},
]);
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-workspace-",
title: "Assistant fork workspace",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork new workspace.",
model: "ten-second-stream",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-workspace").click();
await expect(page).toHaveURL(/\/new\?.*draftId=/, { timeout: 30_000 });
await expectChatHistoryPill(page);
await page.getByTestId("host-picker-trigger").click();
await page
.getByTestId("new-workspace-host-picker-option-secondary-assistant-fork-host")
.click();
await expectChatHistoryPill(page);
});
});

View File

@@ -0,0 +1,49 @@
import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createIdleAgent } from "./helpers/archive-tab";
import { openCommandCenter } from "./helpers/command-center";
import { addOfflineHostAndReload } from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const PRIMARY_HOST_LABEL = "Primary Host";
const SECONDARY_HOST_ID = "host-command-center-secondary";
test.describe("Command center host labels", () => {
test.describe.configure({ timeout: 180_000 });
test("agent results show the host they live on when more than one host exists", async ({
page,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
const title = `cc-host-${randomUUID().slice(0, 8)}`;
try {
const agent = await createIdleAgent(seeded.client, {
cwd: seeded.repoPath,
workspaceId: seeded.workspaceId,
title,
});
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: SECONDARY_HOST_ID,
label: "Secondary Host",
primaryLabel: PRIMARY_HOST_LABEL,
});
const panel = await openCommandCenter(page);
// The shared daemon may carry agents from other specs, so target this agent by its id.
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(title);
await expect(row).toContainText(PRIMARY_HOST_LABEL);
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -1,8 +1,16 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "./fixtures";
import { composerLocator, expectComposerVisible } from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "./helpers/mock-agent";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
const TEST_COMMANDS = [
{
@@ -142,6 +150,43 @@ async function installListCommandsStub(page: Page): Promise<void> {
});
}
async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").first().click();
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
}
async function expectSingleCurrentWorkspaceDeckEntry(
page: Page,
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
): Promise<void> {
const summary = await page
.locator('[data-testid^="workspace-deck-entry-"]')
.evaluateAll((elements, target) => {
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
const entries = elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
testId: element.getAttribute("data-testid"),
hasLayout: rect.width > 0 && rect.height > 0,
};
});
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
return {
totalDeckEntryCount: entries.length,
currentWorkspaceEntryCount: currentEntries.length,
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
};
}, input);
expect(summary).toEqual({
totalDeckEntryCount: input.expectedDeckEntryCount,
currentWorkspaceEntryCount: 1,
visibleCurrentWorkspaceEntryCount: 1,
hiddenCurrentWorkspaceEntryCount: 0,
});
}
async function openReadyMockAgent(
page: Page,
options?: { expectWorkspaceTab?: boolean },
@@ -166,6 +211,25 @@ async function openReadyMockAgent(
}
}
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
}
async function expectFileMentionSuggestion(
page: Page,
query: string,
suggestion: string,
): Promise<void> {
const input = composerLocator(page);
await input.fill(query);
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
}
async function visiblePopoverBox(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
@@ -308,6 +372,64 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
const sessions: MockAgentWorkspace[] = [];
try {
sessions.push(
await seedMockAgentWorkspace({
repoPrefix: "autocomplete-new-route-a-",
title: "Autocomplete new route A",
}),
);
sessions.push(
await seedMockAgentWorkspace({
repoPrefix: "autocomplete-new-route-b-",
title: "Autocomplete new route B",
}),
);
sessions.push(
await seedMockAgentWorkspace({
repoPrefix: "autocomplete-new-route-c-",
title: "Autocomplete new route C",
}),
);
const [first, second, third] = sessions;
await openAgentRoute(page, first);
await expectComposerVisible(page, { timeout: 30_000 });
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: sessions.length,
serverId,
workspaceId: first.workspaceId,
});
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ hasText: "/help", visible: true })
.first();
await expect(popover).toBeInViewport({ timeout: 30_000 });
} finally {
await Promise.allSettled(sessions.map((session) => session.cleanup()));
}
});
test("does not flash at the wrong position on the first slash command paint", async ({
page,
}) => {
@@ -418,6 +540,28 @@ test.describe("Composer autocomplete", () => {
}
});
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-dot-entries-",
title: "Dot file mention autocomplete",
});
try {
await seedDotPrefixedWorkspaceFiles(session.cwd);
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
await expectComposerVisible(page);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await expectFileMentionSuggestion(page, "@.env", ".env.local");
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
} finally {
await session.cleanup();
}
});
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);

View File

@@ -4,8 +4,11 @@ import { getServerId } from "./helpers/server-id";
import {
loadRealDaemonState,
injectDesktopBridge,
openDesktopAboutSettings,
openDesktopSettings,
expectUpdateBanner,
clickCheckForUpdates,
expectPendingUpdateCheckResult,
clickInstallUpdate,
expectInstallInProgress,
interceptDaemonManagementConfirmDialog,
@@ -45,6 +48,21 @@ test.describe("Desktop updates", () => {
await clickInstallUpdate(page);
await expectInstallInProgress(page);
});
test("manual check reports a found update while it downloads", async ({ page }) => {
await injectDesktopBridge(page, {
serverId: getServerId(),
updateAvailable: true,
latestVersion: "1.2.3",
updateReadyToInstall: false,
});
await gotoAppShell(page);
await openDesktopAboutSettings(page);
await clickCheckForUpdates(page);
await expectPendingUpdateCheckResult(page, "1.2.3");
});
});
test.describe("Desktop daemon management", () => {

View File

@@ -10,6 +10,7 @@ import dotenv from "dotenv";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js");
@@ -405,15 +406,6 @@ async function waitForPairingOfferFromDaemon(args: {
);
}
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
@@ -675,7 +667,7 @@ interface DaemonSpawnArgs {
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const env: NodeJS.ProcessEnv = {
const env = withDisabledE2ESpeechEnv({
...process.env,
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
@@ -684,21 +676,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
// Default app E2E does not cover speech flows. Keep these disabled so
// unrelated tests never start background local-model downloads.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete env[key];
}
});
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,

View File

@@ -1,6 +1,7 @@
import { expect, type Page } from "@playwright/test";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
const DEFAULT_SCROLL_TOLERANCE_PX = 24;
export interface ScrollMetrics {
offsetY: number;
@@ -54,6 +55,25 @@ export async function expectNearBottom(page: Page): Promise<void> {
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function scrollAgentChatToBottom(page: Page): Promise<void> {
const chatScroll = getVisibleChatScroll(page);
await chatScroll.evaluate((root: Element) => {
const scrollElement = root as HTMLElement;
scrollElement.scrollTop = scrollElement.scrollHeight;
});
await expect
.poll(async () =>
chatScroll.evaluate((root: Element) => {
const scrollElement = root as HTMLElement;
return Math.max(
0,
scrollElement.scrollHeight - (scrollElement.scrollTop + scrollElement.clientHeight),
);
}),
)
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
}
export async function waitForContentGrowth(
page: Page,
previousContentHeight: number,
@@ -66,3 +86,65 @@ export async function waitForContentGrowth(
.toBeGreaterThan(previousContentHeight);
return readScrollMetrics(page);
}
export async function waitForScrollableChat(
page: Page,
input: { minScrollableDistance: number; timeout?: number },
): Promise<void> {
await expect
.poll(
async () => {
const metrics = await readScrollMetrics(page);
return metrics.contentHeight - metrics.viewportHeight;
},
{ timeout: input.timeout },
)
.toBeGreaterThan(input.minScrollableDistance);
}
export async function scrollChatAwayFromBottom(
page: Page,
input: { deltaY: number; minDistanceFromBottom: number },
): Promise<ScrollMetrics> {
const scroll = getVisibleChatScroll(page);
const box = await scroll.boundingBox();
if (!box) {
throw new Error("Agent chat scroll container is not visible");
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, input.deltaY);
await expect
.poll(async () => {
const metrics = await readScrollMetrics(page);
return metrics.distanceFromBottom;
})
.toBeGreaterThan(input.minDistanceFromBottom);
return readScrollMetrics(page);
}
export async function expectScrollStaysFixed(
page: Page,
baseline: ScrollMetrics,
input?: { durationMs?: number; sampleIntervalMs?: number; tolerancePx?: number },
): Promise<void> {
const durationMs = input?.durationMs ?? 2_000;
const sampleIntervalMs = input?.sampleIntervalMs ?? 250;
const tolerancePx = input?.tolerancePx ?? DEFAULT_SCROLL_TOLERANCE_PX;
const samples: Array<{ elapsedMs: number; offsetY: number; contentHeight: number }> = [];
const startedAt = Date.now();
while (Date.now() - startedAt < durationMs) {
await page.waitForTimeout(sampleIntervalMs);
const metrics = await readScrollMetrics(page);
samples.push({
elapsedMs: Date.now() - startedAt,
offsetY: metrics.offsetY,
contentHeight: metrics.contentHeight,
});
expect(
metrics.offsetY,
JSON.stringify({ baseline, samples: samples.slice(-12) }),
).toBeLessThanOrEqual(baseline.offsetY + tolerancePx);
}
}

View File

@@ -0,0 +1,120 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface CreatedAgentTimelineGate {
release(): void;
waitForCreatedAgent(): Promise<string>;
waitForDelayedResponse(): Promise<void>;
waitForForwardedResponse(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getPayload(message: Record<string, unknown>): Record<string, unknown> | null {
return message.payload && typeof message.payload === "object"
? (message.payload as Record<string, unknown>)
: null;
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
let createdAgentId: string | null = null;
let releaseRequested = false;
let delayedResponseSeen = false;
const delayedForwards: Array<() => void> = [];
let resolveCreatedAgent: ((agentId: string) => void) | null = null;
let resolveDelayedResponse: (() => void) | null = null;
let resolveForwardedResponse: (() => void) | null = null;
const createdAgentSeen = new Promise<string>((resolve) => {
resolveCreatedAgent = resolve;
});
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayedResponse = resolve;
});
const forwardedResponse = new Promise<void>((resolve) => {
resolveForwardedResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
const forwardToClient = (message: WebSocketMessage) => {
ws.send(message);
resolveForwardedResponse?.();
};
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
const agentId = payload.agentId;
if (typeof agentId === "string") {
createdAgentId = agentId;
resolveCreatedAgent?.(agentId);
}
}
if (sessionMessage?.type === "fetch_agent_timeline_response") {
const agentId = payload?.agentId;
const direction = payload?.direction;
if (
!delayedResponseSeen &&
typeof agentId === "string" &&
agentId === createdAgentId &&
direction === "tail"
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
if (releaseRequested) {
forwardToClient(message);
return;
}
delayedForwards.push(() => forwardToClient(message));
return;
}
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreatedAgent: () => createdAgentSeen,
waitForDelayedResponse: () => delayedResponse,
waitForForwardedResponse: () => forwardedResponse,
};
}

View File

@@ -342,9 +342,17 @@ export const selectModel = async (page: Page, model: string) => {
if (await modelTrigger.isVisible().catch(() => false)) {
await modelTrigger.click();
} else {
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
const modelButton = page
.getByRole("button", { name: /Select model/i })
.filter({ visible: true })
.first();
if (await modelButton.isVisible().catch(() => false)) {
await modelButton.click();
} else {
const modelLabel = page.getByText("MODEL", { exact: true }).first();
await expect(modelLabel).toBeVisible();
await modelLabel.click();
}
}
// Wait for the model dropdown to open

View File

@@ -88,11 +88,13 @@ export async function archiveAgentFromDaemon(
export async function fetchAgentArchivedAt(
client: {
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
fetchAgent(options: {
agentId: string;
}): Promise<{ agent: { archivedAt?: string | null } } | null>;
},
agentId: string,
): Promise<string | null> {
const result = await client.fetchAgent(agentId);
const result = await client.fetchAgent({ agentId });
return result?.agent.archivedAt ?? null;
}

View File

@@ -0,0 +1,9 @@
import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}

View File

@@ -5,6 +5,7 @@ import net from "node:net";
import path from "node:path";
import { getE2EDaemonPort } from "./daemon-port";
import { withDisabledE2ESpeechEnv } from "./speech-env";
/**
* Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so
@@ -93,20 +94,21 @@ function spawnSupervisor(args: {
// inside the Playwright worker (the shim is a .mjs symlink, not an executable),
// so resolve the CLI module and load it with node.
const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli");
const env = withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
});
const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
env,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});

View File

@@ -3,7 +3,8 @@ import { appendFile } from "node:fs/promises";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
import { escapeRegex } from "./regex";
import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -52,6 +53,7 @@ export interface DesktopBridgeConfig {
serverId: string;
updateAvailable?: boolean;
latestVersion?: string;
updateReadyToInstall?: boolean;
slowInstall?: boolean;
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
daemonPid?: number | null;
@@ -169,7 +171,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
return cfg.updateAvailable
? {
hasUpdate: true,
readyToInstall: true,
readyToInstall: cfg.updateReadyToInstall ?? true,
currentVersion: "1.0.0",
latestVersion: cfg.latestVersion ?? "1.2.3",
body: null,
@@ -276,12 +278,33 @@ export async function openDesktopSettings(page: Page, serverId: string): Promise
});
}
export async function openDesktopAboutSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "about");
await expect(page.getByText("App updates", { exact: true })).toBeVisible();
}
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
const callout = page.getByTestId("update-callout");
await expect(callout).toBeVisible({ timeout: 15_000 });
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
}
export async function clickCheckForUpdates(page: Page): Promise<void> {
await page.getByRole("button", { name: "Check" }).click();
}
export async function expectPendingUpdateCheckResult(page: Page, version: string): Promise<void> {
const normalizedVersion = `v${version.replace(/^v/i, "")}`;
await expect(
page.getByText(
new RegExp(`Update found: ${escapeRegex(normalizedVersion)}\\. Downloading\\.\\.\\.`),
),
).toBeVisible();
await expect(page.getByText(`Ready to install: ${normalizedVersion}`)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Update" })).toBeDisabled();
}
export async function clickInstallUpdate(page: Page): Promise<void> {
await page.getByRole("button", { name: "Install & restart" }).click();
}

View File

@@ -0,0 +1,79 @@
import { expect, type Page } from "@playwright/test";
import { buildSeededHost } from "./daemon-registry";
const REGISTRY_KEY = "@paseo:daemon-registry";
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
// The multi-host UI (the command-center host label, the sidebar host filter) only renders once
// more than one host exists. The e2e harness runs a single real daemon, so we add an extra registry
// entry pointing at an unreachable endpoint: it stays offline, which is enough to make the UI treat
// the view as multi-host without standing up a second daemon.
//
// Must run AFTER the first navigation: the auto-seed fixture writes the registry + nonce on load,
// and reseeds on every navigation. We write the full registry here and set the fixture's
// disable-once flag, then reload — so the fixture skips its reset and the registry survives. This
// avoids depending on the (unspecified) ordering of multiple Playwright init scripts. Optionally
// relabels the seeded primary host so assertions can target a distinctive name.
export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ host, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
}
const raw = localStorage.getItem(keys.registry);
const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : [];
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
},
);
await page.reload();
}
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
await page.getByTestId("sidebar-display-preferences-menu").click();
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
timeout: 10_000,
});
}
// A host's filter row carries a status dot on the left next to its label.
export async function expectHostFilterRow(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`sidebar-host-filter-${serverId}`)).toBeVisible();
await expect(page.getByTestId(`sidebar-host-filter-status-${serverId}`)).toBeVisible();
}
export async function toggleHostFilter(page: Page, serverId: string): Promise<void> {
await page.getByTestId(`sidebar-host-filter-${serverId}`).click();
}
export async function selectAllHostsFilter(page: Page): Promise<void> {
await page.getByTestId("sidebar-host-filter-all").click();
}

View File

@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
import { getServerId } from "./server-id";
import { buildHostAgentDetailRoute } from "../../src/utils/host-routes";
export interface MockAgentWorkspace {
agentId: string;
@@ -54,9 +54,7 @@ export async function seedMockAgentWorkspace(
}
export function buildAgentRoute(workspaceId: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), workspaceId)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId);
}
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */

View File

@@ -132,9 +132,9 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
fetchAgent(
agentId: string,
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
fetchAgent(options: {
agentId: string;
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
getLastServerInfoMessage(): {
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
} | null;

View File

@@ -393,9 +393,9 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
await expect(sidebar).toBeVisible({ timeout: 15_000 });
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
// open) that surfaces the local host by its label. The "Local" marker only
// appears on dropdown rows in the multi-host case, which this fixture does not
// exercise.
// open) that surfaces the local host by its label. The per-row connection
// endpoint only appears on dropdown rows in the multi-host case, which this
// fixture does not exercise.
const picker = sidebar.getByTestId("settings-host-picker");
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();

View File

@@ -0,0 +1,32 @@
const LOCAL_SPEECH_ENV_KEYS = [
"PASEO_LOCAL_MODELS_DIR",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
] as const;
const DISABLED_E2E_SPEECH_ENV = {
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_TURN_DETECTION_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
} as const;
export function withDisabledE2ESpeechEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
// Default app E2E does not cover speech flows; keep restarts from starting
// background local-model downloads for unrelated tests.
const next: NodeJS.ProcessEnv = {
...env,
...DISABLED_E2E_SPEECH_ENV,
};
for (const key of LOCAL_SPEECH_ENV_KEYS) {
delete next[key];
}
return next;
}

View File

@@ -0,0 +1,270 @@
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection";
import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes";
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
const OFFLINE_SERVER_IDS = [
"srv_e2e_preselect_offline_1",
"srv_e2e_preselect_offline_2",
"srv_e2e_preselect_offline_3",
];
// New Workspace preselection is a form-context decision, not startup routing.
// Entry points from a workspace should carry the current project context, and a
// plain /new must not let a stale remembered offline host steal the initial host
// when there is exactly one online saved host.
async function pressNewWorkspaceShortcut(page: import("@playwright/test").Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+n`);
await expect(page).toHaveURL(/\/new(?:\?.*)?$/, { timeout: 30_000 });
}
async function expectProjectPreselectedWithin(
page: import("@playwright/test").Page,
projectDisplayName: string,
timeout: number,
): Promise<void> {
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await expect(projectPicker).toContainText(projectDisplayName, { timeout });
}
async function expectAnyProjectPreselectedWithin(
page: import("@playwright/test").Page,
timeout: number,
): Promise<void> {
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await expect(projectPicker).toBeVisible({ timeout });
await expect
.poll(
async () => {
const label = ((await projectPicker.textContent()) ?? "").trim();
return label || "Choose project";
},
{ timeout },
)
.not.toBe("Choose project");
}
async function openColdRestoredWorkspaceWithOfflineHostFirst(
page: import("@playwright/test").Page,
workspace: SeededWorkspace,
): Promise<void> {
const connectedServerId = getServerId();
await seedSavedSettingsHosts(page, [
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
serverId,
label: `Offline host ${index + 1}`,
endpoint: `127.0.0.1:${index + 1}`,
})),
{
serverId: connectedServerId,
label: "Connected host",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await page.evaluate(
({ storageKey, serverId, workspaceId }) => {
localStorage.setItem(storageKey, JSON.stringify({ serverId, workspaceId }));
},
{
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
serverId: connectedServerId,
workspaceId: workspace.workspaceId,
},
);
await page.goto("/");
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, workspace.workspaceId), {
timeout: 60_000,
});
await waitForSidebarHydration(page);
}
async function openNewWorkspaceWithStaleOfflineSelection(
page: import("@playwright/test").Page,
): Promise<void> {
const connectedServerId = getServerId();
await seedSavedSettingsHosts(page, [
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
serverId,
label: `Offline host ${index + 1}`,
endpoint: `127.0.0.1:${index + 1}`,
})),
{
serverId: connectedServerId,
label: "Connected host",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await page.evaluate(
({ storageKey, serverId }) => {
localStorage.setItem(
storageKey,
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
);
},
{
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
serverId: OFFLINE_SERVER_IDS[0]!,
},
);
await page.goto(buildNewWorkspaceRoute());
await expect(page.getByTestId("host-picker-trigger")).toBeVisible({ timeout: 60_000 });
}
async function seedOfflineHostsWithStaleSelection(
page: import("@playwright/test").Page,
): Promise<void> {
const connectedServerId = getServerId();
await seedSavedSettingsHosts(page, [
...OFFLINE_SERVER_IDS.map((serverId, index) => ({
serverId,
label: `Offline host ${index + 1}`,
endpoint: `127.0.0.1:${index + 1}`,
})),
{
serverId: connectedServerId,
label: "Connected host",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
]);
await page.evaluate(
({ storageKey, serverId }) => {
localStorage.setItem(
storageKey,
JSON.stringify({ serverId, workspaceId: "wks_stale_offline" }),
);
},
{
storageKey: LAST_WORKSPACE_SELECTION_STORAGE_KEY,
serverId: OFFLINE_SERVER_IDS[0]!,
},
);
}
test.describe("New workspace preselects the open workspace's project", () => {
test.describe.configure({ timeout: 240_000 });
let projectA: SeededWorkspace;
let projectB: SeededWorkspace;
test.beforeEach(async () => {
projectA = await seedWorkspace({ repoPrefix: "preselect-a-" });
projectB = await seedWorkspace({ repoPrefix: "preselect-b-" });
});
test.afterEach(async () => {
await projectA?.cleanup();
await projectB?.cleanup();
});
test("Cmd+N preselects the project you are looking at", async ({ page }) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectB.workspaceId,
});
await pressNewWorkspaceShortcut(page);
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectA.workspaceId,
});
await pressNewWorkspaceShortcut(page);
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
});
test("New workspace button preselects the project you are looking at", async ({ page }) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectB.workspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: projectA.workspaceId,
});
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
});
test("Cmd+N preselects the connected host project when an offline saved host is first", async ({
page,
}) => {
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
await pressNewWorkspaceShortcut(page);
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
timeout: 8_000,
});
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
});
test("New workspace button preselects the connected host project when an offline saved host is first", async ({
page,
}) => {
await openColdRestoredWorkspaceWithOfflineHostFirst(page, projectB);
await openGlobalNewWorkspaceComposer(page);
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
timeout: 8_000,
});
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
});
test("plain /new ignores stale remembered offline hosts when only one saved host is connected", async ({
page,
}) => {
await openNewWorkspaceWithStaleOfflineSelection(page);
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
timeout: 8_000,
});
await expectAnyProjectPreselectedWithin(page, 8_000);
});
test("stale remembered offline host heals after visiting the connected workspace", async ({
page,
}) => {
const connectedServerId = getServerId();
await seedOfflineHostsWithStaleSelection(page);
await page.goto(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(connectedServerId, projectB.workspaceId), {
timeout: 60_000,
});
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expect(page.getByTestId("host-picker-trigger")).toContainText("Connected host", {
timeout: 8_000,
});
await expectProjectPreselectedWithin(page, projectB.projectDisplayName, 8_000);
});
});

View File

@@ -118,7 +118,7 @@ test.describe("Settings host page", () => {
await expectHostActionCards(page, serverId);
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
test("sidebar pins the local daemon host first", async ({ page }) => {
const serverId = getServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the

View File

@@ -0,0 +1,56 @@
import { expect } from "@playwright/test";
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addOfflineHostAndReload,
expectHostFilterRow,
openSidebarDisplayPreferences,
selectAllHostsFilter,
toggleHostFilter,
} from "./helpers/hosts";
import { seedWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const SECONDARY_HOST_ID = "host-filter-secondary";
test.describe("Sidebar host filter (multi-select)", () => {
test.describe.configure({ timeout: 120_000 });
test("pins the sidebar to multiple selected hosts at once", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "host-filter-" });
const serverId = getServerId();
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${serverId}:${seeded.workspaceId}`,
);
try {
// A second (offline) host is enough to surface the host filter without a second daemon.
await gotoAppShell(page);
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await openSidebarDisplayPreferences(page);
await expectHostFilterRow(page, serverId);
await expectHostFilterRow(page, SECONDARY_HOST_ID);
// Pin the primary host — its workspace stays visible.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toBeVisible();
// Add the secondary host without clearing the primary. Under single-select this would replace
// the primary and hide the workspace; multi-select keeps both pinned, so it stays visible.
await toggleHostFilter(page, SECONDARY_HOST_ID);
await expect(workspaceRow).toBeVisible();
// Drop the primary host — only the (empty) secondary host remains pinned, so the workspace hides.
await toggleHostFilter(page, serverId);
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
// Back to all hosts — the workspace returns.
await selectAllHostsFilter(page);
await expect(workspaceRow).toBeVisible({ timeout: 10_000 });
} finally {
await seeded.cleanup();
}
});
});

View File

@@ -10,6 +10,7 @@ import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/uti
import { buildSeededHost } from "./helpers/daemon-registry";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { withDisabledE2ESpeechEnv } from "./helpers/speech-env";
import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
@@ -243,18 +244,16 @@ async function startRestartDaemon(input: {
const tsxBin = execSync("which tsx").toString().trim();
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
cwd: serverDir,
env: {
env: withDisabledE2ESpeechEnv({
...process.env,
PASEO_HOME: input.paseoHome,
PASEO_SERVER_ID: SERVER_ID,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_CORS_ORIGINS: input.origin,
PASEO_RELAY_ENABLED: "0",
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.102-beta.1",
"version": "0.1.102",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -112,6 +112,26 @@ function footerOwners(layout: StreamLayout): string[] {
return owners;
}
function footerAssistantIds(layout: StreamLayout): string[] {
return [
...layout.history.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...layout.liveHead.flatMap((item) =>
item.completedFooter ? [item.completedFooter.itemId] : [],
),
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
];
}
function inlineFooterPlacementByItemId(layout: StreamLayout): Record<string, string> {
return Object.fromEntries(
[...layout.history, ...layout.liveHead].flatMap((item) =>
item.completedFooter ? [[item.item.id, item.completedFooter.itemId]] : [],
),
);
}
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
const item = [...layout.history, ...layout.liveHead].find(
(candidate) => candidate.item.id === id,
@@ -289,4 +309,144 @@ describe("layoutStream", () => {
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
});
it.each(["web", "android"] as const)(
"places inline footer after trailing visible tool rows before the next user on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant, tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(footerOwners(layout)).toEqual([tool.id]);
expect(footerAssistantIds(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"places split live-head tool footer using the assistant from history on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant],
head: [tool, userMessage("u2", 4)],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, tool.id).completedFooter?.itemId).toBe(assistant.id);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[tool.id]: assistant.id,
});
},
);
it.each(["web", "android"] as const)(
"uses the latest assistant for footer content while placing after the visible turn end on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const firstTool = toolCall("tool-1", 3);
const latestAssistant = assistantMessage("a2", 4);
const latestTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
firstTool,
latestAssistant,
latestTool,
userMessage("u2", 6),
],
timingIds: [firstAssistant.id, latestAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, latestTool.id).completedFooter?.itemId).toBe(
latestAssistant.id,
);
expect(footerOwners(layout)).toEqual([latestTool.id]);
expect(footerAssistantIds(layout)).toEqual([latestAssistant.id]);
},
);
it.each(["web", "android"] as const)(
"keeps every completed turn footer while placing each one after that turn's last visible item on %s",
(platform) => {
const firstAssistant = assistantMessage("a1", 2);
const secondAssistant = assistantMessage("a2", 4);
const secondTool = toolCall("tool-2", 5);
const layout = layoutFor({
platform,
tail: [
userMessage("u1", 1),
firstAssistant,
userMessage("u2", 3),
secondAssistant,
secondTool,
userMessage("u3", 6),
],
timingIds: [firstAssistant.id, secondAssistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, firstAssistant.id).completedFooter?.itemId).toBe(
firstAssistant.id,
);
expect(findLayoutItem(layout, secondAssistant.id).completedFooter).toBeNull();
expect(findLayoutItem(layout, secondTool.id).completedFooter?.itemId).toBe(
secondAssistant.id,
);
expect(inlineFooterPlacementByItemId(layout)).toEqual({
[firstAssistant.id]: firstAssistant.id,
[secondTool.id]: secondAssistant.id,
});
},
);
it.each(["web", "android"] as const)(
"keeps bottom footer on the latest assistant turn when trailing tool rows end the turn on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
tail: [userMessage("u1", 1), assistant, tool],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([assistant.id]);
},
);
it.each(["web", "android"] as const)(
"does not render a completed footer before tool rows while the turn is running on %s",
(platform) => {
const assistant = assistantMessage("a1", 2);
const tool = toolCall("tool-1", 3);
const layout = layoutFor({
platform,
agentStatus: "running",
tail: [userMessage("u1", 1), assistant, tool],
timingIds: [assistant.id],
});
expect(layout.auxiliaryTurnFooter).toBeNull();
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
expect(footerOwners(layout)).toEqual([]);
},
);
});

View File

@@ -44,7 +44,6 @@ export interface StreamLayoutInput {
interface LayoutSegmentInput {
strategy: StreamStrategy;
agentStatus: string;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
@@ -52,6 +51,14 @@ interface LayoutSegmentInput {
boundaryIndex: number | null;
boundaryAboveItem: StreamItem | null;
boundaryBelowItem: StreamItem | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
function createTurnFooterHost(input: {
@@ -68,45 +75,111 @@ function createTurnFooterHost(input: {
};
}
function findLatestAssistantInTurn(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
while (true) {
for (
;
index >= 0 && index < items.length;
index = input.strategy.getNeighborIndex(index, "above")
) {
const item = items[index];
if (!item || item.kind === "user_message") {
return null;
}
if (item.kind === "assistant_message") {
return { item, items, index };
}
}
if (
!canCrossBoundary ||
!input.boundaryAboveItems ||
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return null;
}
items = input.boundaryAboveItems;
index = input.boundaryAboveIndex;
canCrossBoundary = false;
}
}
function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.strategy.getLatestItemIndex(footerItems);
if (startIndex === null) {
const latestIndex = input.strategy.getLatestItemIndex(footerItems);
if (latestIndex === null) {
return null;
}
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (!assistant) {
return null;
}
return createTurnFooterHost({
item,
items: footerItems,
index: startIndex,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
function shouldRenderCompletedFooter(input: {
function resolveCompletedFooter(input: {
strategy: StreamStrategy;
items: StreamItem[];
index: number;
item: StreamItem;
belowItem: StreamItem | null;
agentStatus: string;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
}): boolean {
return (
input.item.kind === "assistant_message" &&
input.auxiliaryTurnFooter?.itemId !== input.item.id &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === null && input.agentStatus !== "running"))
);
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
}): TurnFooterHost | null {
if (input.item.kind === "user_message" || input.belowItem?.kind !== "user_message") {
return null;
}
const assistant = findLatestAssistantInTurn({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
function isToolSequenceItem(item: StreamItem | null): boolean {
function isToolSequenceItem(
item: StreamItem | null,
): item is Extract<StreamItem, { kind: "tool_call" | "thought" | "todo_list" }> {
return item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
}
@@ -174,19 +247,17 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
aboveItem,
belowItem,
});
const completedFooter = shouldRenderCompletedFooter({
const completedFooter = resolveCompletedFooter({
strategy: input.strategy,
items: input.items,
index,
item,
belowItem,
agentStatus: input.agentStatus,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
})
? createTurnFooterHost({
item,
items: input.items,
index,
timingByAssistantId: input.timingByAssistantId,
})
: null;
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
return {
item,
@@ -227,7 +298,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
// item borders history), so cached layout stays valid between flushes.
const historyCacheKey = [
input.agentStatus,
frameOrder,
historyBoundaryIndex ?? "null",
liveHeadBoundaryItem?.id ?? "null",
@@ -245,7 +315,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
} else {
history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -253,6 +322,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
boundaryAboveItems: null,
boundaryAboveIndex: null,
});
byKey.set(historyCacheKey, history);
}
@@ -262,7 +333,6 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
@@ -270,6 +340,8 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryIndex: liveHeadBoundaryIndex,
boundaryAboveItem: historyBoundaryItem,
boundaryBelowItem: null,
boundaryAboveItems: input.history,
boundaryAboveIndex: historyBoundaryIndex,
});
return {

View File

@@ -194,7 +194,9 @@ describe("createWebStreamStrategy", () => {
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
expect(scrollContainer).toBeInstanceOf(HTMLElement);
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
@@ -205,4 +207,408 @@ describe("createWebStreamStrategy", () => {
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
});
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
requestKey: "server:agent:initial-entry",
};
const renderInput = {
agentId: "agent",
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: false,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
historyVirtualized: [],
historyMounted: [],
liveHead: [],
},
isAuthoritativeHistoryReady: false,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
const scrollElement = scrollContainer;
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, value: 0 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
scrollTo.mockClear();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, value: 1400 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
historyVirtualized: [],
historyMounted,
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
isAuthoritativeHistoryReady: true,
}),
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(scrollTo).toHaveBeenCalled();
expect(scrollElement.scrollTop).toBe(1400);
});
it("does not force bottom on delayed route readiness after the user scrolls away", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
requestKey: "server:agent:initial-entry",
};
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted,
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: false,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1400 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
requestKey: "server:agent:initial-entry",
};
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted,
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: false,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3)],
},
isAuthoritativeHistoryReady: true,
}),
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(scrollTo).toHaveBeenCalled();
});
});

View File

@@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
contentRef.current = node;
}, []);
const [followOutput, setFollowOutputr] = useState(true);
const followOutputRef = useRef(followOutput);
const setFollowOutput = (value: boolean) => {
followOutputRef.current = value;
setFollowOutputr(value);
return value;
};
const followOutputRef = useRef(followOutput);
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const isPointerScrollActiveRef = useRef(false);
@@ -145,8 +146,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
followOutputRef.current = followOutput;
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady;
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
@@ -276,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const currentScrollTop = scrollContainer.scrollTop;
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
if (!followOutputRef.current && isAtBottom) {
if (!followOutputRef.current && isAtBottom && scrolledDown) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (scrolledUp) {
if (scrolledUp || !isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
@@ -318,6 +322,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!isActivationReady) {
return;
}
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
return;
}
setFollowOutput(true);
forceStickToBottom();
const timeout = window.setTimeout(() => {
@@ -336,7 +343,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.clearTimeout(timeout);
};
}, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]);
}, [
activationKey,
forceStickToBottom,
hasRouteBottomAnchorRequest,
isActivationReady,
scheduleStickToBottom,
]);
useEffect(() => {
if (!followOutputRef.current) {

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
function timestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
}
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id,
text: id,
timestamp: timestamp(seed),
};
}
function assistantMessage(
id: string,
seed: number,
messageId?: string,
): Extract<StreamItem, { kind: "assistant_message" }> {
return {
kind: "assistant_message",
id,
text: id,
timestamp: timestamp(seed),
...(messageId ? { messageId } : {}),
};
}
describe("resolveAssistantTurnBoundaryMessageId", () => {
it("uses the selected assistant message id", () => {
const selected = assistantMessage("assistant-1", 2, "msg-assistant-1");
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), selected],
startIndex: 1,
}),
).toBe("msg-assistant-1");
});
it("does not borrow a boundary id from another assistant in the same turn", () => {
const first = assistantMessage("assistant-1", 2, "msg-assistant-1");
const selected = assistantMessage("assistant-2", 3);
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), first, selected],
startIndex: 2,
}),
).toBeUndefined();
});
it("requires the selected item to be an assistant message", () => {
expect(
resolveAssistantTurnBoundaryMessageId({
items: [userMessage("user-1", 1), assistantMessage("assistant-1", 2, "msg-assistant-1")],
startIndex: 0,
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,13 @@
import type { StreamItem } from "@/types/stream";
export function resolveAssistantTurnBoundaryMessageId(input: {
items: readonly StreamItem[];
startIndex: number;
}): string | undefined {
const item = input.items[input.startIndex];
if (item?.kind !== "assistant_message") {
return undefined;
}
// Forking without the selected assistant's durable message id would send the wrong slice.
return item.messageId || undefined;
}

View File

@@ -9,7 +9,13 @@ import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "@/components/message";
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
import {
AssistantTurnFooter,
LiveElapsed,
STREAM_METADATA_FONT_SIZE,
type AssistantForkTarget,
} from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
@@ -22,17 +28,23 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
});
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
target: AssistantForkTarget;
boundaryMessageId?: string;
}) => Promise<void> | void;
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
onForkAssistantTurn,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
if (isRunning) {
return (
@@ -50,6 +62,7 @@ export const TurnFooter = memo(function TurnFooter({
items={host.items}
timing={host.timing}
startIndex={host.startIndex}
onForkAssistantTurn={onForkAssistantTurn}
/>
);
});
@@ -59,11 +72,13 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
items,
timing,
startIndex,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
return (
<TurnFooterRow>
@@ -72,6 +87,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
items={items}
timing={timing}
startIndex={startIndex}
onForkAssistantTurn={onForkAssistantTurn}
/>
</TurnFooterRow>
);
@@ -111,11 +127,13 @@ function CompletedTurnFooter({
items,
timing,
startIndex,
onForkAssistantTurn,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
timing?: TurnTiming;
startIndex: number;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
const getContent = useCallback(
() =>
@@ -126,12 +144,18 @@ function CompletedTurnFooter({
}),
[strategy, items, startIndex],
);
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
items,
startIndex,
});
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
forkBoundaryMessageId={boundaryMessageId}
onFork={onForkAssistantTurn}
/>
</View>
);

View File

@@ -11,6 +11,7 @@ import React, {
type ComponentProps,
type ReactNode,
} from "react";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import {
View,
@@ -59,7 +60,12 @@ import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import {
CompletedTurnFooterRow,
TurnFooter,
type AssistantTurnForkHandler,
type TurnContentStrategy,
} from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
type BottomAnchorLocalRequest,
@@ -76,11 +82,21 @@ import {
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { MountedTabActiveContext } from "@/components/split-container";
import { generateDraftId } from "@/stores/draft-keys";
import {
buildDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -121,6 +137,7 @@ function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}): ReactNode {
if (!input.content) {
return null;
@@ -133,6 +150,7 @@ function renderStreamItemWithTurnFooter(input: {
items={footerHost.items}
timing={footerHost.timing}
startIndex={footerHost.startIndex}
onForkAssistantTurn={input.onForkAssistantTurn}
/>
) : null;
const content = (
@@ -233,6 +251,56 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
const EMPTY_STREAM_HEAD: StreamItem[] = [];
function buildChatHistoryAttachment(input: {
draftId: string;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
missingAttachmentMessage: string;
}): WorkspaceComposerAttachment {
if (!input.payload.attachment) {
throw new Error(input.missingAttachmentMessage);
}
return {
kind: "chat_history",
id: `chat_history:${input.draftId}`,
attachment: input.payload.attachment,
source: {
serverId: input.serverId,
agentId: input.agentId,
boundaryMessageId: input.payload.boundaryMessageId,
itemCount: input.payload.itemCount,
},
};
}
function buildForkDraftSetup(agent: AgentScreenAgent): WorkspaceDraftTabSetup | undefined {
if (!agent.provider) {
return undefined;
}
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
featureValues,
};
}
function buildForkDraftTabTarget(
setup: WorkspaceDraftTabSetup | undefined,
draftId: string,
): WorkspaceTabTarget {
return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId };
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
@@ -249,6 +317,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref,
) {
const { t } = useTranslation();
const router = useRouter();
const viewportRef = useRef<StreamViewportHandle | null>(null);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
@@ -273,6 +342,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
);
const supportsAgentForkContext = useSessionStore(
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
);
const workspaceRoot = agent.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
@@ -361,6 +433,76 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
});
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
async ({ target, boundaryMessageId }) => {
try {
if (!supportsAgentForkContext) {
toast?.error(t("message.actions.forkUnavailable"));
return;
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const draftSetup = buildForkDraftSetup(agent);
const prepareForkDraft = async () => {
const draftId = generateDraftId();
const payload = await client.buildAgentForkContext(
agentId,
boundaryMessageId ? { boundaryMessageId } : {},
);
const attachment = buildChatHistoryAttachment({
draftId,
serverId: resolvedServerId,
agentId,
payload,
missingAttachmentMessage: t("message.actions.forkFailed"),
});
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
scopeKey: buildDraftWorkspaceAttachmentScopeKey(draftId),
attachments: [attachment],
});
return draftId;
};
if (target === "tab") {
const workspaceId = agent.workspaceId;
if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace"));
}
const draftId = await prepareForkDraft();
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: buildForkDraftTabTarget(draftSetup, draftId),
});
return;
}
const draftId = await prepareForkDraft();
const sourceDirectory =
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
if (draftSetup) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId,
setup: draftSetup,
sourceDirectory,
});
}
router.push(
buildNewWorkspaceRoute({
serverId: resolvedServerId,
sourceDirectory,
displayName: agent.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey,
draftId,
}),
);
} catch (error) {
toast?.error(toErrorMessage(error) || t("message.actions.forkFailed"));
}
},
);
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and
@@ -602,9 +744,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content,
layoutItem,
strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn,
});
},
[renderStreamItemContent, streamRenderStrategy],
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -629,9 +772,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn}
/>
) : null,
[
handleForkAssistantTurn,
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
@@ -788,22 +933,60 @@ function agentCapabilityFlagsEqual(
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
}
function collectAgentProjectPlacementDiffs(
left: AgentScreenAgent["projectPlacement"],
right: AgentScreenAgent["projectPlacement"],
): string[] {
const reasons: string[] = [];
if (left?.checkout?.cwd !== right?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left?.checkout?.isGit !== right?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
if (left?.projectName !== right?.projectName) {
reasons.push("agent.projectPlacement.projectName");
}
if (left?.projectKey !== right?.projectKey) {
reasons.push("agent.projectPlacement.projectKey");
}
return reasons;
}
function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.provider !== right.provider) reasons.push("agent.provider");
if (left.currentModeId !== right.currentModeId) reasons.push("agent.currentModeId");
if (left.model !== right.model) reasons.push("agent.model");
if (left.thinkingOptionId !== right.thinkingOptionId) {
reasons.push("agent.thinkingOptionId");
}
if (left.runtimeInfo?.modeId !== right.runtimeInfo?.modeId) {
reasons.push("agent.runtimeInfo.modeId");
}
if (left.runtimeInfo?.model !== right.runtimeInfo?.model) {
reasons.push("agent.runtimeInfo.model");
}
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
const reasons: string[] = [];
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
if (left.id !== right.id) reasons.push("agent.id");
if (left.workspaceId !== right.workspaceId) reasons.push("agent.workspaceId");
if (left.status !== right.status) reasons.push("agent.status");
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
reasons.push("agent.capabilities");
}
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
reasons.push("agent.projectPlacement.checkout.cwd");
}
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
reasons.push("agent.projectPlacement.checkout.isGit");
}
reasons.push(...collectAgentSetupDiffs(left, right));
reasons.push(...collectAgentProjectPlacementDiffs(left.projectPlacement, right.projectPlacement));
return reasons;
}

View File

@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import {
createContext,
type ReactNode,
@@ -58,6 +64,7 @@ import {
startHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -949,12 +956,23 @@ function RootStack() {
);
}
function WorkspaceRouteNavigationBridge() {
const navigationRef = useNavigationContainerRef();
useEffect(() => {
return registerWorkspaceRouteNavigationRef(navigationRef);
}, [navigationRef]);
return null;
}
function AppShell() {
return (
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>

View File

@@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() {
let cancelled = false;
void client
.fetchAgent(agentId)
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;

View File

@@ -1,10 +1,39 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildOpenProjectRoute } from "@/utils/host-routes";
import { Redirect } from "expo-router";
import { useHostRouteServerId } from "@/navigation/host-route-context";
import {
resolveHostIndexRoute,
resolveWorkspaceSelectionStatus,
} from "@/navigation/host-runtime-bootstrap";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import {
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
export default function HostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
if (!serverId) return null;
// COMPAT(hostRootOpenProjectRoute): added 2026-06-11, remove after 2026-12-11.
return <Redirect href={buildOpenProjectRoute()} />;
const serverId = useHostRouteServerId();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionWorkspaceId =
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
if (!serverId || !isWorkspaceSelectionLoaded) {
return <StartupSplashScreen />;
}
return (
<Redirect
href={resolveHostIndexRoute({
serverId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces,
workspaceExists: workspaceSelectionExists,
}),
})}
/>
);
}

View File

@@ -8,16 +8,19 @@ export default function NewWorkspaceRoute() {
dir?: string;
name?: string;
projectId?: string;
draftId?: string;
}>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
const displayName = typeof params.name === "string" ? params.name : undefined;
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
const draftId = typeof params.draftId === "string" ? params.draftId : undefined;
const screenKey = JSON.stringify([
serverId,
sourceDirectory ?? null,
displayName ?? null,
projectId ?? null,
draftId ?? null,
]);
return (
@@ -28,6 +31,7 @@ export default function NewWorkspaceRoute() {
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
draftId={draftId}
/>
</HostRouteBootstrapBoundary>
);

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import {
CircleDot,
FileText,
GitPullRequest,
MessageSquareCode,
MousePointer2,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export interface AttachmentPillContent {
icon: ReactNode;
title: string;
subtitle: string;
}
function getReviewSubtitle(count: number, t: TFunction): string {
return count === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count });
}
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
return "Comment";
}
return "Review";
}
function getTextAttachmentSubtitle(
attachment: Extract<AgentAttachment, { type: "text" }>,
t: TFunction,
): string {
if (attachment.contextKind === "chat_history") {
return "Previous conversation";
}
return t("message.attachments.text");
}
export function getAgentAttachmentPillContent(
attachment: AgentAttachment,
t: TFunction,
): AttachmentPillContent {
switch (attachment.type) {
case "review":
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.comments.length, t),
};
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "github_issue":
return {
icon: attachmentGithubIssueIcon,
title: attachment.title,
subtitle: `Issue #${attachment.number}`,
};
case "text":
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
subtitle: getTextAttachmentSubtitle(attachment, t),
};
case "uploaded_file":
return {
icon: attachmentFileIcon,
title: attachment.fileName,
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
};
}
}
export function getWorkspaceAttachmentPillContent(
attachment: WorkspaceComposerAttachment,
t: TFunction,
): AttachmentPillContent {
if (attachment.kind === "browser_element") {
return {
icon: attachmentBrowserIcon,
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
return {
icon: attachmentFileIcon,
title: attachment.title,
subtitle: getPullRequestContextSubtitle(attachment),
};
}
if (attachment.kind === "chat_history") {
return {
icon: attachmentFileIcon,
title: attachment.attachment.title ?? t("message.attachments.textAttachment"),
subtitle: getTextAttachmentSubtitle(attachment.attachment, t),
};
}
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.commentCount, t),
};
}
const ThemedAttachmentFileText = withUnistyles(FileText);
const ThemedAttachmentGitPullRequest = withUnistyles(GitPullRequest);
const ThemedAttachmentCircleDot = withUnistyles(CircleDot);
const ThemedAttachmentMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedAttachmentMousePointer = withUnistyles(MousePointer2);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const attachmentReviewIcon = (
<ThemedAttachmentMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentGithubPrIcon = (
<ThemedAttachmentGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentGithubIssueIcon = (
<ThemedAttachmentCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentFileIcon = (
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);
const attachmentBrowserIcon = (
<ThemedAttachmentMousePointer size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);

View File

@@ -63,6 +63,18 @@ export type PullRequestContextAttachment =
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source: {
serverId: string;
agentId: string;
boundaryMessageId?: string | null;
itemCount?: number;
};
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
@@ -75,6 +87,7 @@ export type WorkspaceComposerAttachment =
attachment: BrowserElementAttachment;
}
| PullRequestContextAttachment
| ChatHistoryContextAttachment
| {
kind: "review";
attachment: Extract<AgentAttachment, { type: "review" }>;

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
import {
createImageSourceCacheKey,
fileUriToPath,
localFileSourceToPath,
parseDataUrl,
parseImageDataUrl,
pathToFileUri,
} from "./utils";
describe("pathToFileUri", () => {
it("converts POSIX absolute paths to file URIs", () => {
@@ -23,6 +30,24 @@ describe("pathToFileUri", () => {
});
});
describe("fileUriToPath", () => {
it("converts Windows drive-letter file URIs back to paths", () => {
expect(fileUriToPath("file:///C:/Users/file.txt")).toBe("C:/Users/file.txt");
});
});
describe("localFileSourceToPath", () => {
it("decodes markdown-encoded Windows drive-letter paths", () => {
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
});
it("preserves literal percent sequences in plain local paths", () => {
expect(localFileSourceToPath("/tmp/image%20with%20literal%20percent.png")).toBe(
"/tmp/image%20with%20literal%20percent.png",
);
});
});
describe("parseDataUrl", () => {
it("accepts base64 data URLs with media-type parameters", () => {
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({

View File

@@ -139,11 +139,41 @@ export function pathToFileUri(path: string): string {
return `file:///${path.replace(/\\/g, "/")}`;
}
function decodeFilePathSource(source: string): string {
try {
return decodeURIComponent(source);
} catch {
return source;
}
}
function normalizeWindowsDrivePath(path: string): string {
if (!/^[A-Za-z]:[\\/]/.test(path)) {
return path;
}
return path.replace(/\\/g, "/");
}
function isMarkdownEncodedWindowsDrivePath(source: string): boolean {
return /^[A-Za-z]:(?:%5[Cc]|%2[Ff])/.test(source);
}
export function fileUriToPath(uri: string): string {
if (!uri.startsWith("file://")) {
return uri;
}
return decodeURIComponent(uri.replace(/^file:\/\//, ""));
const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, ""));
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
export function localFileSourceToPath(source: string): string {
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
}
export function getFileExtensionFromName(fileName: string | null | undefined): string {

View File

@@ -22,6 +22,7 @@ export function isWorkspaceAttachment(
return (
attachment?.kind === "review" ||
attachment?.kind === "browser_element" ||
attachment?.kind === "chat_history" ||
isPullRequestContextAttachment(attachment)
);
}
@@ -33,6 +34,7 @@ export function userAttachmentsOnly(
(attachment): attachment is UserComposerAttachment =>
attachment.kind !== "review" &&
attachment.kind !== "browser_element" &&
attachment.kind !== "chat_history" &&
!isPullRequestContextAttachment(attachment),
);
}
@@ -56,5 +58,8 @@ export function workspaceAttachmentToSubmitAttachment(
text: attachment.text,
};
}
if (attachment.kind === "chat_history") {
return attachment.attachment;
}
return attachment.kind === "review" ? attachment.attachment : null;
}

View File

@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "./types";
import {
appendWorkspaceAttachment,
buildDraftWorkspaceAttachmentScopeKey,
buildWorkspaceAttachmentScopeKey,
collectWorkspaceAttachmentsForScopes,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "./workspace-attachments-store";
@@ -57,6 +59,24 @@ function contextAttachment(id: string): WorkspaceComposerAttachment {
};
}
function chatHistoryAttachment(id: string, text = "Previous chat."): WorkspaceComposerAttachment {
return {
kind: "chat_history",
id,
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text,
},
source: {
serverId: "local",
agentId: "agent-1",
},
};
}
describe("workspace attachments store", () => {
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
expect(
@@ -76,6 +96,12 @@ describe("workspace attachments store", () => {
).toBe("workspace-attachments:server=local:cwd=%2Frepo");
});
it("scopes draft attachments by draft id", () => {
expect(buildDraftWorkspaceAttachmentScopeKey("draft-1")).toBe(
"workspace-attachments:draft=draft-1",
);
});
it("publishes and clears attachments for a workspace scope", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({
@@ -116,6 +142,13 @@ describe("workspace attachments store", () => {
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
});
it("dedupes repeated chat history attachments by id", () => {
const original = chatHistoryAttachment("chat_history:draft-1", "Original chat.");
const replacement = chatHistoryAttachment("chat_history:draft-1", "Updated chat.");
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
});
it("adds a workspace attachment against the current scope state", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildWorkspaceAttachmentScopeKey({
@@ -138,4 +171,25 @@ describe("workspace attachments store", () => {
context,
]);
});
it("collects attachments across requested scopes in scope order", () => {
const draftScopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
const workspaceScopeKey = buildWorkspaceAttachmentScopeKey({
serverId: "local",
workspaceId: "workspace-1",
cwd: "/repo",
});
const draftContext = chatHistoryAttachment("chat_history:draft-1");
const workspaceContext = contextAttachment("comment-1");
expect(
collectWorkspaceAttachmentsForScopes({
attachmentsByScope: {
[workspaceScopeKey]: [workspaceContext],
[draftScopeKey]: [draftContext],
},
scopeKeys: [` ${draftScopeKey} `, "", workspaceScopeKey],
}),
).toEqual([draftContext, workspaceContext]);
});
});

View File

@@ -1,15 +1,26 @@
import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/shallow";
import type { WorkspaceComposerAttachment } from "./types";
const EMPTY_WORKSPACE_ATTACHMENTS: readonly WorkspaceComposerAttachment[] = [];
export interface WorkspaceAttachmentScopeInput {
kind?: "workspace";
serverId: string;
workspaceId?: string | null;
cwd: string;
}
export interface DraftWorkspaceAttachmentScopeInput {
kind: "draft";
draftId: string;
}
export type WorkspaceAttachmentScope =
| WorkspaceAttachmentScopeInput
| DraftWorkspaceAttachmentScopeInput;
interface WorkspaceAttachmentsStoreState {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
}
@@ -52,6 +63,10 @@ export function buildWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope
);
}
export function buildDraftWorkspaceAttachmentScopeKey(draftId: string): string {
return ["workspace-attachments", `draft=${encodeScopePart(draftId)}`].join(":");
}
function areWorkspaceAttachmentsEqual(
left: readonly WorkspaceComposerAttachment[],
right: readonly WorkspaceComposerAttachment[],
@@ -66,11 +81,12 @@ function areWorkspaceAttachmentsEqual(
}
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
if (
attachment.kind !== "github.pull_request_comment" &&
attachment.kind !== "github.pull_request_review" &&
attachment.kind !== "github.pull_request_check"
) {
const isContextAttachment =
attachment.kind === "chat_history" ||
attachment.kind === "github.pull_request_comment" ||
attachment.kind === "github.pull_request_review" ||
attachment.kind === "github.pull_request_check";
if (!isContextAttachment) {
return null;
}
return JSON.stringify({
@@ -145,11 +161,25 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
},
}));
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScopeInput): string {
const { serverId, workspaceId, cwd } = input;
export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope): string {
const isDraftScope = input.kind === "draft";
const draftId = isDraftScope ? input.draftId : "";
const serverId = isDraftScope ? "" : input.serverId;
const workspaceId = isDraftScope ? undefined : input.workspaceId;
const cwd = isDraftScope ? "" : input.cwd;
return useMemo(() => {
if (isDraftScope) {
return buildDraftWorkspaceAttachmentScopeKey(draftId);
}
return buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
}
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
return useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[serverId, workspaceId, cwd],
() => (normalizedDraftId ? buildDraftWorkspaceAttachmentScopeKey(normalizedDraftId) : ""),
[normalizedDraftId],
);
}
@@ -159,6 +189,43 @@ export function useWorkspaceAttachments(scopeKey: string): readonly WorkspaceCom
);
}
export function collectWorkspaceAttachmentsForScopes(input: {
attachmentsByScope: Record<string, readonly WorkspaceComposerAttachment[]>;
scopeKeys: readonly string[];
}): readonly WorkspaceComposerAttachment[] {
const attachments: WorkspaceComposerAttachment[] = [];
for (const scopeKey of input.scopeKeys) {
const normalizedScopeKey = scopeKey.trim();
if (!normalizedScopeKey) {
continue;
}
attachments.push(
...(input.attachmentsByScope[normalizedScopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS),
);
}
return attachments;
}
export function useWorkspaceAttachmentsForScopes(
scopeKeys: readonly string[] | undefined,
): readonly WorkspaceComposerAttachment[] {
const normalizedScopeKeys = useMemo(
() => (scopeKeys ?? []).map((scopeKey) => scopeKey.trim()).filter(Boolean),
[scopeKeys],
);
const attachmentsByScope = useWorkspaceAttachmentsStore(
useShallow((state) => state.attachmentsByScope),
);
return useMemo(
() =>
collectWorkspaceAttachmentsForScopes({
attachmentsByScope,
scopeKeys: normalizedScopeKeys,
}),
[attachmentsByScope, normalizedScopeKeys],
);
}
export function resetWorkspaceAttachmentsStore(): void {
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: {} });
}

View File

@@ -15,8 +15,6 @@ import {
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -451,9 +449,6 @@ export interface AdaptiveModalSheetProps {
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
scrollable?: boolean;
presentation?: "push" | "replace";
}
@@ -467,8 +462,6 @@ export function AdaptiveModalSheet({
snapPoints,
testID,
desktopMaxWidth,
onFilesDropped,
onGenericFilesDropped,
scrollable = true,
presentation,
}: AdaptiveModalSheetProps) {
@@ -602,18 +595,7 @@ export function AdaptiveModalSheet({
style={ABSOLUTE_FILL_STYLE}
onPress={onClose}
/>
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone
onFilesDropped={onFilesDropped}
onGenericFilesDropped={onGenericFilesDropped}
>
{cardInner}
</FileDropZone>
) : (
cardInner
)}
</View>
<View style={desktopCardStyle}>{cardInner}</View>
</View>
);

View File

@@ -0,0 +1,140 @@
import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Split } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
export type AssistantForkTarget = "tab" | "workspace";
interface AssistantForkMenuProps {
onFork: (target: AssistantForkTarget) => Promise<void> | void;
testID?: string;
}
const ThemedSplit = withUnistyles(Split);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export const AssistantForkMenu = memo(function AssistantForkMenu({
onFork,
testID = "assistant-fork-menu",
}: AssistantForkMenuProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [pendingTarget, setPendingTarget] = useState<AssistantForkTarget | null>(null);
const isLocked = pendingTarget !== null;
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next && pendingTarget !== null) return;
setIsOpen(next);
},
[pendingTarget],
);
const handleSelect = useCallback(
(target: AssistantForkTarget) => async () => {
if (isLocked) return;
setPendingTarget(target);
try {
await onFork(target);
} finally {
setPendingTarget(null);
setIsOpen(false);
}
},
[isLocked, onFork],
);
const triggerStyle = useCallback(
() => [styles.trigger, isLocked ? styles.triggerDisabled : null],
[isLocked],
);
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("message.actions.forkMenu")}</Text>
</TooltipContent>
),
[t],
);
const forkIcon = useMemo(() => <ThemedSplit size={16} uniProps={foregroundColorMapping} />, []);
return (
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<View style={styles.triggerSlot} collapsable={false}>
<DropdownMenuTrigger
accessibilityLabel={t("message.actions.forkMenu")}
accessibilityRole="button"
disabled={isLocked}
style={triggerStyle}
testID={`${testID}-trigger`}
>
{({ hovered, open }) => (
<ThemedSplit
size={16}
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
{tooltipContent}
</Tooltip>
<DropdownMenuContent align="start" minWidth={220} side="bottom" testID={`${testID}-content`}>
<DropdownMenuItem
closeOnSelect={false}
disabled={isLocked && pendingTarget !== "tab"}
leading={forkIcon}
onSelect={handleSelect("tab")}
status={pendingTarget === "tab" ? "pending" : undefined}
testID={`${testID}-new-tab`}
>
{t("message.actions.forkInNewTab")}
</DropdownMenuItem>
<DropdownMenuItem
closeOnSelect={false}
disabled={isLocked && pendingTarget !== "workspace"}
leading={forkIcon}
onSelect={handleSelect("workspace")}
status={pendingTarget === "workspace" ? "pending" : undefined}
testID={`${testID}-new-workspace`}
>
{t("message.actions.forkInNewWorkspace")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
});
const styles = StyleSheet.create((theme) => ({
trigger: {
padding: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
backgroundColor: "transparent",
},
triggerDisabled: {
opacity: theme.opacity[50],
},
triggerSlot: {
alignSelf: "center",
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
}));

View File

@@ -13,6 +13,7 @@ import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useHosts } from "@/runtime/host-runtime";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
@@ -199,9 +200,10 @@ function CommandCenterAgentRow({
interface CommandCenterAgentRowContentProps {
agent: AggregatedAgent;
showHost: boolean;
}
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
function CommandCenterAgentRowContent({ agent, showHost }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const titleStyle = useMemo(
@@ -213,7 +215,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
[theme.colors.foregroundMuted],
);
return (
<View style={styles.rowContent}>
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
@@ -226,7 +228,8 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
<Text style={titleStyle} numberOfLines={1}>
{agent.title || t("shell.commandCenter.newAgent")}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
<Text style={subtitleStyle} numberOfLines={1} testID="command-center-agent-subtitle">
{showHost ? `${agent.serverLabel} · ` : ""}
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
@@ -246,6 +249,7 @@ interface AgentItemsSectionProps {
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
showHost: boolean;
}
function AgentItemsSection({
@@ -257,6 +261,7 @@ function AgentItemsSection({
onSelect,
sectionDividerStyle,
sectionLabelStyle,
showHost,
}: AgentItemsSectionProps) {
const { t } = useTranslation();
@@ -277,7 +282,7 @@ function AgentItemsSection({
onLayout={onRowLayout(rowIndex)}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
<CommandCenterAgentRowContent agent={agent} showHost={showHost} />
</CommandCenterAgentRow>
);
})}
@@ -302,6 +307,8 @@ export function CommandCenter() {
const isCompact = useIsCompactFormFactor();
const showBottomSheet = isCompact && isNative;
// Host names only earn their space once results can span more than one host.
const showHost = useHosts().length > 1;
const rowRefs = useRef<Map<number, View>>(new Map());
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
@@ -477,6 +484,7 @@ export function CommandCenter() {
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
showHost={showHost}
/>
) : null}
</>

View File

@@ -1,106 +0,0 @@
import { View, Text } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
children: React.ReactNode;
onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean;
style?: StyleProp<ViewStyle>;
}
const IS_WEB = isWeb;
export function FileDropZone({
children,
onFilesDropped,
onGenericFilesDropped,
disabled = false,
style,
}: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled,
});
const overlayOpacity = useSharedValue(0);
useEffect(() => {
overlayOpacity.value = withTiming(isDragging ? 1 : 0, { duration: 150 });
}, [isDragging, overlayOpacity]);
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
}));
const overlayStyle = useMemo(
() => [styles.overlay, overlayAnimatedStyle],
[overlayAnimatedStyle],
);
const containerStyle = useMemo(() => [styles.container, style], [style]);
// On non-web platforms, just render children
if (!IS_WEB) {
return children;
}
return (
<View
// Cast ref for web - View renders as div on web
ref={containerRef as unknown as React.RefObject<View>}
style={containerStyle}
>
{children}
{/* Drop overlay */}
<Animated.View style={overlayStyle}>
{/* Backdrop */}
<View style={styles.backdrop} />
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</Text>
</View>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
position: "relative",
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: theme.colors.surface0,
opacity: 0.7,
},
overlayContent: {
alignItems: "center",
gap: theme.spacing[2],
},
overlayText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));

View File

@@ -0,0 +1,20 @@
import { createContext, useContext } from "react";
import type { SharedValue } from "react-native-reanimated";
import type { FileDropSink } from "./types";
export interface FileDropContextValue {
/** Drag-active flag, driven on the UI thread so toggling it triggers no React render. */
isDragging: SharedValue<boolean>;
/** Active sink can't accept right now (e.g. composer submitting): hide backdrop and reject drops. */
suppressed: SharedValue<boolean>;
/** Whether a consumer is currently registered — no consumer (e.g. archived agent), no backdrop. */
hasSink: SharedValue<boolean>;
/** Register the active sink. Pass a getter so the zone always reads the latest handlers. */
registerSink: (getSink: () => FileDropSink | null) => () => void;
}
export const FileDropContext = createContext<FileDropContextValue | null>(null);
export function useFileDropContext(): FileDropContextValue | null {
return useContext(FileDropContext);
}

View File

@@ -0,0 +1,71 @@
import { View, Text, StyleSheet as RNStyleSheet } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated";
import { Upload } from "lucide-react-native";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { useFileDropContext } from "./context";
const ThemedUpload = withUnistyles(Upload);
const primaryIconColorMapping = (theme: Theme) => ({ color: theme.colors.primary });
/**
* Drop overlay rendered by FileDropZone. Reads `isDragging` on the UI thread so the dim
* only ever repaints the backdrop — never the surrounding tree.
*/
export function FileDropBackdrop() {
const { t } = useTranslation();
const ctx = useFileDropContext();
const isDragging = ctx?.isDragging;
const suppressed = ctx?.suppressed;
const hasSink = ctx?.hasSink;
const animatedStyle = useAnimatedStyle(() => {
const active = isDragging?.value && hasSink?.value && !suppressed?.value;
return { opacity: withTiming(active ? 1 : 0, { duration: 150 }) };
});
const overlayStyle = useMemo(() => [positionStyles.overlay, animatedStyle], [animatedStyle]);
if (!ctx) return null;
// Animated.View keeps only plain-RN positioning; theme-dependent paint lives on the
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
// on theme change — see docs/unistyles.md).
return (
<Animated.View style={overlayStyle} pointerEvents="none">
<View style={styles.backdrop} />
<View style={styles.content}>
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
<Text style={styles.text}>{t("composer.attachments.dropFilesHere")}</Text>
</View>
</Animated.View>
);
}
const positionStyles = RNStyleSheet.create({
overlay: {
...RNStyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
},
});
const styles = StyleSheet.create((theme) => ({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: theme.colors.surface0,
opacity: 0.7,
},
content: {
alignItems: "center",
gap: theme.spacing[2],
},
text: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));

View File

@@ -0,0 +1,84 @@
import type { ReactNode, RefObject } from "react";
import { useCallback, useMemo, useRef } from "react";
import { View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSharedValue } from "react-native-reanimated";
import { isWeb } from "@/constants/platform";
import { FileDropContext, type FileDropContextValue } from "./context";
import { FileDropBackdrop } from "./file-drop-backdrop";
import { useDropListeners } from "./use-drop-listeners";
import type { FileDropSink } from "./types";
interface FileDropZoneProps {
children: ReactNode;
/** When true, no drops are accepted and the backdrop stays hidden. */
disabled?: boolean;
/** Styles the drop area (defaults to filling its parent). The backdrop fills this area. */
style?: StyleProp<ViewStyle>;
}
/**
* Defines a drag-and-drop area and renders its dim backdrop. Files are consumed by any
* descendant calling `useFileDrop` — the drop area, the backdrop, and the consumer are
* decoupled, so a consumer's layout can never collapse the backdrop.
*/
export function FileDropZone({ children, disabled = false, style }: FileDropZoneProps) {
const isDragging = useSharedValue(false);
const suppressed = useSharedValue(false);
const hasSink = useSharedValue(false);
const activeGetSink = useRef<(() => FileDropSink | null) | null>(null);
const registerSink = useCallback(
(getSink: () => FileDropSink | null) => {
activeGetSink.current = getSink;
hasSink.value = true;
return () => {
if (activeGetSink.current === getSink) {
activeGetSink.current = null;
hasSink.value = false;
}
};
},
[hasSink],
);
const getSink = useCallback(() => activeGetSink.current?.() ?? null, []);
const ctx = useMemo<FileDropContextValue>(
() => ({ isDragging, suppressed, hasSink, registerSink }),
[isDragging, suppressed, hasSink, registerSink],
);
const containerRef = useDropListeners({ isDragging, suppressed, hasSink, getSink, disabled });
const targetStyle = useMemo(() => [styles.target, style], [style]);
// On native there is no web drag-and-drop, so skip the listeners and the backdrop — but still
// render the styled layout View (callers use FileDropZone as their container) and provide
// context so useFileDrop no-ops safely.
if (!isWeb) {
return (
<FileDropContext.Provider value={ctx}>
<View style={targetStyle}>{children}</View>
</FileDropContext.Provider>
);
}
return (
<FileDropContext.Provider value={ctx}>
<View ref={containerRef as unknown as RefObject<View>} style={targetStyle}>
{children}
<FileDropBackdrop />
</View>
</FileDropContext.Provider>
);
}
// No default flex: the caller's `style` owns sizing (full-area surfaces pass flex:1; a dialog
// passes a content-sized style). `position` anchors the absolutely-positioned backdrop.
const styles = StyleSheet.create({
target: {
position: "relative",
},
});

View File

@@ -0,0 +1,21 @@
import type { ImageAttachment } from "@/composer/types";
export interface DroppedFileItem {
kind: "web-file";
file: File;
}
export interface DroppedPathItem {
kind: "desktop-path";
path: string;
}
export type DroppedItem = DroppedFileItem | DroppedPathItem;
/**
* What a consumer (e.g. a composer) registers to receive files dropped onto the
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
* everything else arrives raw via `onGenericFiles`.
*/
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
}

View File

@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { SharedValue } from "react-native-reanimated";
import type { ImageAttachment } from "@/composer/types";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
@@ -8,45 +10,13 @@ import {
isRasterImagePath,
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
export interface DroppedFileItem {
kind: "web-file";
file: File;
}
export interface DroppedPathItem {
kind: "desktop-path";
path: string;
}
export type DroppedItem = DroppedFileItem | DroppedPathItem;
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean;
}
interface UseFileDropZoneReturn {
isDragging: boolean;
containerRef: React.RefObject<HTMLElement | null>;
}
const IS_WEB = isWeb;
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
type DesktopDragDropPayload =
| {
type: "enter";
paths: string[];
}
| {
type: "over";
}
| {
type: "drop";
paths: string[];
}
| {
type: "leave";
};
| { type: "enter"; paths: string[] }
| { type: "over" }
| { type: "drop"; paths: string[] }
| { type: "leave" };
interface DesktopDragDropEvent {
payload: DesktopDragDropPayload;
@@ -65,37 +35,43 @@ async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
});
}
export function useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled = false,
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
const [isDragging, setIsDragging] = useState(false);
interface UseDropListenersOptions {
isDragging: SharedValue<boolean>;
/** Active sink can't accept right now: reject drops without showing acceptance. */
suppressed: SharedValue<boolean>;
/** Whether a consumer is mounted: with none, don't advertise or accept drops. */
hasSink: SharedValue<boolean>;
/** Stable getter for the currently registered sink. */
getSink: () => FileDropSink | null;
disabled: boolean;
}
/**
* Attaches web/desktop drag-and-drop listeners to the returned element ref. Drag state is
* written to a shared value (no React renders); dropped files are routed to the active sink.
*/
export function useDropListeners({
isDragging,
suppressed,
hasSink,
getSink,
disabled,
}: UseDropListenersOptions): RefObject<HTMLElement | null> {
const containerRef = useRef<HTMLElement | null>(null);
const dragCounterRef = useRef(0);
const onFilesDroppedRef = useRef(onFilesDropped);
const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
const dragCounter = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
// Keep callback refs up to date
useEffect(() => {
onFilesDroppedRef.current = onFilesDropped;
}, [onFilesDropped]);
useEffect(() => {
onGenericFilesDroppedRef.current = onGenericFilesDropped;
}, [onGenericFilesDropped]);
// Reset drag state when disabled changes
// Clear an in-progress drag when the zone becomes disabled.
useEffect(() => {
if (disabled) {
setIsDragging(false);
dragCounterRef.current = 0;
isDragging.value = false;
dragCounter.current = 0;
}
}, [disabled]);
}, [disabled, isDragging]);
// Set up event listeners on web
useEffect(() => {
if (!IS_WEB) return;
if (!isWeb) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -108,13 +84,16 @@ export function useFileDropZone({
didCleanup = true;
try {
void Promise.resolve(cleanupFn()).catch((error) => {
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
});
} catch (error) {
console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error);
console.warn("[useDropListeners] Failed to remove desktop drag-drop listener:", error);
}
}
// Desktop drag-drop (Tauri-style) is window-scoped, not element-scoped: with multiple zones
// mounted, every zone would react to the same drop. Dormant today — current Electron does not
// expose onDragDropEvent, so the element-scoped HTML5 DOM path below is what actually runs.
async function setupDesktopDragDrop(): Promise<boolean> {
const desktopHost = getDesktopHost();
if (desktopHost === null) {
@@ -130,29 +109,32 @@ export function useFileDropZone({
const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => {
const payload = event.payload;
if (payload.type === "leave") {
setIsDragging(false);
isDragging.value = false;
return;
}
if (payload.type === "enter" || payload.type === "over") {
if (!disabled) {
setIsDragging(true);
if (!disabledRef.current) {
isDragging.value = true;
}
return;
}
// Drop always ends the current drag operation.
setIsDragging(false);
isDragging.value = false;
if (disabled) return;
if (disabledRef.current || suppressed.value) return;
const sink = getSink();
if (!sink) return;
const items: DroppedPathItem[] = payload.paths.map((path) => ({
kind: "desktop-path",
path,
}));
if (onGenericFilesDroppedRef.current && items.length > 0) {
onGenericFilesDroppedRef.current(items);
if (sink.onGenericFiles && items.length > 0) {
sink.onGenericFiles(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
@@ -165,11 +147,15 @@ export function useFileDropZone({
if (attachments.length === 0) {
return;
}
onFilesDroppedRef.current(attachments);
// Use the sink captured at drop time, not a fresh getSink() — routing belongs to the
// composer the user dropped on (matches the web path below). No post-persist busy
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
// would discard the image from the same drop.
sink.onFiles(attachments);
return;
})
.catch((error) => {
console.error("[useFileDropZone] Failed to persist dropped files:", error);
console.error("[useDropListeners] Failed to persist dropped files:", error);
});
});
@@ -181,7 +167,7 @@ export function useFileDropZone({
cleanup = unlisten;
return true;
} catch (error) {
console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error);
console.warn("[useDropListeners] Failed to listen for desktop drag-drop:", error);
return false;
}
}
@@ -196,11 +182,11 @@ export function useFileDropZone({
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (disabledRef.current) return;
dragCounterRef.current++;
dragCounter.current++;
if (e.dataTransfer?.types.includes("Files")) {
setIsDragging(true);
isDragging.value = true;
}
}
@@ -208,22 +194,22 @@ export function useFileDropZone({
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (disabledRef.current) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
dragCounter.current--;
if (dragCounter.current === 0) {
isDragging.value = false;
}
}
@@ -231,10 +217,13 @@ export function useFileDropZone({
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
isDragging.value = false;
dragCounter.current = 0;
if (disabled) return;
if (disabledRef.current || suppressed.value) return;
const sink = getSink();
if (!sink) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
@@ -242,8 +231,8 @@ export function useFileDropZone({
file,
}));
if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
onGenericFilesDroppedRef.current(genericItems);
if (sink.onGenericFiles && genericItems.length > 0) {
sink.onGenericFiles(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
@@ -252,9 +241,12 @@ export function useFileDropZone({
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
onFilesDroppedRef.current(attachments);
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
// and re-checking would discard the image from the same drop. The guard at drop start
// already rejects drops that begin while busy.
sink.onFiles(attachments);
} catch (error) {
console.error("[useFileDropZone] Failed to process dropped files:", error);
console.error("[useDropListeners] Failed to process dropped files:", error);
}
}
@@ -283,10 +275,7 @@ export function useFileDropZone({
disposed = true;
runCleanup();
};
}, [disabled]);
}, [isDragging, suppressed, hasSink, getSink]);
return {
isDragging,
containerRef,
};
return containerRef;
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
import { useFileDropContext } from "./context";
import type { FileDropSink } from "./types";
interface UseFileDropOptions {
/** When true, the zone hides the backdrop and rejects drops atomically (e.g. while submitting). */
disabled?: boolean;
}
/**
* Receive files dropped onto the surrounding FileDropZone. The sink is read through
* a ref, so passing a fresh object every render neither re-registers nor re-renders.
* No-ops when rendered without a FileDropZone ancestor.
*/
export function useFileDrop(sink: FileDropSink, options?: UseFileDropOptions): void {
const ctx = useFileDropContext();
const sinkRef = useRef(sink);
sinkRef.current = sink;
const disabled = options?.disabled ?? false;
const registerSink = ctx?.registerSink;
useEffect(() => {
if (!registerSink) return;
return registerSink(() => sinkRef.current);
}, [registerSink]);
const suppressed = ctx?.suppressed;
useEffect(() => {
if (!suppressed) return;
suppressed.value = disabled;
return () => {
suppressed.value = false;
};
}, [suppressed, disabled]);
}

View File

@@ -1,11 +1,12 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { Pressable, View } from "react-native";
import type { GestureResponderEvent } from "react-native";
import { Plus, Server, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { HostStatusDot } from "@/components/host-status-dot";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
import { orderHostsLocalFirst } from "@/types/host-connection";
import {
ADD_HOST_OPTION_ID,
@@ -30,30 +31,48 @@ export function HostStatusDotSlot({ serverId }: { serverId: string }): ReactElem
);
}
// Standard secure/plain web ports carry no information in the host display, so
// "relay.paseo.sh:443" reads as "relay.paseo.sh" while "127.0.0.1:6767" is kept.
function formatConnectionEndpoint(endpoint: string): string {
return endpoint.replace(/:(?:443|80)$/, "");
}
// Socket/pipe transports have no host:port — their endpoint is a filesystem
// path, so they read as "Local". TCP and relay show the address being used.
function formatActiveConnectionLabel(connection: ActiveConnection): string {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return "Local";
}
return formatConnectionEndpoint(connection.endpoint);
}
export interface HostPickerOptionProps {
serverId: string;
label: string;
isLocal: boolean;
showActiveConnection: boolean;
selected?: boolean;
active: boolean;
onPress: () => void;
onOpenHostSettings?: (serverId: string) => void;
localMarkerTestID?: string;
testID?: string;
}
export function HostPickerOption({
serverId,
label,
isLocal,
showActiveConnection,
selected,
active,
onPress,
onOpenHostSettings,
localMarkerTestID,
testID,
}: HostPickerOptionProps): ReactElement {
const { theme } = useUnistyles();
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
const connectionLabel =
showActiveConnection && activeConnection
? formatActiveConnectionLabel(activeConnection)
: undefined;
const leadingSlot = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
const handleSettingsPress = useCallback(
(event: GestureResponderEvent) => {
@@ -63,31 +82,20 @@ export function HostPickerOption({
[onOpenHostSettings, serverId],
);
const trailingSlot = useMemo(() => {
if (!isLocal && !onOpenHostSettings) return undefined;
if (!onOpenHostSettings) return undefined;
return (
<>
{isLocal ? (
<Text style={styles.localMarker} testID={localMarkerTestID}>
Local
</Text>
) : null}
{onOpenHostSettings ? (
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
</>
<Pressable
onPress={handleSettingsPress}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={`Open ${label} settings`}
>
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
);
}, [
handleSettingsPress,
isLocal,
label,
localMarkerTestID,
onOpenHostSettings,
theme.colors.foregroundMuted,
theme.iconSize.sm,
@@ -96,6 +104,7 @@ export function HostPickerOption({
return (
<ComboboxItem
label={label}
description={connectionLabel}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
@@ -149,7 +158,7 @@ export interface HostPickerProps {
includeAllHost?: boolean;
includeAddHost?: boolean;
onAddHost?: () => void;
showLocalMarker?: boolean;
showActiveConnection?: boolean;
onOpenHostSettings?: (serverId: string) => void;
searchable?: boolean;
title?: string;
@@ -157,7 +166,6 @@ export interface HostPickerProps {
desktopMinWidth?: number;
addHostTestID?: string;
hostOptionTestID?: (serverId: string) => string;
hostLocalMarkerTestID?: (serverId: string) => string;
children: ReactNode;
}
@@ -171,7 +179,7 @@ export function HostPicker({
includeAllHost,
includeAddHost,
onAddHost,
showLocalMarker,
showActiveConnection,
onOpenHostSettings,
searchable,
title,
@@ -179,7 +187,6 @@ export function HostPicker({
desktopMinWidth,
addHostTestID,
hostOptionTestID,
hostLocalMarkerTestID,
children,
}: HostPickerProps): ReactElement {
const localServerId = useLocalDaemonServerId();
@@ -243,23 +250,20 @@ export function HostPicker({
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={showLocalMarker === true && localServerId === option.id}
showActiveConnection={showActiveConnection === true}
selected={selected}
active={active}
onPress={onPress}
onOpenHostSettings={onOpenHostSettings ? handleOpenHostSettings : undefined}
localMarkerTestID={hostLocalMarkerTestID?.(option.id)}
testID={hostOptionTestID?.(option.id)}
/>
);
},
[
addHostTestID,
hostLocalMarkerTestID,
hostOptionTestID,
localServerId,
onOpenHostSettings,
showLocalMarker,
showActiveConnection,
handleOpenHostSettings,
],
);
@@ -292,9 +296,4 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
}));

View File

@@ -32,6 +32,8 @@ import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
@@ -41,6 +43,8 @@ import { useStatusModeWorkspacePlacements } from "@/hooks/use-status-mode-worksp
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
@@ -84,7 +88,6 @@ interface SidebarSharedProps {
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
handleRefresh: () => void;
handleNewWorkspaceNavigate: () => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
@@ -175,10 +178,6 @@ export const LeftSidebar = memo(function LeftSidebar({
void openProjectPicker();
}, [openProjectPicker]);
const handleNewWorkspaceNavigate = useCallback(() => {
router.push(buildNewWorkspaceRoute());
}, []);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsRoute());
@@ -262,7 +261,6 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsBottom={insets.bottom}
isOpen={isOpen}
closeSidebar={showMobileAgent}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
@@ -278,7 +276,6 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
@@ -293,10 +290,6 @@ function sidebarHostOptionTestID(serverId: string): string {
return `sidebar-host-row-${serverId}`;
}
function sidebarHostLocalMarkerTestID(serverId: string): string {
return `sidebar-host-local-marker-${serverId}`;
}
function FooterIconButton({
buttonRef,
onPress,
@@ -368,13 +361,12 @@ function SidebarHostPicker({
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showLocalMarker
showActiveConnection
onOpenHostSettings={onOpenHostSettings}
searchable
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
hostLocalMarkerTestID={sidebarHostLocalMarkerTestID}
>
<FooterIconButton
buttonRef={triggerRef}
@@ -419,6 +411,61 @@ function HeaderIconTooltipContent({
);
}
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
label,
testID,
variant,
shortcutKeys,
onBeforeNavigate,
}: {
label: string;
testID: string;
variant: "header" | "compact";
shortcutKeys: ShortcutKey[][] | null;
onBeforeNavigate?: () => void;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(
activeWorkspaceServerId,
"workspaceMultiplicity",
);
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(
activeWorkspaceServerId
? buildNewWorkspaceRoute(
activeWorkspace && canUseActiveWorkspaceContext
? {
serverId: activeWorkspaceServerId,
sourceDirectory: activeWorkspace.projectRootPath,
projectId: activeWorkspace.projectId,
}
: { serverId: activeWorkspaceServerId },
)
: buildNewWorkspaceRoute(),
);
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={Plus}
label={label}
onPress={handlePress}
testID={testID}
variant={variant}
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarFooter({
theme,
handleOpenProject,
@@ -499,7 +546,6 @@ function MobileSidebar({
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
@@ -545,11 +591,6 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
const handleNewWorkspace = useCallback(() => {
closeSidebar();
handleNewWorkspaceNavigate();
}, [closeSidebar, handleNewWorkspaceNavigate]);
const closeGesture = useMemo(
() =>
Gesture.Pan()
@@ -691,13 +732,12 @@ function MobileSidebar({
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow
icon={Plus}
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
onPress={handleNewWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/>
<SidebarHeaderRow
icon={History}
@@ -778,7 +818,6 @@ function DesktopSidebar({
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleNewWorkspaceNavigate,
handleOpenProject,
handleHome,
handleSettings,
@@ -856,10 +895,8 @@ function DesktopSidebar({
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<View style={styles.sidebarHeaderGroup}>
<SidebarHeaderRow
icon={Plus}
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
onPress={handleNewWorkspaceNavigate}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}

View File

@@ -32,7 +32,6 @@ import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
CircleDot,
Info,
CheckCircle,
XCircle,
@@ -42,15 +41,13 @@ import {
Check,
CheckSquare,
Copy,
GitPullRequest,
MessageSquareCode,
TriangleAlertIcon,
Scissors,
MicVocal,
FileSymlink,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import Animated, {
Easing,
@@ -89,6 +86,7 @@ import {
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
@@ -104,7 +102,6 @@ import {
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import { getFileTypeLabel } from "@/attachments/file-types";
import {
AttachmentFrame,
AttachmentLabel,
@@ -116,7 +113,9 @@ import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
serverId?: string;
@@ -170,10 +169,6 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedAttachmentFileText = withUnistyles(FileText);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -402,19 +397,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
const attachmentReviewIcon = (
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentGithubPrIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentGithubIssueIcon = (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
const attachmentFileIcon = (
<ThemedAttachmentFileText size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
interface UserMessageImagePillProps {
image: UserMessageImageAttachment;
onOpen: (image: UserMessageImageAttachment) => void;
@@ -432,55 +414,6 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
);
}
interface UserMessageAttachmentContent {
icon: ReactNode;
title: string;
subtitle: string;
}
function getUserMessageAttachmentContent(
attachment: AgentAttachment,
t: ReturnType<typeof useTranslation>["t"],
): UserMessageAttachmentContent {
switch (attachment.type) {
case "review": {
const count = attachment.comments.length;
return {
icon: attachmentReviewIcon,
title: t("message.attachments.review"),
subtitle:
count === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count }),
};
}
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "github_issue":
return {
icon: attachmentGithubIssueIcon,
title: attachment.title,
subtitle: `Issue #${attachment.number}`,
};
case "text":
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
subtitle: t("message.attachments.text"),
};
case "uploaded_file":
return {
icon: attachmentFileIcon,
title: attachment.fileName,
subtitle: getFileTypeLabel(attachment.fileName) ?? t("message.attachments.file"),
};
}
}
export const UserMessage = memo(function UserMessage({
serverId,
agentId,
@@ -579,7 +512,7 @@ export const UserMessage = memo(function UserMessage({
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const content = getUserMessageAttachmentContent(attachment, t);
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
@@ -628,6 +561,11 @@ interface AssistantTurnFooterProps {
getContent: () => string;
completedAt?: Date;
durationMs?: number;
forkBoundaryMessageId?: string;
onFork?: (input: {
target: AssistantForkTarget;
boundaryMessageId?: string;
}) => Promise<void> | void;
}
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
@@ -672,6 +610,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent,
completedAt,
durationMs,
forkBoundaryMessageId,
onFork,
}: AssistantTurnFooterProps) {
const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false);
@@ -711,6 +651,13 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
revealTimerRef.current = null;
}, TIMESTAMP_REVEAL_MS);
}, [canSwap]);
const handleFork = useCallback(
(target: AssistantForkTarget) => {
return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId });
},
[forkBoundaryMessageId, onFork],
);
const canFork = Boolean(onFork && forkBoundaryMessageId);
return (
<View style={assistantTurnFooterStylesheet.container}>
@@ -718,6 +665,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{durationLabel ? (
<Pressable
onPress={handlePress}

View File

@@ -256,7 +256,7 @@ function WorkspaceSelectionProbe({
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
counts.frame += 1;
const { projects } = useSidebarWorkspacesList({ hostFilter: SERVER_ID });
const { projects } = useSidebarWorkspacesList({ hostFilters: [SERVER_ID] });
return (
<>

View File

@@ -64,6 +64,7 @@ import {
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
shouldShowSidebarHostLabels,
useSidebarWorkspaceEntry,
type SidebarProjectEntry,
type SidebarStatusWorkspacePlacement,
@@ -1748,13 +1749,14 @@ function WorkspaceRowItem({
isDragging = false,
dragHandleProps,
}: WorkspaceRowItemProps) {
const currentPathname = usePathname();
const handlePress = useCallback(() => {
if (!workspace.serverId) {
return;
}
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
return (
<WorkspaceRow
@@ -1879,6 +1881,7 @@ function ProjectBlock({
creatingWorkspaceIds,
activeWorkspaceSelection,
hostLabelByServerId,
showHostLabels,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -1899,6 +1902,7 @@ function ProjectBlock({
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
}) {
const rowModel = useMemo(
() =>
@@ -1928,9 +1932,7 @@ function ProjectBlock({
<MemoWorkspaceRowItem
workspace={item}
subtitle={
project.hosts.length > 1
? (hostLabelByServerId.get(item.serverId) ?? item.serverId)
: null
showHostLabels ? (hostLabelByServerId.get(item.serverId) ?? item.serverId) : null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -1947,7 +1949,7 @@ function ProjectBlock({
},
[
project.projectKind,
project.hosts.length,
showHostLabels,
activeWorkspaceSelection,
creatingWorkspaceIds,
hostLabelByServerId,
@@ -2115,6 +2117,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
@@ -2181,6 +2184,7 @@ export function SidebarWorkspaceList({
}
return labels;
}, [hosts]);
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
const content =
groupMode === "status" ? (
@@ -2189,6 +2193,8 @@ export function SidebarWorkspaceList({
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
) : (
<ProjectModeList
@@ -2202,6 +2208,7 @@ export function SidebarWorkspaceList({
parentGestureRef={parentGestureRef}
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
);
@@ -2213,11 +2220,15 @@ function SidebarStatusModeWrapper({
projectNamesByKey,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: {
statusWorkspacePlacements: SidebarStatusWorkspacePlacement[];
projectNamesByKey: Map<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
}) {
const showShortcutBadges = useShowShortcutBadges();
@@ -2228,6 +2239,8 @@ function SidebarStatusModeWrapper({
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
);
}
@@ -2243,12 +2256,14 @@ function ProjectModeList({
parentGestureRef,
pathname,
hostLabelByServerId,
showHostLabels,
}: Omit<
SidebarWorkspaceListProps,
"statusWorkspacePlacements" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh"
> & {
pathname: string;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
}) {
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
@@ -2436,6 +2451,7 @@ function ProjectModeList({
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
);
},
@@ -2445,6 +2461,7 @@ function ProjectModeList({
handleWorktreeCreated,
handleWorkspaceReorder,
hostLabelByServerId,
showHostLabels,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
@@ -10,11 +10,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { formatConnectionStatus } from "@/utils/daemons";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -36,9 +36,10 @@ interface DisplayPreferenceOption<Value extends string> {
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilter = useSidebarViewStore((state) => state.hostFilter);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const setHostFilter = useSidebarViewStore((state) => state.setHostFilter);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
@@ -52,13 +53,6 @@ export function SidebarDisplayPreferencesMenu() {
[setGroupMode],
);
const handleSelectHost = useCallback(
(serverId: string | null) => {
setHostFilter(serverId);
},
[setHostFilter],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
@@ -75,6 +69,7 @@ export function SidebarDisplayPreferencesMenu() {
);
const showHostFilter = hosts.length > 1;
const allHostsSelected = hostFilters.length === 0;
return (
<DropdownMenu>
@@ -105,20 +100,21 @@ export function SidebarDisplayPreferencesMenu() {
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<HostFilterItem
label="All hosts"
value={null}
hostFilter={hostFilter}
onSelect={handleSelectHost}
/>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
value={host.serverId}
hostFilter={hostFilter}
onSelect={handleSelectHost}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
@@ -167,25 +163,32 @@ function DisplayPreferenceMenuItem<Value extends string>({
function HostFilterItem({
label,
serverId,
value,
hostFilter,
onSelect,
selected,
onToggle,
}: {
label: string;
serverId?: string;
value: string | null;
hostFilter: string | null;
onSelect: (serverId: string | null) => void;
serverId: string;
selected: boolean;
onToggle: (serverId: string) => void;
}) {
const isSelected = hostFilter === value;
const handleSelect = useCallback(() => onSelect(value), [value, onSelect]);
const status = useHostRuntimeSnapshot(serverId ?? "");
const subtitle = serverId
? formatConnectionStatus(status?.connectionStatus ?? "idle")
: undefined;
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem selected={isSelected} description={subtitle} onSelect={handleSelect}>
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);

View File

@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState } from "react";
import { usePathname } from "expo-router";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
@@ -95,6 +96,8 @@ interface StatusWorkspaceListProps {
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
}
export function SidebarStatusWorkspaceList({
@@ -103,6 +106,8 @@ export function SidebarStatusWorkspaceList({
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: StatusWorkspaceListProps) {
const groups = useMemo(
() => buildStatusGroups(workspaces, projectNamesByKey),
@@ -138,6 +143,8 @@ export function SidebarStatusWorkspaceList({
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
</NestableScrollContainer>
) : (
@@ -154,6 +161,8 @@ export function SidebarStatusWorkspaceList({
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
</ScrollView>
)}
@@ -168,6 +177,8 @@ function StatusGroupList({
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
@@ -175,6 +186,8 @@ function StatusGroupList({
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
}) {
return (
<>
@@ -190,7 +203,12 @@ function StatusGroupList({
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
subtitle={buildStatusRowSubtitle({
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
hostLabel: showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
@@ -204,6 +222,21 @@ function StatusGroupList({
);
}
// Status mode breaks the project grouping, so the row needs the project name to stay
// legible; the host is appended after a middle dot once labels are active.
function buildStatusRowSubtitle({
projectName,
hostLabel,
}: {
projectName: string;
hostLabel: string | null;
}): string {
if (!hostLabel) {
return projectName;
}
return projectName ? `${projectName} · ${hostLabel}` : hostLabel;
}
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
const [isHovered, setIsHovered] = useState(false);
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
@@ -288,19 +321,20 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
subtitle,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarStatusWorkspacePlacement;
projectName: string;
subtitle: string;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const currentPathname = usePathname();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
@@ -308,15 +342,15 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
const handlePress = useCallback(() => {
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null;
return (
<StatusWorkspaceRowWithMenu
workspace={workspaceEntry}
projectName={projectName}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -327,14 +361,14 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
subtitle,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
subtitle: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -455,7 +489,7 @@ function StatusWorkspaceRowWithMenu({
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
subtitle={subtitle}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
@@ -487,7 +521,7 @@ function StatusWorkspaceRowWithMenu({
function StatusWorkspaceRowInner({
workspace,
projectName,
subtitle,
selected,
shortcutNumber,
showShortcutBadge,
@@ -504,7 +538,7 @@ function StatusWorkspaceRowInner({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
subtitle: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
@@ -554,7 +588,7 @@ function StatusWorkspaceRowInner({
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
subtitle={subtitle}
scriptIconKind={scriptIconKind}
isHovered={isHovered}
isLoading={isArchiving}

View File

@@ -1,4 +1,4 @@
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
import { forwardRef, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import {
Pressable,
View,
@@ -28,10 +28,15 @@ interface ComboboxTriggerProps extends Omit<PressableProps, "style" | "children"
style?: TriggerStyleProp;
children?: ReactNode;
chevron?: ReactNode | null;
// Fill the Pressable's width and use the standard sidebar-row gap, so the
// trigger reads as a full-width row: the label expands and the chevron pins to
// the trailing edge. Default (false) keeps the content-width pill used by the
// composer triggers.
block?: boolean;
}
export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function ComboboxTrigger(
{ children, chevron, style, ...props },
{ children, chevron, style, block = false, ...props },
ref,
): ReactElement {
const pressableStyle = useCallback(
@@ -44,9 +49,11 @@ export const ComboboxTrigger = forwardRef<View, ComboboxTriggerProps>(function C
[style],
);
const rowStyle = useMemo(() => [styles.row, block && styles.rowBlock], [block]);
return (
<Pressable ref={ref} collapsable={false} style={pressableStyle} {...props}>
<View style={styles.row}>
<View style={rowStyle}>
{children}
{chevron !== null &&
(chevron ?? (
@@ -68,6 +75,10 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[1],
},
rowBlock: {
flexGrow: 1,
gap: theme.spacing[2],
},
chevronContainer: {
flexShrink: 0,
transform: [{ translateY: 1 }],

View File

@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
@@ -420,7 +421,7 @@ export function WorkspaceSetupDialog() {
testID="workspace-setup-dialog"
desktopMaxWidth={640}
>
<View style={styles.section}>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
@@ -440,7 +441,7 @@ export function WorkspaceSetupDialog() {
inputWrapperStyle={styles.composerInputWrapper}
footer={composerFooter}
/>
</View>
</FileDropZone>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>

View File

@@ -86,7 +86,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
});
useSidebarViewStore.setState({
groupMode: "project",
hostFilter: null,
hostFilters: [],
});
act(() => {
@@ -216,7 +216,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
);
useSessionStore.getState().setHasHydratedWorkspaces("host-a", true);
useSessionStore.getState().setHasHydratedWorkspaces("host-b", true);
useSidebarViewStore.getState().setHostFilter("host-b");
useSidebarViewStore.getState().toggleHostFilter("host-b");
});
await act(async () => {

View File

@@ -4,7 +4,7 @@ import {
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
import type { DroppedItem } from "@/components/file-drop/types";
interface DroppedAttachmentsRuntime {
readDesktopFileBytes(path: string): Promise<Uint8Array>;

View File

@@ -0,0 +1,70 @@
import type { ComposerAttachment, WorkspaceComposerAttachment } from "@/attachments/types";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
export function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
if (attachment.kind === "chat_history") {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
export function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function isSentContextAttachment(
attachment: ComposerAttachment,
): attachment is WorkspaceComposerAttachment {
return (
attachment.kind === "browser_element" ||
attachment.kind === "chat_history" ||
isPullRequestContextAttachment(attachment)
);
}
export function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isSentContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
buildDraftWorkspaceAttachmentScopeKey,
resetWorkspaceAttachmentsStore,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import { removeSentContextAttachments } from "./workspace-cleanup";
function chatHistoryAttachment(): WorkspaceComposerAttachment {
return {
kind: "chat_history",
id: "chat_history:draft-1",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Previous chat.",
},
source: {
serverId: "local",
agentId: "agent-1",
},
};
}
function pullRequestContextAttachment(): WorkspaceComposerAttachment {
return {
kind: "github.pull_request_comment",
id: "comment-1",
title: "Comment",
text: "Please check this.",
};
}
function browserElementAttachment(): WorkspaceComposerAttachment {
return {
kind: "browser_element",
attachment: {
url: "https://example.com",
selector: "button.primary",
tag: "button",
text: "Click me",
outerHTML: '<button class="primary">Click me</button>',
computedStyles: {},
boundingRect: { x: 0, y: 0, width: 100, height: 40 },
reactSource: null,
parentChain: [],
children: [],
formatted: "button.primary\nClick me",
},
};
}
describe("workspace composer attachment cleanup", () => {
it("clears sent scoped context attachments from their stores", () => {
resetWorkspaceAttachmentsStore();
const scopeKey = buildDraftWorkspaceAttachmentScopeKey("draft-1");
const chatHistory = chatHistoryAttachment();
const pullRequestContext = pullRequestContextAttachment();
const browserElement = browserElementAttachment();
useWorkspaceAttachmentsStore.getState().setWorkspaceAttachments({
scopeKey,
attachments: [chatHistory, pullRequestContext, browserElement],
});
removeSentContextAttachments([chatHistory, pullRequestContext, browserElement]);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
});
});

View File

@@ -1,25 +1,25 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { withUnistyles } from "react-native-unistyles";
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
import type {
ComposerAttachment,
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { getWorkspaceAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
isPullRequestContextAttachment,
userAttachmentsOnly,
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
getAttachmentKey,
removeSentContextAttachments,
removeWorkspaceAttachmentsMatching,
} from "./workspace-cleanup";
import { useClearReviewDraft } from "@/review/store";
type TranslationFn = ReturnType<typeof useTranslation>["t"];
interface WorkspaceAttachmentBindingInput {
normalAttachments: UserComposerAttachment[];
workspaceAttachments: readonly WorkspaceComposerAttachment[];
@@ -50,97 +50,9 @@ interface ComposerWorkspaceAttachmentBinding {
resetSuppression: () => void;
}
function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "browser_element") {
return JSON.stringify({
type: "browser_element",
url: attachment.attachment.url,
selector: attachment.attachment.selector,
tag: attachment.attachment.tag,
text: attachment.attachment.text,
html: attachment.attachment.outerHTML,
});
}
if (isPullRequestContextAttachment(attachment)) {
return JSON.stringify({
kind: attachment.kind,
id: attachment.id,
});
}
return JSON.stringify({
type: "review",
cwd: attachment.attachment.cwd,
mode: attachment.attachment.mode,
baseRef: attachment.attachment.baseRef ?? null,
reviewDraftKey: attachment.reviewDraftKey,
comments: attachment.attachment.comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
})),
});
}
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
const nextAttachments = attachments.filter(
(attachment) => getAttachmentKey(attachment) !== selectedKey,
);
if (nextAttachments.length !== attachments.length) {
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
}
}
}
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
for (const key of sentContextKeys) {
removeWorkspaceAttachmentsMatching(key);
}
}
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
return "Comment";
}
return "Review";
}
interface PillContent {
title: string;
subtitle: string;
}
function getPillContent(attachment: WorkspaceComposerAttachment, t: TranslationFn): PillContent {
if (attachment.kind === "browser_element") {
return {
title: attachment.attachment.tag,
subtitle: t("composer.attachments.element"),
};
}
if (isPullRequestContextAttachment(attachment)) {
return {
title: attachment.title,
subtitle: getContextSourceLabel(attachment),
};
}
return {
title: t("message.attachments.review"),
subtitle:
attachment.commentCount === 1
? t("message.attachments.commentsOne")
: t("message.attachments.commentsMany", { count: attachment.commentCount }),
};
}
function getOpenAccessibilityLabel(
attachment: WorkspaceComposerAttachment,
t: TranslationFn,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.openBrowserElement");
@@ -148,12 +60,15 @@ function getOpenAccessibilityLabel(
if (isPullRequestContextAttachment(attachment)) {
return "Open context attachment";
}
if (attachment.kind === "chat_history") {
return "Open chat history attachment";
}
return t("composer.attachments.openReview");
}
function getRemoveAccessibilityLabel(
attachment: WorkspaceComposerAttachment,
t: TranslationFn,
t: ReturnType<typeof useTranslation>["t"],
): string {
if (attachment.kind === "browser_element") {
return t("composer.attachments.removeBrowserElement");
@@ -161,17 +76,17 @@ function getRemoveAccessibilityLabel(
if (isPullRequestContextAttachment(attachment)) {
return "Remove context attachment";
}
if (attachment.kind === "chat_history") {
return "Remove chat history attachment";
}
return t("composer.attachments.removeReview");
}
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement {
if (attachment.kind === "browser_element") {
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
function getPillTestID(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "chat_history") {
return "composer-chat-history-attachment-pill";
}
if (isPullRequestContextAttachment(attachment)) {
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
}
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
return "composer-review-attachment-pill";
}
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
@@ -249,7 +164,11 @@ function useWorkspaceAttachmentBinding({
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
const selected = current[index];
if (isWorkspaceAttachment(selected)) {
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
if (
selected.kind === "browser_element" ||
selected.kind === "chat_history" ||
isPullRequestContextAttachment(selected)
) {
const selectedKey = getAttachmentKey(selected);
removeWorkspaceAttachmentsMatching(selectedKey);
return true;
@@ -323,7 +242,7 @@ function WorkspaceAttachmentPill({
onRemove,
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const content = getPillContent(attachment, t);
const content = getWorkspaceAttachmentPillContent(attachment, t);
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
@@ -332,18 +251,14 @@ function WorkspaceAttachmentPill({
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-review-attachment-pill"
testID={getPillTestID(attachment)}
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
disabled={disabled}
>
<AttachmentLabel
icon={renderPillIcon(attachment)}
title={content.title}
subtitle={content.subtitle}
/>
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
</AttachmentPill>
);
}
@@ -355,8 +270,3 @@ export const composerWorkspaceAttachment = {
userAttachmentsOnly,
useBinding: useWorkspaceAttachmentBinding,
};
const ThemedMousePointer2 = withUnistyles(MousePointer2);
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
const ThemedFileText = withUnistyles(FileText);
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });

View File

@@ -87,4 +87,63 @@ describe("useDraftAgentCreateFlow", () => {
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = {
kind: "chat_history",
id: "chat-history-1",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Previous conversation",
},
source: {
serverId: "server-1",
agentId: "agent-source",
},
} as const;
const createRequest = vi.fn(async () => ({
agentId: "agent-1",
result: { id: "agent-1" },
}));
const onCreateSuccess = vi.fn();
const validateBeforeSubmit = vi.fn(() => null);
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
createRequest,
onCreateSuccess,
validateBeforeSubmit,
}),
);
await act(async () => {
await result.current.handleCreateFromInput({
text: " ",
attachments: [attachment],
cwd: "/repo",
});
});
expect(validateBeforeSubmit).toHaveBeenCalledWith({
text: "",
attachments: [attachment],
cwd: "/repo",
});
expect(createRequest).toHaveBeenCalledWith({
attempt: expect.objectContaining({
text: "",
attachments: [attachment.attachment],
}),
text: "",
attachments: [attachment.attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
});

View File

@@ -241,7 +241,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const images = wirePayload.images;
const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) {
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
const error = new Error(t("composer.errors.initialPromptRequired"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;

View File

@@ -5,6 +5,13 @@ export interface WorkspaceDraftAutoSubmitConfig {
model: string | null;
}
export function shouldAllowEmptyDraftText(input: {
allowsEmptyAutoSubmit: boolean;
attachments: readonly unknown[];
}): boolean {
return input.allowsEmptyAutoSubmit || input.attachments.length > 0;
}
export function validateDraftSubmission(input: {
text: string;
allowsEmptyAutoSubmit: boolean;

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { validateDraftSubmission } from "./workspace-tab-core";
import { shouldAllowEmptyDraftText, validateDraftSubmission } from "./workspace-tab-core";
const baseComposerState = {
providerDefinitions: [{ id: "codewhale" }],
@@ -49,3 +49,23 @@ describe("workspace draft agent model validation", () => {
).toBe("No model is available for the selected provider");
});
});
describe("workspace draft empty text readiness", () => {
test("allows attachment-only retries after a fork draft create fails", () => {
expect(
shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit: false,
attachments: [{ kind: "chat_history" }],
}),
).toBe(true);
});
test("still rejects empty drafts with no auto-submit and no attachments", () => {
expect(
shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit: false,
attachments: [],
}),
).toBe(false);
});
});

View File

@@ -8,6 +8,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { AgentStreamView } from "@/agent-stream/view";
@@ -26,14 +27,18 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core";
import {
shouldAllowEmptyDraftText,
validateDraftSubmission,
} from "@/composer/draft/workspace-tab-core";
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
useWorkspaceAttachments,
useDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import {
@@ -117,6 +122,7 @@ async function submitDraftCreateRequest(input: {
text: string;
images?: UserMessageImageAttachment[];
attachments?: unknown;
cwd: string;
client: DaemonClient | null;
workspaceDirectory: string | null;
workspaceId: string | null;
@@ -137,6 +143,7 @@ async function submitDraftCreateRequest(input: {
text,
images,
attachments,
cwd,
client,
workspaceDirectory,
workspaceId,
@@ -161,7 +168,7 @@ async function submitDraftCreateRequest(input: {
});
const config = buildWorkspaceDraftAgentConfig({
provider,
cwd: workspaceDirectory,
cwd,
...modeIdOverride,
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
@@ -399,7 +406,14 @@ export function WorkspaceDraftAgentTab({
cwd: composerState.workingDir,
workspaceId,
});
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
const draftAttachmentScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const attachmentScopeKeys = useMemo(
() => [draftAttachmentScopeKey, workspaceAttachmentScopeKey].filter(Boolean),
[draftAttachmentScopeKey, workspaceAttachmentScopeKey],
);
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback(
@@ -436,15 +450,20 @@ export function WorkspaceDraftAgentTab({
getPendingServerId: () => serverId,
initialAttempt: initialCreateAttempt,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text }) =>
validateDraftSubmission({
text,
validateBeforeSubmit: ({ text, attachments }) => {
const allowsEmptyDraftText = shouldAllowEmptyDraftText({
allowsEmptyAutoSubmit,
attachments,
});
return validateDraftSubmission({
text,
allowsEmptyAutoSubmit: allowsEmptyDraftText,
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
}),
});
},
onBeforeSubmit: async () => {
await composerState.persistFormPreferences();
if (isWeb) {
@@ -462,12 +481,13 @@ export function WorkspaceDraftAgentTab({
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments }) =>
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
submitDraftCreateRequest({
attempt,
text,
images,
attachments,
cwd,
client,
workspaceDirectory: draftWorkingDirectory,
workspaceId: workspaceFields?.id ?? null,
@@ -478,6 +498,8 @@ export function WorkspaceDraftAgentTab({
}),
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result);
},
});
@@ -644,7 +666,7 @@ export function WorkspaceDraftAgentTab({
);
return (
<View style={styles.container}>
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
@@ -689,7 +711,7 @@ export function WorkspaceDraftAgentTab({
value={draftInput.text}
onChangeText={draftInput.setText}
attachments={draftInput.attachments}
workspaceAttachments={workspaceAttachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
@@ -702,7 +724,7 @@ export function WorkspaceDraftAgentTab({
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</View>
</FileDropZone>
);
}

View File

@@ -43,8 +43,8 @@ import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { FileDropZone } from "@/components/file-drop-zone";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -103,6 +103,7 @@ import type {
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
import { getFileTypeLabel } from "@/attachments/file-types";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -121,6 +122,8 @@ type AttachmentListUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {}
const noopCallback = () => {};
@@ -759,7 +762,7 @@ interface ComposerProps {
value: string;
onChangeText: (text: string) => void;
attachments: UserComposerAttachment[];
workspaceAttachments?: readonly WorkspaceComposerAttachment[];
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
@@ -971,7 +974,7 @@ export function Composer({
value,
onChangeText,
attachments,
workspaceAttachments = [],
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
onChangeAttachments,
cwd,
@@ -1026,6 +1029,7 @@ export function Composer({
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
const userInput = value;
const setUserInput = onChangeText;
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
const {
selectedAttachments,
buildOutgoingAttachments,
@@ -1881,6 +1885,15 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
useFileDrop(
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
{ disabled: isSubmitBusy },
);
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
@@ -1894,97 +1907,90 @@ export function Composer({
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<FileDropZone
onFilesDropped={addImages}
onGenericFilesDropped={handleGenericFilesDropped}
disabled={isSubmitBusy}
style={styles.dropZone}
>
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<Animated.View style={composerContainerStyle}>
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocompleteVisible}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
</View>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitBusy}
isSubmitLoading={isSubmitBusy}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
onSelectionChange={handleSelectionChange}
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</FileDropZone>
</View>
{renderComposerFooter(footer, footerInlineContent)}
</Animated.View>
</ComposerKeyboardScopeProvider>
);
}
@@ -1994,10 +2000,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "column",
position: "relative",
},
dropZone: {
flex: 0,
width: "100%",
},
borderSeparator: {
height: theme.borderWidth[1],
backgroundColor: theme.colors.border,

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -1210,6 +1211,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
error: payload.error,
});
if (followUp?.direction === "after") {
refreshAgentInitializationTimeout({
key: initKey,
agentId,
setAgentInitializing: (id, initializing) => {
if (initializing) {
return;
}
clearAgentInitializingFlag(setInitializingAgents, serverId, id);
},
});
requestCanonicalCatchUp(agentId, {
epoch: followUp.cursor.epoch,
endSeq: followUp.cursor.seq,

View File

@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.31",
version: "3.0.34",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.31", "--acp"],
command: ["npx", "-y", "cline@3.0.34", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.112.0",
version: "2.114.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.112.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.114.0", "--acp"],
},
{
id: "codewhale",
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.11",
version: "0.2.12",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.11", "acp"],
command: ["npx", "-y", "dimcode@0.2.12", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.9",
version: "0.4.12",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.9", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.12", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.159.1",
version: "0.161.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.159.1", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.161.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.22",
version: "0.8.1",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.7.22", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.8.1", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.21",
version: "1.1.22",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.22", "acp"],
},
{
id: "poolside",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.31",
version: "1.0.33",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.31", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.33", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.2",
version: "0.19.3",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.2", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.3", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -2,6 +2,14 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStopReason =
| "manual_ipc"
| "settings"
| "host_remove"
| "quit"
| "app_update"
| "version_mismatch"
| "restart";
export interface DesktopDaemonStatus {
serverId: string;
@@ -122,8 +130,10 @@ export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
}
export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon"));
export async function stopDesktopDaemon(
reason: DesktopDaemonStopReason = "manual_ipc",
): Promise<DesktopDaemonStatus> {
return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason }));
}
export async function restartDesktopDaemon(): Promise<DesktopDaemonStatus> {

View File

@@ -60,7 +60,7 @@ export function useBuiltInDaemonManagement(
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
stopDaemon: () => stopDesktopDaemon("settings"),
});
if (result.kind === "enabled") {
const upsertResult = await upsertDesktopDaemonConnection(

View File

@@ -57,6 +57,18 @@ describe("desktop app updater — check", () => {
expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "automatic" }]);
});
it("does not add manual last-checked feedback for automatic checks", async () => {
const { updater, port } = createUpdater({ now: () => 42 });
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
expect(updater.getSnapshot()).toMatchObject({
status: "pending",
lastCheckedAt: null,
});
});
it("moves to 'checking' during a non-silent check", async () => {
const { updater, port } = createUpdater();
const deferred = port.deferNextCheck();
@@ -102,15 +114,17 @@ describe("desktop app updater — check", () => {
});
});
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
it("reports the found update while it is still preparing", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
port.nextCheckResult(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: false, latestVersion: "1.2.3" }),
);
await updater.checkForUpdates({ releaseChannel: "stable" });
expect(updater.getSnapshot()).toMatchObject({
status: "pending",
availableUpdate: null,
availableUpdate: { latestVersion: "1.2.3", readyToInstall: false },
});
});
@@ -135,6 +149,134 @@ describe("desktop app updater — check", () => {
});
});
it("reports service-returned check errors", async () => {
const { updater, port } = createUpdater({ now: () => 42 });
port.nextCheckResult(buildFakeCheckResult({ errorMessage: "sha512 checksum mismatch" }));
await updater.checkForUpdates({ releaseChannel: "stable" });
expect(updater.getSnapshot()).toMatchObject({
status: "error",
errorMessage: "sha512 checksum mismatch",
lastCheckedAt: 42,
});
});
it("keeps no-update silent check errors quiet", async () => {
const { updater, port } = createUpdater({ now: () => 42 });
port.nextCheckResult(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "1.2.3" }),
);
await updater.checkForUpdates({ releaseChannel: "stable" });
port.nextCheckResult(buildFakeCheckResult({ errorMessage: "network down" }));
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
expect(updater.getSnapshot()).toMatchObject({
status: "available",
errorMessage: null,
lastCheckedAt: 42,
});
});
it("shows silent update preparation errors when an update is involved", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(
buildFakeCheckResult({
hasUpdate: true,
readyToInstall: false,
latestVersion: "1.2.3",
errorMessage: "sha512 checksum mismatch",
}),
);
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
expect(updater.getSnapshot()).toMatchObject({
status: "error",
errorMessage: "sha512 checksum mismatch",
lastCheckedAt: null,
});
});
it("does not let a silent check supersede an in-flight manual check", async () => {
const { updater, port } = createUpdater();
const deferred = port.deferNextCheck();
const manualCheck = updater.checkForUpdates({ releaseChannel: "stable" });
port.nextCheckResult(buildFakeCheckResult({ errorMessage: "network down" }));
await updater.checkForUpdates({ releaseChannel: "stable", intent: "automatic", silent: true });
deferred.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
await manualCheck;
expect(port.recordedChecks).toEqual([{ releaseChannel: "stable", intent: "manual" }]);
expect(updater.getSnapshot().status).toBe("up-to-date");
});
it("does not let an older silent check supersede a newer silent check", async () => {
const { updater, port } = createUpdater();
const olderCheck = port.deferNextCheck();
const olderPending = updater.checkForUpdates({
releaseChannel: "stable",
intent: "automatic",
silent: true,
});
const newerCheck = port.deferNextCheck();
const newerPending = updater.checkForUpdates({
releaseChannel: "stable",
intent: "automatic",
silent: true,
});
newerCheck.resolve(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "2.0.0" }),
);
await newerPending;
expect(updater.getSnapshot()).toMatchObject({
status: "available",
availableUpdate: { latestVersion: "2.0.0" },
});
olderCheck.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
await olderPending;
expect(updater.getSnapshot()).toMatchObject({
status: "available",
availableUpdate: { latestVersion: "2.0.0" },
});
});
it("lets a newer silent check win after an older silent check resolves first", async () => {
const { updater, port } = createUpdater();
const olderCheck = port.deferNextCheck();
const olderPending = updater.checkForUpdates({
releaseChannel: "stable",
intent: "automatic",
silent: true,
});
const newerCheck = port.deferNextCheck();
const newerPending = updater.checkForUpdates({
releaseChannel: "stable",
intent: "automatic",
silent: true,
});
olderCheck.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
await olderPending;
expect(updater.getSnapshot().status).toBe("idle");
newerCheck.resolve(
buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "2.0.0" }),
);
await newerPending;
expect(updater.getSnapshot()).toMatchObject({
status: "available",
availableUpdate: { latestVersion: "2.0.0" },
});
});
it("does not move to 'error' when a silent check throws", async () => {
const { updater, port } = createUpdater();
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
@@ -268,6 +410,32 @@ describe("formatStatusText", () => {
).toBe("Update ready: v1.2.3");
});
it("shows the found version while an update is pending", () => {
expect(
formatStatusText({
status: "pending",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Update found: v1.2.3. Downloading... Last checked at time-42.");
});
it("keeps manual check feedback visible when an update is available", () => {
expect(
formatStatusText({
status: "available",
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
installMessage: null,
lastCheckedAt: 42,
formatVersion,
formatLastCheckedAt,
}),
).toBe("Update ready: v1.2.3. Last checked at time-42.");
});
it("falls back to a generic 'available' message when no version is reported", () => {
expect(
formatStatusText({

View File

@@ -137,13 +137,42 @@ export function formatStatusText(input: {
}
if (status === "pending") {
if (availableUpdate?.latestVersion) {
return i18n.t(
lastCheckedAt != null
? "desktop.updates.status.pendingWithVersionAndLastChecked"
: "desktop.updates.status.pendingWithVersion",
{
version: formatVersion(availableUpdate.latestVersion),
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
},
);
}
if (lastCheckedAt != null) {
return i18n.t("desktop.updates.status.pendingWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
});
}
return i18n.t("desktop.updates.status.pending");
}
if (status === "available") {
if (availableUpdate?.latestVersion) {
return i18n.t("desktop.updates.status.availableWithVersion", {
version: formatVersion(availableUpdate.latestVersion),
return i18n.t(
lastCheckedAt != null
? "desktop.updates.status.availableWithVersionAndLastChecked"
: "desktop.updates.status.availableWithVersion",
{
version: formatVersion(availableUpdate.latestVersion),
time: lastCheckedAt != null ? formatLastCheckedAt(lastCheckedAt) : undefined,
},
);
}
if (lastCheckedAt != null) {
return i18n.t("desktop.updates.status.availableWithLastChecked", {
time: formatLastCheckedAt(lastCheckedAt),
});
}
return i18n.t("desktop.updates.status.available");
@@ -182,13 +211,17 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
return null;
}
const { releaseChannel, intent = "manual", silent = false } = options;
if (silent && state.status === "checking") {
return null;
}
const requestVersion = state.requestVersion + 1;
commit({
...state,
requestVersion,
status: silent ? state.status : "checking",
errorMessage: null,
errorMessage: silent ? state.errorMessage : null,
});
try {
@@ -197,7 +230,24 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
return result;
}
const nextLastCheckedAt = deps.now();
const nextLastCheckedAt = intent === "manual" ? deps.now() : state.lastCheckedAt;
if (result.errorMessage) {
if (silent && !result.hasUpdate) {
console.warn("[DesktopUpdater] Silent update check failed", result.errorMessage);
return result;
}
commit({
...state,
status: "error",
availableUpdate: null,
errorMessage: result.errorMessage,
installMessage: null,
lastCheckedAt: nextLastCheckedAt,
});
return result;
}
let nextStatus: DesktopAppUpdateStatus;
let nextAvailable: DesktopAppUpdateCheckResult | null;
@@ -206,7 +256,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
nextAvailable = result;
} else if (result.hasUpdate) {
nextStatus = "pending";
nextAvailable = null;
nextAvailable = result;
} else {
nextStatus = "up-to-date";
nextAvailable = null;
@@ -216,6 +266,7 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
...state,
status: nextStatus,
availableUpdate: nextAvailable,
errorMessage: null,
installMessage: null,
lastCheckedAt: nextLastCheckedAt,
});
@@ -229,12 +280,12 @@ export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopApp
const message = getErrorMessage(error);
if (silent) {
console.warn("[DesktopUpdater] Silent update check failed", message);
commit({ ...state });
} else {
commit({
...state,
status: "error",
errorMessage: message,
lastCheckedAt: intent === "manual" ? deps.now() : state.lastCheckedAt,
});
}
return null;

View File

@@ -10,6 +10,7 @@ export interface DesktopAppUpdateCheckResult {
latestVersion: string | null;
body: string | null;
date: string | null;
errorMessage: string | null;
}
export interface DesktopAppUpdateInstallResult {
@@ -121,6 +122,7 @@ export async function checkDesktopAppUpdate({
latestVersion: toStringOrNull(result.latestVersion),
body: toStringOrNull(result.body),
date: toStringOrNull(result.date),
errorMessage: toStringOrNull(result.errorMessage),
};
}

View File

@@ -41,6 +41,7 @@ function buildCheckResult(
latestVersion: null,
body: null,
date: null,
errorMessage: null,
...overrides,
};
}

View File

@@ -9,6 +9,7 @@ import {
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
shouldShowSidebarHostLabels,
type SidebarProjectEntry,
} from "./sidebar-workspaces-view-model";
@@ -297,6 +298,63 @@ describe("shared sidebar workspace model", () => {
});
});
describe("shouldShowSidebarHostLabels", () => {
it("is false with no visible projects", () => {
expect(shouldShowSidebarHostLabels([])).toBe(false);
});
it("is false when every project lives on a single host", () => {
const projects = buildSidebarProjectsFromStructure({
projects: [
project({ projectKey: "project-a", workspaceKeys: ["ws-1"] }),
project({ projectKey: "project-b", workspaceKeys: ["ws-2"] }),
],
});
expect(shouldShowSidebarHostLabels(projects)).toBe(false);
});
it("is true when projects span separate hosts", () => {
const projects = buildSidebarProjectsFromStructure({
projects: [
project({
projectKey: "project-a",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/project-a", canCreateWorktree: true },
],
workspaceKeys: ["host-a:ws-1"],
}),
project({
projectKey: "project-b",
hosts: [
{ serverId: "host-b", iconWorkingDir: "/repo/project-b", canCreateWorktree: true },
],
workspaceKeys: ["host-b:ws-2"],
}),
],
});
expect(shouldShowSidebarHostLabels(projects)).toBe(true);
});
it("is true for a single project shared across hosts", () => {
const projects = buildSidebarProjectsFromStructure({
projects: [
project({
projectKey: "getpaseo/paseo",
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/paseo", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/paseo", canCreateWorktree: true },
],
workspaceKeys: ["host-a:main", "host-b:feature"],
}),
],
});
expect(shouldShowSidebarHostLabels(projects)).toBe(true);
});
});
describe("computeSidebarOrderUpdates", () => {
it("returns no updates when there are no visible projects", () => {
const updates = computeSidebarOrderUpdates({

Some files were not shown because too many files have changed in this diff Show More