Compare commits

..

39 Commits

Author SHA1 Message Date
Mohamed Boudra
8338bb59d9 test(projects): assert legacy placement identity 2026-07-29 11:39:34 +00:00
Mohamed Boudra
8c89592bb3 fix(projects): preserve legacy placement identity 2026-07-29 11:28:32 +00:00
Mohamed Boudra
ad0939dfbb fix(projects): preserve conservative identity boundaries 2026-07-29 11:13:07 +00:00
Mohamed Boudra
7b9787a75d fix(projects): resolve legacy mutation aliases 2026-07-29 10:59:01 +00:00
Mohamed Boudra
7e43d90aa5 fix(projects): retain absolute scp users 2026-07-29 10:45:23 +00:00
Mohamed Boudra
1f467fa43e test(app): model persisted project keys 2026-07-29 10:36:06 +00:00
Mohamed Boudra
fef479e749 fix(projects): scope legacy paths to hosts 2026-07-29 10:20:55 +00:00
Mohamed Boudra
1ff152edcd fix(projects): retain archived reference errors 2026-07-29 10:09:43 +00:00
Mohamed Boudra
0060c88399 fix(projects): harden project reference identity 2026-07-29 09:55:37 +00:00
Mohamed Boudra
30a6122deb fix(projects): preserve project key compatibility
Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories.
2026-07-29 09:42:05 +00:00
Mohamed Boudra
5ae7ca7004 test(projects): assert host-scoped project keys 2026-07-29 09:26:18 +00:00
Mohamed Boudra
471a7431f2 fix(projects): prevent project key collisions
Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership.
2026-07-29 09:16:01 +00:00
Mohamed Boudra
649592fe73 fix(projects): restore project key as grouping identity
Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping.
2026-07-29 09:12:08 +00:00
Mohamed Boudra
39e5006882 fix(projects): harden grouping identity and routing 2026-07-29 07:40:36 +00:00
Mohamed Boudra
f054e403a6 fix(projects): preserve exact placement identity 2026-07-29 02:48:30 +00:00
Mohamed Boudra
3e66464b2e test(projects): expect framed settings route 2026-07-29 02:33:01 +00:00
Mohamed Boudra
8f2599d424 fix(projects): harden grouped project routing 2026-07-29 02:26:21 +00:00
Mohamed Boudra
1dc03e565f test(projects): use grouped identity in UI specs 2026-07-29 02:13:13 +00:00
Mohamed Boudra
8140d8508e fix(projects): separate identity from presentation 2026-07-29 01:40:55 +00:00
Mohamed Boudra
afbd527c8d fix(projects): preserve SSH URL path semantics 2026-07-29 01:20:04 +00:00
Mohamed Boudra
29d3b2f76c fix(projects): keep settings on routed host 2026-07-29 01:06:29 +00:00
Mohamed Boudra
a924d51e4c fix(projects): model SCP remote semantics 2026-07-29 00:59:35 +00:00
Mohamed Boudra
d853df73a4 fix(projects): preserve remote path semantics 2026-07-29 00:43:52 +00:00
Mohamed Boudra
a3188a7699 fix(projects): preserve host-scoped actions 2026-07-29 00:28:36 +00:00
Mohamed Boudra
f154531825 fix(projects): harden host and remote identity 2026-07-29 00:13:45 +00:00
Mohamed Boudra
e849518c76 fix(projects): normalize default remote ports 2026-07-29 00:02:16 +00:00
Mohamed Boudra
5928dc08d9 fix(projects): stabilize settings and path identity 2026-07-28 23:50:49 +00:00
Mohamed Boudra
26452a4f01 fix(projects): reconcile grouped custom names 2026-07-28 23:33:50 +00:00
Mohamed Boudra
34500f30cf fix(projects): preserve symlinked bootstrap roots 2026-07-28 23:23:39 +00:00
Mohamed Boudra
95ceb238ee fix(projects): isolate legacy host identity 2026-07-28 23:11:50 +00:00
Mohamed Boudra
6544ca7419 fix(projects): preserve selected project identity 2026-07-28 22:57:45 +00:00
Mohamed Boudra
ec08367d93 fix(projects): stabilize legacy subproject grouping 2026-07-28 22:40:04 +00:00
Mohamed Boudra
07bb8eeaa6 fix(projects): close grouped routing gaps 2026-07-28 22:30:45 +00:00
Mohamed Boudra
d3d59749b0 fix(projects): preserve selected host identity 2026-07-28 22:18:48 +00:00
Mohamed Boudra
8f89aee756 test(app): update project host shape contract 2026-07-28 22:05:24 +00:00
Mohamed Boudra
b7ee191245 fix(projects): strengthen grouping identity 2026-07-28 21:59:01 +00:00
Mohamed Boudra
b92d142fce fix(projects): preserve host-local project identity 2026-07-28 21:40:42 +00:00
Mohamed Boudra
9e86b13b1f fix(projects): keep ambiguous clones separate
A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate.
2026-07-28 21:21:29 +00:00
Mohamed Boudra
47e095270a fix(projects): restore cross-host grouping
Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration.
2026-07-28 21:19:07 +00:00
162 changed files with 7162 additions and 2884 deletions

View File

@@ -115,9 +115,6 @@ jobs:
- 'packages/relay/**'
- 'packages/server/**'
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
format:
runs-on: ubuntu-latest
env:
@@ -141,7 +138,7 @@ jobs:
lint:
needs: changes
if: >-
${{ !cancelled() &&
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
@@ -171,7 +168,7 @@ jobs:
typecheck:
needs: changes
if: >-
${{ !cancelled() &&
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
@@ -202,7 +199,11 @@ jobs:
server-tests:
needs: changes
if: ${{ !cancelled() }}
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -211,43 +212,28 @@ jobs:
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps
- name: Run server tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -256,7 +242,11 @@ jobs:
desktop-tests:
needs: changes
if: ${{ !cancelled() }}
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -265,53 +255,39 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
steps:
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
if: matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
if: failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
@@ -320,7 +296,7 @@ jobs:
- name: Build and smoke unpacked desktop app
if: >-
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
@@ -332,7 +308,7 @@ jobs:
- name: Upload packaged smoke diagnostics
if: >-
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
@@ -346,7 +322,7 @@ jobs:
app-tests:
needs: changes
if: >-
${{ !cancelled() &&
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
@@ -376,7 +352,7 @@ jobs:
sdk-tests:
needs: changes
if: >-
${{ !cancelled() &&
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
@@ -407,7 +383,11 @@ jobs:
playwright:
needs: changes
if: ${{ !cancelled() }}
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -421,57 +401,43 @@ jobs:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true' && !matrix.desktop
if: ${{ !matrix.desktop }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: env.RUN_TESTS == 'true' && !matrix.desktop
if: ${{ !matrix.desktop }}
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: env.RUN_TESTS == 'true' && matrix.desktop
if: ${{ matrix.desktop }}
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: env.RUN_TESTS == 'true' && failure()
if: failure()
with:
name: playwright-results-${{ matrix.shard }}
path: |
@@ -482,7 +448,7 @@ jobs:
relay-tests:
needs: changes
if: >-
${{ !cancelled() &&
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
@@ -508,7 +474,11 @@ jobs:
cli-tests:
needs: changes
if: ${{ !cancelled() }}
if: >-
${{ always() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false') }}
strategy:
fail-fast: false
matrix:
@@ -517,38 +487,24 @@ jobs:
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"

View File

@@ -21,8 +21,16 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider
same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed.
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement,
or permission, and have not been activated during the idle window are eligible. `running`,
`initializing`, and `error` agents stay resident. An idle parent also stays resident while current
in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated
independently; collection does not cascade or change parentage.
Active schedules targeting an existing agent protect that agent from collection. Paused, completed,
and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the
runtime.
### Cancellation
@@ -51,8 +59,9 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
children.
Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native
archive hook, and cascades to managed children. Runtime collection does none of those things; it only
releases the live runtime and writes `lastStatus: closed` on the still-active record.
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.

View File

@@ -4,9 +4,15 @@
Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name.
`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its
ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed
`projectKey` is a persisted, opaque equivalence key used only to group the same logical project
across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized
Git remote and otherwise uses the local project root. Consumers never derive it from live Git.
Creation persists it with the project, and normal boot reconciliation fills or refreshes it for
older records where the field is absent—there is no migration.
`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and
updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path,
names, and workspace foreign keys. Attached workspaces are independently refreshed
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
@@ -450,7 +456,8 @@ Array of project records.
| Field | Type | Description |
| ------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs |
| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values |
| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed |
| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key |
| `displayName` | `string` | Selected-root basename, stable across remote and Git changes |

View File

@@ -2,7 +2,7 @@
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
@@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.

View File

@@ -38,14 +38,6 @@ There are two supported ways to ship from `main`:
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
2. **Beta flow**: release candidates on the `beta` channel. Betas carry an in-place changelog entry (beta users check it), publish npm only on the explicit `beta` dist-tag, and never move the website download target off the latest stable.
Paseo has one linear release track even though npm dist-tags are independent
pointers. The npm invariant is:
- A beta release moves only `beta`; `latest` remains on the newest stable.
- A stable release moves both `latest` and `beta` to that stable version. This
keeps users who install `@getpaseo/cli@beta` on the newest Paseo release after
a beta is promoted or superseded by a direct stable release.
## Release version decision
Every fresh release starts by classifying the full previous-stable-to-`HEAD`
@@ -84,20 +76,6 @@ npm run release:minor
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
After the stable release succeeds, move npm's `beta` pointer to the new stable
version for every published package. This changes dist-tags only; do not
republish the packages:
```bash
PASEO_VERSION=$(node -p "require('./package.json').version")
for package in highlight relay protocol client server cli; do
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
done
```
Verify both npm tags now resolve to `PASEO_VERSION` before considering the
stable release complete.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
The production relay is the Elixir service in [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay), with its own deployment process. Paseo releases and pushes to this repository do not deploy it. The Cloudflare relay code and workflow in this repository are legacy and are not used in production.
@@ -114,7 +92,6 @@ npm run version:all:patch
npm run version:all:minor
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
# Then move npm's beta dist-tag to this stable version using the command above.
```
## Beta flow
@@ -529,7 +506,6 @@ Betas are checkpoints along the way; the entry is the single record for the jump
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). When promoting from beta, overwrite the existing `## X.Y.Z-beta.N` heading in place (heading → `X.Y.Z`, date → promotion day) — do not add a new entry on top of the beta one
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
- [ ] Move npm's `beta` dist-tag to the new stable version for every published package and verify both `latest` and `beta` resolve to it
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `Release Mobile` workflow for the same tag is green

View File

@@ -87,14 +87,42 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
its stable identity, text, timestamp, images, and attachments do not change when the provider
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape or
a property inferred from message identity. The transaction registry holds every unresolved send and
records RPC acceptance and provider acknowledgement independently. Provider acknowledgement exists
solely so a later transport error cannot roll back a prompt already observed canonically.
The daemon's accepted response already waits for the correlated run start, but its response and the
directory update reach client state separately. An accepted transaction remains active until the
directory observes that run or canonical ingestion acknowledges the prompt, bridging those ordered
authorities without inspecting timeline snapshots. Either signal clears only an RPC-accepted
transaction, regardless of which arrived first; it cannot settle a fresh send.
Overlapping sends settle independently rather than collapsing to one newest pending message.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider
identity to the existing row, and keeps the local presentation in its original timeline slot.
Content matching is limited to the dated compatibility path for daemon timelines created before
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary.
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
payload while taking the canonical row's ordered position. If a live assistant head is the
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
## Relevant code

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { test, expect } from "./fixtures";
import {
addProjectFlow,
addProjectFlowBack,
@@ -13,36 +13,21 @@ import {
expectAddProjectPage,
expectNewWorkspaceForAddedProject,
openAddProjectFlow,
waitForConnectedHost,
} from "./helpers/add-project-flow";
import { gotoAppShell } from "./helpers/app";
import { buildSeededHost } from "./helpers/daemon-registry";
import { addOfflineHostAndReload } from "./helpers/hosts";
import {
addConnectedHostAndReload,
addOfflineHostAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
const SECONDARY_HOST_ID = "add-project-flow-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise<void> {
const registryEntry = buildSeededHost({
serverId: host.serverId,
label: SECONDARY_HOST_LABEL,
endpoint: `127.0.0.1:${host.port}`,
nowIso: new Date().toISOString(),
});
await page.evaluate(
({ key, entry }) => {
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: EXTRA_HOSTS_KEY, entry: registryEntry },
);
await page.reload();
}
async function expectProjectDirectory(pathname: string): Promise<void> {
await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true);
}
@@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => {
test("keyboard selection chooses the second host", async ({ page }) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => {
page,
}) => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,
@@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => {
try {
await gotoAppShell(page);
await addConnectedHostAndReload(page, secondaryHost);
await addConnectedHostAndReload(page, {
serverId: secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: SECONDARY_HOST_ID,
endpoint: `localhost:${secondaryHost.port}`,

View File

@@ -0,0 +1,685 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test as baseTest } from "./fixtures";
import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream";
import { gateNextAgentMessage } from "./helpers/agent-message-gate";
import {
attachImageFromMenu,
expectComposerDraft,
expectComposerEditable,
expectAttachmentPill,
expectComposerVisible,
fillComposerDraft,
sendDraftToQueue,
startRunningMockAgent,
} from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { readScrollMetrics } from "./helpers/agent-bottom-anchor";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { selectModel } from "./helpers/app";
const IMAGE = {
name: "message-submission.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
interface MessageGeometry {
x: number;
y: number;
width: number;
height: number;
}
interface SubmissionScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
}
interface DraftCreateScenario {
workspaceId: string;
agentCreatedDelay: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}
interface RejectionScenario {
errorMessage: string;
}
interface UnrelatedRunningScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
}
const test = baseTest.extend<{
submissionScenario: SubmissionScenario;
draftCreateScenario: DraftCreateScenario;
rejectionScenario: RejectionScenario;
unrelatedRunningScenario: UnrelatedRunningScenario;
}>({
submissionScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-submission-${testInfo.workerIndex}-`,
title: "Message submission regression",
model: "ten-second-stream",
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate });
await agent.cleanup();
},
draftCreateScenario: async ({ page }, provide, testInfo) => {
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const workspace = await seedWorkspace({
repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`,
});
await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay });
agentCreatedDelay.release();
await workspace.cleanup();
},
rejectionScenario: async ({ page }, provide, testInfo) => {
const errorMessage = "Requested mock prompt rejection";
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-rejection-${testInfo.workerIndex}-`,
title: "Message rejection regression",
model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 },
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ errorMessage });
await agent.cleanup();
},
unrelatedRunningScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `unrelated-running-${testInfo.workerIndex}-`,
title: "Unrelated running transition",
model: "one-minute-stream",
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate, agent });
await agent.cleanup();
},
});
async function submitMessageWithImage(page: Page, prompt: string): Promise<Locator> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
const nextFrame = await composer.evaluate(
(composerElement, submittedPrompt) =>
new Promise<{
rowPresent: boolean;
workingPresent: boolean;
composerValue: string | null;
attachmentPresent: boolean;
}>((resolve) => {
requestAnimationFrame(() => {
const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]'));
const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement;
resolve({
rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)),
workingPresent: Boolean(
document.querySelector('[data-testid="turn-working-indicator"]'),
),
composerValue: composerInput.value,
attachmentPresent: Boolean(
document.querySelector('[data-testid="composer-image-attachment-pill"]'),
),
});
});
}),
prompt,
);
expect(nextFrame).toEqual({
rowPresent: true,
workingPresent: true,
composerValue: "",
attachmentPresent: false,
});
return page.getByTestId("user-message").filter({ hasText: prompt }).last();
}
async function submitImageOnlyMessage(page: Page): Promise<Locator> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").last();
await expect(userMessage).toBeVisible();
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
return userMessage;
}
async function expectPendingSubmission(page: Page, userMessage: Locator): Promise<void> {
await expect(userMessage).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached();
await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0");
await expect(userMessage).toHaveAttribute("aria-busy", "true");
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
async function readMessageGeometry(page: Page, userMessage: Locator): Promise<MessageGeometry> {
const box = await userMessage.boundingBox();
if (!box) throw new Error("Submitted user message has no browser geometry");
const { offsetY } = await readScrollMetrics(page);
return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height };
}
async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise<void>> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await page.evaluate(() => {
const state = { active: true, sawMissing: false };
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionFooterContinuity = state;
const checkFrame = () => {
if (!state.active) return;
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
state.sawMissing = true;
}
requestAnimationFrame(checkFrame);
};
requestAnimationFrame(checkFrame);
});
return async () => {
const sawMissing = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionFooterContinuity as
| { active: boolean; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Working-footer continuity check was not started");
state.active = false;
delete windowState.__messageSubmissionFooterContinuity;
return state.sawMissing;
});
expect(sawMissing).toBe(false);
};
}
async function expectAcceptedSubmission(
page: Page,
userMessage: Locator,
submittedGeometry: MessageGeometry,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry);
}
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
}
async function expectRejectedSubmissionRestored(
page: Page,
input: { prompt: string; errorMessage: string },
): Promise<void> {
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
}
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toHaveCount(1);
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
async function queueMessage(page: Page, prompt: string): Promise<void> {
await fillComposerDraft(page, prompt);
await sendDraftToQueue(page);
}
async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount(
prompts.length,
);
for (const prompt of prompts) {
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
}
}
async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise<void> {
await expectComposerDraft(page, prompt);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
}
async function expectInterruptedTurnOrderAfterReconnect(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`,
title: "Submission reconnect ordering",
model: "ten-second-stream",
});
const prompt = "Keep this prompt before its response.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted.");
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await queueMessage(page, prompt);
gate.setAgentStreamSuppressed(true);
await page.getByRole("button", { name: "Send queued message now" }).click();
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(promptRow).toBeVisible();
await gate.waitForServerMessage("send_agent_message_response");
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setAgentStreamSuppressed(false);
gate.forceNextTimelineEpochReset();
gate.restoreFresh();
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
title: "Submission missed running transition",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const userMessage = await submitImageOnlyMessage(page);
await gate.waitForHeldClientRequest();
gate.setServerMessageSuppressed("agent_status", true);
gate.setServerMessageSuppressed("agent_update", true);
gate.releaseHeldClientRequest();
await gate.waitForServerMessage("send_agent_message_response");
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setServerMessageSuppressed("agent_status", false);
gate.setServerMessageSuppressed("agent_update", false);
gate.restoreFresh();
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`,
title: "Submission acknowledgement before RPC",
model: "ten-second-stream",
});
const prompt = "Settle this provider-acknowledged submission.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.setServerMessageSuppressed("agent_status", true);
gate.setServerMessageSuppressed("agent_update", true);
gate.holdNextServerMessage("send_agent_message_response");
const userMessage = await submitMessageWithImage(page, prompt);
await gate.waitForHeldServerMessage();
await gate.waitForAgentStreamItem("user_message");
gate.releaseHeldServerMessage();
await gate.drop();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectLegacyAssistantStartsAfterInterruptedPrompt(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`,
title: "Legacy assistant interrupt boundary",
model: "ten-second-stream",
});
const prompt = "Start the replacement answer after this prompt.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer.");
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await queueMessage(page, prompt);
gate.setAssistantMessageIdsStripped(true);
gate.setAgentStreamEventSuppressed("turn_canceled", true);
await page.getByRole("button", { name: "Send queued message now" }).click();
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(replacementAnswer).toBeVisible({ timeout: 30_000 });
await expectRenderedBefore(promptRow, replacementAnswer);
} finally {
gate.setAssistantMessageIdsStripped(false);
gate.setAgentStreamEventSuppressed("turn_canceled", false);
await agent.cleanup();
}
}
async function expectStaleCanonicalPagePreservesNewerLiveOutput(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`,
title: "Stale canonical page race",
model: "one-minute-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call.");
await awaitToolCall(page, "read");
await page
.getByRole("button", { name: /stop|cancel/i })
.first()
.click();
await expectAgentIdle(page);
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.requestTimelineTail(agent.agentId);
await gate.waitForHeldServerMessage();
gate.truncateHeldTimelineAfterLast("tool_call");
expect(gate.getHeldTimelineLastItemType()).toBe("tool_call");
const nextPrompt = "Stream after the stale snapshot.";
await agent.client.sendAgentMessage(agent.agentId, nextPrompt);
const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt });
const liveAssistant = nextPromptRow.locator(
'xpath=following::*[@data-testid="assistant-message"][1]',
);
await expect(nextPromptRow).toBeVisible();
await expect(liveAssistant).toContainText("Cycle 1");
gate.releaseHeldServerMessage();
await expect(liveAssistant).toContainText("Cycle 1");
} finally {
await agent.cleanup();
}
}
async function expectCanonicalOrderWinsAcrossOverlappingClients(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
title: "Cross-client submission order",
model: "ten-second-stream",
});
const localPrompt = "Send this after the other client turn.";
const remotePrompt = "Commit this other client turn first.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const localRow = await submitMessageWithImage(page, localPrompt);
await gate.waitForHeldClientRequest();
await agent.client.sendAgentMessage(agent.agentId, remotePrompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt });
await expect(remoteRow).toBeVisible();
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.releaseHeldClientRequest();
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
await expect(localRow).toHaveAttribute("aria-busy", "false");
await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect
.poll(async () => {
const localElement = await localRow.elementHandle();
if (!localElement) return false;
return remoteRow.evaluate(
(remoteElement, localNode) =>
Boolean(
remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING,
),
localElement,
);
})
.toBe(true);
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectRenderedBefore(first: Locator, second: Locator): Promise<void> {
const secondElement = await second.elementHandle();
if (!secondElement) throw new Error("Expected the second timeline item to be rendered");
expect(
await first.evaluate(
(firstElement, secondNode) =>
Boolean(
firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING,
),
secondElement,
),
).toBe(true);
}
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
}
async function expectCreatedAgentHandoff(
page: Page,
prompt: string,
userMessage: Locator,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 });
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
interface DraftCreatePendingSubmission {
prompt: string;
userMessage: Locator;
}
async function beginDraftCreateSubmission(
page: Page,
scenario: DraftCreateScenario,
): Promise<DraftCreatePendingSubmission> {
await openWorkspaceDraft(page, scenario.workspaceId);
await selectModel(page, "one-minute-stream");
const prompt = "Keep this row through create handoff.";
const userMessage = await submitMessageWithImage(page, prompt);
await scenario.agentCreatedDelay.waitForCreateRequest();
await scenario.agentCreatedDelay.waitForDelayedCreatedStatus();
await expectPendingSubmission(page, userMessage);
return { prompt, userMessage };
}
async function completeDraftCreateSubmission(
page: Page,
scenario: DraftCreateScenario,
pending: DraftCreatePendingSubmission,
): Promise<void> {
scenario.agentCreatedDelay.release();
await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage);
}
test.describe("Agent message submission", () => {
test("keeps the submitted row stable when the host accepts", async ({
page,
submissionScenario,
}) => {
const userMessage = await submitMessageWithImage(page, "Hold this submission.");
await expectPendingSubmission(page, userMessage);
await submissionScenario.gate.waitForRequest();
const submittedGeometry = await readMessageGeometry(page, userMessage);
const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page);
submissionScenario.gate.accept();
await expectAcceptedSubmission(page, userMessage, submittedGeometry);
await finishFooterContinuityCheck();
});
test("keeps the submitted row stable through draft create handoff", async ({
page,
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
});
test("restores a rejected submission and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
await retryRestoredSubmission(page, prompt);
});
test("restores overlapping queued sends when their connection fails", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `overlapping-queued-send-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep the agent running while messages queue.",
});
const prompts = ["Restore the first queued send.", "Restore the second queued send."];
try {
await queueMessage(page, prompts[0]);
await queueMessage(page, prompts[1]);
await page.getByRole("button", { name: "Send queued message now" }).first().click();
await gate.waitForRequest(1);
await page.getByRole("button", { name: "Send queued message now" }).first().click();
await gate.waitForRequest(2);
await gate.disconnect();
await expectQueuedSendFailuresRestored(page, prompts);
} finally {
await agent.cleanup();
}
});
test("does not accept a failed submission from an unrelated running turn", async ({
page,
unrelatedRunningScenario,
}) => {
const prompt = "Restore this unsent prompt.";
await submitMessageThatWillBeRejected(page, prompt);
await unrelatedRunningScenario.gate.waitForRequest();
await unrelatedRunningScenario.agent.client.sendAgentMessage(
unrelatedRunningScenario.agent.agentId,
"Start an unrelated turn.",
);
await expect(
page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }),
).toBeVisible();
await unrelatedRunningScenario.gate.disconnect();
await expectFailedSubmissionRestored(page, prompt);
});
test("keeps a submitted prompt before its response when canonical history arrives", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
});
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo);
});
test("clears a provider acknowledgement that arrives before RPC acceptance", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo);
});
test("keeps an old-daemon replacement answer after its interrupted prompt", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo);
});
test("preserves newer live output when a stale canonical page arrives", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo);
});
test("uses canonical order when another client turn overtakes a held submission", async ({
page,
}, testInfo) => {
await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo);
});
});

View File

@@ -207,7 +207,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
@@ -293,7 +293,7 @@ test.describe("Composer attachments", () => {
});
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectId,
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await fillComposerDraft(page, "lock test prompt");

View File

@@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await archiveItem.click();
}
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
async function removeProjectFromSidebar(page: Page, projectKey: string): Promise<void> {
const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await projectRow.hover();
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`);
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
@@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
// user-confirmed removal proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`);
await expect(removeItem).toBeVisible({ timeout: 10_000 });
await removeItem.click();
}
@@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => {
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
const newWorkspaceRow = page.getByTestId(
`sidebar-project-new-workspace-row-${workspace.projectId}`,
`sidebar-project-new-workspace-row-${workspace.projectKey}`,
);
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
@@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => {
test.describe("Project remove", () => {
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
let readdedProjectId: string | null = null;
try {
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`);
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -206,7 +207,7 @@ test.describe("Project remove", () => {
timeout: 30_000,
});
await removeProjectFromSidebar(page, workspace.projectId);
await removeProjectFromSidebar(page, workspace.projectKey);
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
@@ -220,21 +221,24 @@ test.describe("Project remove", () => {
const readded = await workspace.client.addProject(workspace.repoPath);
expect(readded.error).toBeNull();
expect(readded.project).not.toBeNull();
const readdedProjectId = readded.project?.projectId ?? "";
readdedProjectId = readded.project?.projectId ?? "";
const readdedProjectKey = readded.project?.projectKey ?? "";
expect(readdedProjectId).not.toBe(workspace.projectId);
expect(readdedProjectKey).toBe(workspace.projectKey);
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
await page.reload();
await waitForSidebarHydration(page);
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`);
await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 });
await expect(readdedProjectRow).toContainText(workspace.projectDisplayName);
await expect(readdedProjectRow).not.toContainText(workspace.repoPath);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).toContainText(workspace.projectDisplayName);
await expect(projectRow).not.toContainText(workspace.repoPath);
await expect(
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`),
page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`),
).toBeVisible({ timeout: 30_000 });
} finally {
if (readdedProjectId) {
await workspace.client.removeProject(readdedProjectId).catch(() => undefined);
}
await workspace.cleanup();
}
});

View File

@@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca
return page.getByTestId(`add-project-flow-method-${method}`);
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise<Locator> {
const currentPage = page.getByTestId(`add-project-flow-page-${kind}`);
await expect(currentPage).toBeVisible({ timeout: 30_000 });

View File

@@ -0,0 +1,86 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
agentId: string;
}
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: Record<string, unknown>;
};
const request = envelope.type === "session" ? envelope.message : null;
if (
request?.type !== "send_agent_message_request" ||
typeof request.requestId !== "string" ||
typeof request.agentId !== "string"
) {
return null;
}
return {
type: "send_agent_message_request",
requestId: request.requestId,
agentId: request.agentId,
};
} catch {
return null;
}
}
export async function gateNextAgentMessage(page: Page) {
let serverSocket: WebSocketRoute | null = null;
let browserSocket: WebSocketRoute | null = null;
const heldMessages: Array<WebSocketMessage | null> = [];
const requests: SendAgentMessageRequest[] = [];
const requestWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
browserSocket = ws;
const server = ws.connectToServer();
serverSocket = server;
ws.onMessage((message) => {
const request = readSendRequest(message);
if (request) {
heldMessages.push(message);
requests.push(request);
for (const resolve of requestWaiters) resolve();
requestWaiters.clear();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
const waitForRequest = async (count = 1): Promise<SendAgentMessageRequest> => {
while (requests.length < count) {
await new Promise<void>((resolve) => requestWaiters.add(resolve));
}
return requests[count - 1];
};
return {
waitForRequest,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
throw new Error("No held send-agent-message request to accept");
}
serverSocket.send(heldMessage);
heldMessages[index] = null;
},
async disconnect(): Promise<void> {
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
},
};
}

View File

@@ -16,6 +16,20 @@ interface ClientRequest {
type?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
payload?: unknown;
}
function readSessionMessage(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.message ?? envelope;
} catch {
return null;
}
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
@@ -38,15 +52,111 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo
return null;
}
function stripAssistantMessageId(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
};
const event = (envelope.message?.payload ?? envelope.payload)?.event;
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
delete event.item.messageId;
return JSON.stringify(envelope);
}
function stripMessageSubmissionDisposition(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") {
return message;
}
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
delete payload.outOfBand;
return JSON.stringify(envelope);
}
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
if (!enabled || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
payload.epoch = `playwright-reset-${Date.now()}`;
payload.reset = true;
return JSON.stringify(envelope);
}
function readAgentStreamEventType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown } }).event;
return typeof event?.type === "string" ? event.type : null;
}
function readAgentStreamItemType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
.event;
return event?.type === "timeline" && typeof event.item?.type === "string"
? event.item.type
: null;
}
function shouldSuppressServerMessage(input: {
message: ClientRequest | null;
messageTypes: ReadonlySet<string>;
agentStreamEventTypes: ReadonlySet<string>;
suppressAgentStream: boolean;
}): boolean {
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
if (messageType && input.messageTypes.has(messageType)) return true;
if (input.suppressAgentStream && messageType === "agent_stream") return true;
const eventType = readAgentStreamEventType(input.message);
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
let stripSubmissionDisposition = false;
let heldClientRequestType: string | null = null;
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldClientRequest: (() => void) | null = null;
let heldServerMessageType: string | null = null;
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldServerMessage: (() => void) | null = null;
const suppressedServerMessageTypes = new Set<string>();
const suppressedAgentStreamEventTypes = new Set<string>();
const activeSockets = new Set<WebSocketRoute>();
let latestServer: WebSocketRoute | null = null;
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
const serverMessageCounts = new Map<string, number>();
const agentStreamItemCounts = new Map<string, number>();
const serverMessageWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
@@ -56,9 +166,20 @@ export async function installDaemonWebSocketGate(page: Page) {
activeSockets.add(ws);
const server = ws.connectToServer();
latestServer = server;
ws.onMessage((message) => {
if (!acceptingConnections) return;
if (reconnectWithFreshClient && typeof message === "string") {
const hello = readClientRequest(message);
if (hello?.type === "hello") {
const parsed = JSON.parse(message) as { clientId?: string };
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
reconnectWithFreshClient = false;
server.send(JSON.stringify(parsed));
return;
}
}
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
@@ -69,6 +190,12 @@ export async function installDaemonWebSocketGate(page: Page) {
directoryStarts.total[directory] += 1;
}
}
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
resolveHeldClientRequest?.();
resolveHeldClientRequest = null;
return;
}
try {
server.send(message);
} catch {
@@ -78,8 +205,55 @@ export async function installDaemonWebSocketGate(page: Page) {
server.onMessage((message) => {
if (!acceptingConnections) return;
const serverMessage = readSessionMessage(message);
let outboundMessage = stripAssistantMessageId(
message,
stripAssistantMessageIds,
serverMessage?.type,
);
outboundMessage = stripMessageSubmissionDisposition(
outboundMessage,
stripSubmissionDisposition,
serverMessage?.type,
);
const shouldForceTimelineReset =
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
if (typeof serverMessage?.type === "string") {
serverMessageCounts.set(
serverMessage.type,
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
);
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
}
const agentStreamItemType = readAgentStreamItemType(serverMessage);
if (agentStreamItemType) {
agentStreamItemCounts.set(
agentStreamItemType,
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
);
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
}
if (serverMessage?.type === heldServerMessageType) {
heldServerMessage = { browser: ws, message: outboundMessage };
resolveHeldServerMessage?.();
resolveHeldServerMessage = null;
return;
}
if (
shouldSuppressServerMessage({
message: serverMessage,
messageTypes: suppressedServerMessageTypes,
agentStreamEventTypes: suppressedAgentStreamEventTypes,
suppressAgentStream,
})
)
return;
try {
ws.send(message);
ws.send(outboundMessage);
} catch {
activeSockets.delete(ws);
}
@@ -100,6 +274,125 @@ export async function installDaemonWebSocketGate(page: Page) {
restore(): void {
acceptingConnections = true;
},
restoreFresh(): void {
reconnectWithFreshClient = true;
acceptingConnections = true;
},
holdNextClientRequest(type: string): void {
heldClientRequestType = type;
heldClientRequest = null;
},
waitForHeldClientRequest(): Promise<void> {
if (heldClientRequest) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldClientRequest = resolve;
});
},
releaseHeldClientRequest(): void {
if (!heldClientRequest) throw new Error("No held client request to release");
heldClientRequest.server.send(heldClientRequest.message);
heldClientRequest = null;
heldClientRequestType = null;
},
holdNextServerMessage(type: string): void {
heldServerMessageType = type;
heldServerMessage = null;
},
waitForHeldServerMessage(): Promise<void> {
if (heldServerMessage) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldServerMessage = resolve;
});
},
releaseHeldServerMessage(): void {
if (!heldServerMessage) throw new Error("No held server message to release");
heldServerMessage.browser.send(heldServerMessage.message);
heldServerMessage = null;
heldServerMessageType = null;
},
requestTimelineTail(agentId: string): void {
if (!latestServer) throw new Error("No daemon WebSocket is connected");
latestServer.send(
JSON.stringify({
type: "session",
message: {
type: "fetch_agent_timeline_request",
agentId,
requestId: `playwright-timeline-${Date.now()}`,
direction: "tail",
limit: 0,
projection: "projected",
},
}),
);
},
getHeldTimelineLastItemType(): string | null {
if (!heldServerMessage) throw new Error("No held server message to inspect");
const response = readSessionMessage(heldServerMessage.message);
const payload = response?.payload;
if (!payload || typeof payload !== "object") return null;
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) return null;
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
return typeof last?.item?.type === "string" ? last.item.type : null;
},
truncateHeldTimelineAfterLast(itemType: string): void {
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
throw new Error("No held text server message to truncate");
}
const envelope = JSON.parse(heldServerMessage.message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) throw new Error("Held message has no payload");
const entries = payload.entries;
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
const index = entries.findLastIndex(
(entry) =>
typeof entry === "object" &&
entry !== null &&
(entry as { item?: { type?: unknown } }).item?.type === itemType,
);
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
const lastSeq = retained.at(-1)?.seqEnd;
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
payload.entries = retained;
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
payload.hasNewer = false;
if (payload.window && typeof payload.window === "object") {
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
}
heldServerMessage.message = JSON.stringify(envelope);
},
setServerMessageSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedServerMessageTypes.add(type);
} else {
suppressedServerMessageTypes.delete(type);
}
},
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedAgentStreamEventTypes.add(type);
} else {
suppressedAgentStreamEventTypes.delete(type);
}
},
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setMessageSubmissionDispositionStripped(stripped: boolean): void {
stripSubmissionDisposition = stripped;
},
setAgentStreamSuppressed(suppressed: boolean): void {
suppressAgentStream = suppressed;
},
forceNextTimelineEpochReset(): void {
forceTimelineEpochReset = true;
},
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
return {
subscribed: { ...directoryStarts.subscribed },
@@ -110,5 +403,18 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
getAgentStreamItemCount(type: string): number {
return agentStreamItemCounts.get(type) ?? 0;
},
async waitForServerMessage(type: string, count = 1): Promise<void> {
while ((serverMessageCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
};
}

View File

@@ -57,6 +57,66 @@ export async function addOfflineHostAndReload(
await page.reload();
}
export async function addConnectedHostAndReload(
page: Page,
input: { serverId: string; label: string; port: number },
): Promise<void> {
await addConnectedHostsAndReload(page, [input]);
}
export async function addConnectedHostsAndReload(
page: Page,
inputs: Array<{ serverId: string; label: string; port: number }>,
): Promise<void> {
const connectedHosts = inputs.map((input) =>
buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: `127.0.0.1:${input.port}`,
nowIso: new Date().toISOString(),
}),
);
await page.evaluate(
({ hosts, keys }) => {
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 }> = raw ? JSON.parse(raw) : [];
for (const host of hosts) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
hosts: connectedHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
},
);
await page.reload();
}
export async function waitForConnectedHost(
page: Page,
input: { serverId: string; endpoint: string },
): Promise<void> {
await page.getByTestId("sidebar-hosts-trigger").click();
const host = page.getByTestId(`sidebar-host-row-${input.serverId}`);
await expect(host).toContainText(input.endpoint, { timeout: 30_000 });
await page.keyboard.press("Escape");
await expect(host).not.toBeVisible();
}
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({

View File

@@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env";
export interface IsolatedHostDaemon {
serverId: string;
port: number;
paseoHome: string;
restart(): Promise<void>;
close(): Promise<void>;
}
@@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
return {
serverId,
port,
paseoHome,
restart: async () => {
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
await stopProcess(child);

View File

@@ -46,9 +46,13 @@ function requireWorkspace(payload: WorkspacePayload) {
}
function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject {
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Workspace ${workspace.id} has no project key`);
}
return {
workspaceId: workspace.id,
projectKey: workspace.projectId,
projectKey,
projectDisplayName: workspace.projectDisplayName,
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,

View File

@@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const testId = await projectRow.getAttribute("data-testid");
expect(testId).not.toBeNull();
return testId!.replace("sidebar-project-row-", "");
await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 });
const projectId = new URL(page.url()).searchParams.get("projectId");
expect(projectId).not.toBeNull();
return projectId!;
}

View File

@@ -8,6 +8,8 @@ export interface SeedWorkspaceDescriptor {
id: string;
name: string;
projectId: string;
projectKey?: string;
project?: { projectKey?: string };
projectDisplayName: string;
projectRootPath: string;
workspaceDirectory: string;
@@ -25,6 +27,7 @@ export interface SeedDaemonClient {
addProject(cwd: string): Promise<{
project: {
projectId: string;
projectKey?: string;
projectDisplayName: string;
projectRootPath: string;
} | null;
@@ -179,8 +182,10 @@ export interface SeededWorkspace {
workspaceId: string;
workspaceName: string;
workspaceDirectory: string;
/** Stable project identity the daemon groups workspaces under. */
/** Host-local identity used by daemon project operations. */
projectId: string;
/** Opaque cross-host key used by grouped project UI and routes. */
projectKey: string;
/** Project label the UI shows (owner/repo for known remotes, else basename). */
projectDisplayName: string;
cleanup(): Promise<void>;
@@ -208,6 +213,10 @@ export async function seedWorkspace(options: {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
}
const workspace = created.workspace;
const projectKey = workspace.projectKey ?? workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${workspace.id} has no project key`);
}
return {
client,
repoPath: project.path,
@@ -215,6 +224,7 @@ export async function seedWorkspace(options: {
workspaceName: workspace.name,
workspaceDirectory: workspace.workspaceDirectory,
projectId: workspace.projectId,
projectKey,
projectDisplayName: workspace.projectDisplayName,
cleanup: async () => {
await client.removeProject(workspace.projectId).catch(() => undefined);

View File

@@ -64,6 +64,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
if (!createdWorkspace.workspace) {
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
}
const projectKey =
createdWorkspace.workspace.projectKey ?? createdWorkspace.workspace.project?.projectKey;
if (!projectKey) {
throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`);
}
return {
prompt,
promptPreview,
@@ -75,6 +80,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
workspaceName: createdWorkspace.workspace.name,
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
projectId: createdWorkspace.workspace.projectId,
projectKey,
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
cleanup: async () => {
await client.close().catch(() => undefined);

View File

@@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
@@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});

View File

@@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectKey: firstProject.projectKey,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
@@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => {
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectKey: secondProject.projectKey,
projectDisplayName: secondProject.projectDisplayName,
});

View File

@@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 });
// Project A's row icon opens New Workspace with A preselected.
await openNewWorkspaceComposer(page, {
projectKey: projectA.projectId,
projectKey: projectA.projectKey,
projectDisplayName: projectA.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName);
@@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => {
// manualProjectKey is what the reused 'new' screen must reset when the next
// route-driven navigation targets a different project.
await page.getByTestId("new-workspace-project-picker-trigger").click();
const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`);
const optionC = page.getByTestId(
`new-workspace-project-picker-option-${projectC.projectKey}`,
);
await expect(optionC).toBeVisible({ timeout: 30_000 });
await optionC.click();
await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName);
@@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => {
// because the stale manual choice (C) was reset on the route change. If the
// reset were missing, the trigger would still read C.
await openNewWorkspaceComposer(page, {
projectKey: projectB.projectId,
projectKey: projectB.projectKey,
projectDisplayName: projectB.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName);
@@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => {
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Open New Workspace for the non-git project via the global button, then
// select it in the picker (the per-row icon would preselect it too).
@@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => {
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const nonGitOption = page.getByTestId(
`new-workspace-project-picker-option-${nonGitProject.projectId}`,
`new-workspace-project-picker-option-${nonGitProject.projectKey}`,
);
await expect(nonGitOption).toBeVisible({ timeout: 30_000 });
await nonGitOption.click();
@@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => {
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${gitProject.projectId}`,
`new-workspace-project-picker-option-${gitProject.projectKey}`,
);
await expect(gitOption).toBeVisible({ timeout: 30_000 });
await gitOption.click();

View File

@@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => {
// so its handler is still registered when we cycle here.
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});

View File

@@ -0,0 +1,52 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import { submitMessage } from "./helpers/composer";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
test.describe("Codex out-of-band commands", () => {
test.setTimeout(300_000);
test("settles the submitted row when a goal command completes without a turn", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("settles the submitted row when an older daemon omits submission disposition", async ({
page,
}) => {
const gate = await installDaemonWebSocketGate(page);
gate.setMessageSubmissionDispositionStripped(true);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
gate.restore();
await cleanupRewindFlow({ handle, cwd });
}
});
});

View File

@@ -1,8 +1,10 @@
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { waitForConnectedHost } from "./helpers/hosts";
import { expectOpenedProject } from "./helpers/project-picker-ui";
import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow";
import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
@@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({
dialogOpenResult: projectPickerFixture.projectPath,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();
@@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({
dialogOpenResult: null,
});
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await page.getByTestId("sidebar-add-project").click();
await openAddProjectFlow(page);
const browse = page.getByRole("button", { name: /^Browse/ });
await expect(browse).toBeVisible({ timeout: 30_000 });
await browse.click();

View File

@@ -1,6 +1,7 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
composerLocator,
expectComposerDraft,
@@ -23,7 +24,152 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
await expect(userMessage(page, text)).toBeVisible();
}
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
}
}
return false;
}, prompt),
)
.toBe(true);
await page.evaluate((messageText) => {
const key = "@paseo:replica-cache";
const raw = localStorage.getItem(key);
if (!raw) throw new Error("Replica cache was not persisted");
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
}
async function waitForCurrentSubmissionExcludedFromCache(
page: Page,
prompt: string,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise<void> {
const prompt = "Keep this cached submission pending.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-current-cache-e2e-",
title: "Current cache submission e2e",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
gate.holdNextClientRequest("send_agent_message_request");
await submitMessage(page, prompt);
await gate.waitForHeldClientRequest();
await waitForCurrentSubmissionExcludedFromCache(page, prompt);
await gate.drop();
await page.reload();
await expect(userMessage(page, prompt)).toHaveCount(0);
} finally {
gate.restore();
await session.cleanup();
}
}
test.describe("Rewind sheet", () => {
test("does not restore a local-only submission from the display cache", async ({ page }) => {
await expectPendingSubmissionNotRestoredAfterReload(page);
});
test("does not invent rewind identity for an ID-less cached message", async ({ page }) => {
const prompt = "Restore this rewind identity from the legacy cache.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-cache-upgrade-e2e-",
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
let heldTimelineRequest = false;
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
await restoredMessage.hover();
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore();
await session.cleanup();
}
});
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";

View File

@@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => {
// Both projects are expandable parents — the non-git one is NOT flattened
// into a bare workspace link.
await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 });
await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 });
// Each parent shows both of its workspace rows underneath.
await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 });
@@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => {
// Both projects show a per-row New workspace icon (revealed on hover): the
// git project can branch off a worktree, and the non-git project can add
// another workspace because the host supports workspaceMultiplicity.
await projectRow(page, gitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({
await projectRow(page, gitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({
timeout: 30_000,
});
await projectRow(page, nonGitProject.projectId).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({
await projectRow(page, nonGitProject.projectKey).hover();
await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({
timeout: 30_000,
});

View File

@@ -0,0 +1,340 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test as base, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
addConnectedHostAndReload,
addConnectedHostsAndReload,
waitForConnectedHost,
} from "./helpers/hosts";
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { createTempGitRepo, type TempDirectory } from "./helpers/workspace";
const SECONDARY_HOST_ID = "project-grouping-secondary";
const SECONDARY_HOST_LABEL = "Secondary Host";
const LEGACY_PRIMARY_HOST_ID = "project-grouping-legacy-primary";
const LEGACY_SECONDARY_HOST_ID = "project-grouping-legacy-secondary";
const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git";
const SHARED_LEGACY_PROJECT_ID = "remote:github.com/paseo-e2e/grouped-project";
interface HostProject {
serverId: string;
projectId: string;
workspaceId: string;
}
interface CrossHostProject {
secondaryHost: IsolatedHostDaemon;
primary: HostProject;
secondary: HostProject;
}
interface ReconciledCrossHostProject extends CrossHostProject {
primaryHost: IsolatedHostDaemon;
}
async function createProject(
client: SeedDaemonClient,
repo: TempDirectory,
serverId: string,
): Promise<HostProject> {
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create project on ${serverId}`);
}
return {
serverId,
projectId: created.workspace.projectId,
workspaceId: created.workspace.id,
};
}
async function expectOneProjectContainsBothWorkspaces(
page: Page,
fixture: CrossHostProject,
): Promise<void> {
const primaryWorkspace = page.getByTestId(
`sidebar-workspace-row-${fixture.primary.serverId}:${fixture.primary.workspaceId}`,
);
const secondaryWorkspace = page.getByTestId(
`sidebar-workspace-row-${fixture.secondary.serverId}:${fixture.secondary.workspaceId}`,
);
await expect(primaryWorkspace).toBeVisible({ timeout: 30_000 });
await expect(secondaryWorkspace).toBeVisible({ timeout: 30_000 });
await expect(page.locator('[data-testid^="sidebar-project-row-"]')).toHaveCount(1);
}
async function openGroupedProjectSettings(page: Page): Promise<void> {
const projectRow = page.locator('[data-testid^="sidebar-project-row-"]').first();
const testId = await projectRow.getAttribute("data-testid");
if (!testId) throw new Error("Grouped project row has no test ID");
const projectKey = testId.slice("sidebar-project-row-".length);
await projectRow.hover();
await page.getByTestId(`sidebar-project-kebab-${projectKey}`).click();
await page.getByTestId(`sidebar-project-menu-open-settings-${projectKey}`).click();
await expect(page.getByTestId("host-picker")).toBeVisible({ timeout: 30_000 });
}
async function readPersistedProjectKey(host: IsolatedHostDaemon): Promise<unknown> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
return projects[0]?.projectKey;
}
async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise<void> {
const projectsPath = path.join(host.paseoHome, "projects", "projects.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) {
delete project.projectKey;
}
await writeFile(projectsPath, JSON.stringify(projects));
const persisted = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
expect(persisted.every((project) => !("projectKey" in project))).toBe(true);
}
async function rewritePersistedProjectId(
host: IsolatedHostDaemon,
previousProjectId: string,
nextProjectId: string,
): Promise<void> {
const projectsDirectory = path.join(host.paseoHome, "projects");
const projectsPath = path.join(projectsDirectory, "projects.json");
const workspacesPath = path.join(projectsDirectory, "workspaces.json");
const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array<
Record<string, unknown>
>;
const workspaces = JSON.parse(await readFile(workspacesPath, "utf8")) as Array<
Record<string, unknown>
>;
for (const project of projects) {
if (project.projectId === previousProjectId) project.projectId = nextProjectId;
}
for (const workspace of workspaces) {
if (workspace.projectId === previousProjectId) workspace.projectId = nextProjectId;
}
await Promise.all([
writeFile(projectsPath, JSON.stringify(projects)),
writeFile(workspacesPath, JSON.stringify(workspaces)),
]);
}
async function createReconciliationFixture(options?: { sharedLegacyProjectId?: string }): Promise<{
fixture: ReconciledCrossHostProject;
cleanup: () => Promise<void>;
}> {
const primaryHost = await startIsolatedHostDaemon(LEGACY_PRIMARY_HOST_ID);
const secondaryHost = await startIsolatedHostDaemon(LEGACY_SECONDARY_HOST_ID);
const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient({ port: primaryHost.port });
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
try {
let primary = await createProject(primaryClient, primaryRepo, primaryHost.serverId);
let secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await primaryClient.close();
await secondaryClient.close();
await removePersistedProjectKeys(primaryHost);
await removePersistedProjectKeys(secondaryHost);
if (options?.sharedLegacyProjectId) {
await Promise.all([
rewritePersistedProjectId(primaryHost, primary.projectId, options.sharedLegacyProjectId),
rewritePersistedProjectId(
secondaryHost,
secondary.projectId,
options.sharedLegacyProjectId,
),
]);
primary = { ...primary, projectId: options.sharedLegacyProjectId };
secondary = { ...secondary, projectId: options.sharedLegacyProjectId };
}
await Promise.all([primaryHost.restart(), secondaryHost.restart()]);
return {
fixture: { primaryHost, secondaryHost, primary, secondary },
cleanup: async () => {
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
},
};
} catch (error) {
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryHost.close().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
throw error;
}
}
const test = base.extend<{
crossHostProject: CrossHostProject;
reconciledCrossHostProject: ReconciledCrossHostProject;
sharedLegacyIdCrossHostProject: ReconciledCrossHostProject;
}>({
crossHostProject: async ({ page: _page }, provide) => {
const secondaryHost = await startIsolatedHostDaemon(SECONDARY_HOST_ID);
const primaryRepo = await createTempGitRepo("grouped-primary-", {
originUrl: SHARED_REMOTE_URL,
});
const secondaryRepo = await createTempGitRepo("grouped-secondary-", {
originUrl: SHARED_REMOTE_URL,
});
const primaryClient = await connectSeedClient();
const secondaryClient = await connectSeedClient({ port: secondaryHost.port });
let primary: HostProject | null = null;
let secondary: HostProject | null = null;
try {
primary = await createProject(primaryClient, primaryRepo, getServerId());
secondary = await createProject(secondaryClient, secondaryRepo, secondaryHost.serverId);
await provide({ secondaryHost, primary, secondary });
} finally {
if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined);
if (secondary)
await secondaryClient.removeProject(secondary.projectId).catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await secondaryClient.close().catch(() => undefined);
await primaryRepo.cleanup().catch(() => undefined);
await secondaryRepo.cleanup().catch(() => undefined);
await secondaryHost.close().catch(() => undefined);
}
},
reconciledCrossHostProject: async ({ page: _page }, provide) => {
const resource = await createReconciliationFixture();
try {
await provide(resource.fixture);
} finally {
await resource.cleanup();
}
},
sharedLegacyIdCrossHostProject: async ({ page: _page }, provide) => {
const resource = await createReconciliationFixture({
sharedLegacyProjectId: SHARED_LEGACY_PROJECT_ID,
});
try {
await provide(resource.fixture);
} finally {
await resource.cleanup();
}
},
});
test.describe("Sidebar project grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("groups projects with the same Git remote across hosts", async ({
page,
crossHostProject,
}) => {
expect(crossHostProject.primary.projectId).not.toBe(crossHostProject.secondary.projectId);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: crossHostProject.secondaryHost.serverId,
label: SECONDARY_HOST_LABEL,
port: crossHostProject.secondaryHost.port,
});
await waitForConnectedHost(page, {
serverId: crossHostProject.secondaryHost.serverId,
endpoint: `localhost:${crossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, crossHostProject);
});
test("groups persisted projects missing project keys after app boot", async ({
page,
reconciledCrossHostProject,
}) => {
expect(reconciledCrossHostProject.primary.projectId).not.toBe(
reconciledCrossHostProject.secondary.projectId,
);
await gotoAppShell(page);
await addConnectedHostsAndReload(page, [
{
serverId: reconciledCrossHostProject.primaryHost.serverId,
label: "Legacy Primary Host",
port: reconciledCrossHostProject.primaryHost.port,
},
{
serverId: reconciledCrossHostProject.secondaryHost.serverId,
label: "Legacy Secondary Host",
port: reconciledCrossHostProject.secondaryHost.port,
},
]);
await waitForConnectedHost(page, {
serverId: reconciledCrossHostProject.primaryHost.serverId,
endpoint: `localhost:${reconciledCrossHostProject.primaryHost.port}`,
});
await waitForConnectedHost(page, {
serverId: reconciledCrossHostProject.secondaryHost.serverId,
endpoint: `localhost:${reconciledCrossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, reconciledCrossHostProject);
await expect
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.primaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
await expect
.poll(() => readPersistedProjectKey(reconciledCrossHostProject.secondaryHost))
.toBe("remote:github.com/paseo-e2e/grouped-project");
});
test("resets a rename draft when switching grouped-project hosts", async ({
page,
sharedLegacyIdCrossHostProject,
}) => {
expect(sharedLegacyIdCrossHostProject.primary.projectId).toBe(
sharedLegacyIdCrossHostProject.secondary.projectId,
);
await gotoAppShell(page);
await addConnectedHostsAndReload(page, [
{
serverId: sharedLegacyIdCrossHostProject.primaryHost.serverId,
label: "Legacy Primary Host",
port: sharedLegacyIdCrossHostProject.primaryHost.port,
},
{
serverId: sharedLegacyIdCrossHostProject.secondaryHost.serverId,
label: "Legacy Secondary Host",
port: sharedLegacyIdCrossHostProject.secondaryHost.port,
},
]);
await waitForConnectedHost(page, {
serverId: sharedLegacyIdCrossHostProject.primaryHost.serverId,
endpoint: `localhost:${sharedLegacyIdCrossHostProject.primaryHost.port}`,
});
await waitForConnectedHost(page, {
serverId: sharedLegacyIdCrossHostProject.secondaryHost.serverId,
endpoint: `localhost:${sharedLegacyIdCrossHostProject.secondaryHost.port}`,
});
await expectOneProjectContainsBothWorkspaces(page, sharedLegacyIdCrossHostProject);
await openGroupedProjectSettings(page);
await page.getByTestId("project-name-edit-button").click();
const nameInput = page.getByTestId("project-name-input");
await nameInput.fill("Draft from the first host");
await page.getByTestId("host-picker").click();
await page
.getByTestId(`host-picker-item-${sharedLegacyIdCrossHostProject.secondary.serverId}`)
.click();
await expect(nameInput).not.toBeVisible();
await page.getByTestId("project-name-edit-button").click();
await expect(page.getByTestId("project-name-input")).toHaveValue("");
});
});

View File

@@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => {
}
await gotoAppShell(page);
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click();
await waitForSidebarWorkspace(page, lastWorkspaceId);
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");

View File

@@ -32,6 +32,8 @@ interface RestartDaemonClient {
name: string;
status: string;
workspaceDirectory: string;
projectKey?: string;
project?: { projectKey?: string };
}>;
}>;
fetchAgents(options?: { scope?: "active" }): Promise<{
@@ -461,9 +463,18 @@ test.describe("Workspace model restart regressions", () => {
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
const reconciledWorkspace = (await client.fetchWorkspaces()).entries.find(
(workspace) => workspace.id === seeded.workspaceA,
);
const reconciledProjectKey =
reconciledWorkspace?.projectKey ?? reconciledWorkspace?.project?.projectKey;
if (!reconciledProjectKey) {
throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`);
}
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: seeded.projectId,
projectKey: reconciledProjectKey,
projectDisplayName: seeded.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);

View File

@@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => {
try {
const project = {
projectKey: seeded.projectId,
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
};
@@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => {
await waitForSidebarHydration(page);
// Model B: a non-git project is an expandable parent like any other, with
// its single workspace already rendered as its own row underneath.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({
@@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => {
// Both the original and the new workspace render as distinct rows under
// the same expandable parent.
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({
await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({

View File

@@ -1,139 +0,0 @@
import { describe, expect, it } from "vitest";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
type ScrollKeyboardDismissEvent,
type ScrollKeyboardDismissGesture,
} from "./model";
interface Point {
ts: number;
y: number;
nativeTs?: number | null;
}
function event(point: Point): ScrollKeyboardDismissEvent {
return {
timeStamp: point.ts,
nativeEvent: {
contentOffset: { y: point.y },
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
},
};
}
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
return points.reduce(
(gesture, point) => recordScroll(gesture, event(point)),
beginDrag(event(start)),
);
}
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
}
describe("scroll keyboard dismissal", () => {
it("keeps the keyboard up for a slow read-scroll", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1160, y: 32 },
),
).toBe(false);
});
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
true,
);
});
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
false,
);
});
it("keeps a fast-but-decelerating drag below the release threshold", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1300, y: 600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1080, y: 240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
expect(
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
],
{ ts: 5003, nativeTs: 1600, y: 120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
ts: 1080,
nativeTs: null,
y: 240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});

View File

@@ -1,124 +0,0 @@
/**
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
const RELEASE_SAMPLE_MIN_MS = 30;
/**
* The subset of a native scroll event used by the classifier. React Native's
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
*/
export interface ScrollKeyboardDismissEvent {
timeStamp: number;
nativeEvent: {
contentOffset: { y: number };
timestamp?: number;
};
}
interface DragSamples {
startTs: number;
startY: number;
sampleTs: number;
sampleY: number;
previousSampleTs: number;
previousSampleY: number;
}
export type ScrollKeyboardDismissGesture =
| { phase: "idle" }
| { phase: "dragging"; samples: DragSamples };
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
phase: "idle",
});
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
const nativeTimestamp = event.nativeEvent.timestamp;
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
return nativeTimestamp;
}
return event.timeStamp;
}
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
const timestamp = resolveEventTimeMs(event);
const offsetY = event.nativeEvent.contentOffset.y;
return {
phase: "dragging",
samples: {
startTs: timestamp,
startY: offsetY,
sampleTs: timestamp,
sampleY: offsetY,
previousSampleTs: 0,
previousSampleY: 0,
},
};
}
export function recordScroll(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): ScrollKeyboardDismissGesture {
if (gesture.phase === "idle") {
return gesture;
}
const timestamp = resolveEventTimeMs(event);
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
return gesture;
}
const { samples } = gesture;
return {
phase: "dragging",
samples: {
startTs: samples.startTs,
startY: samples.startY,
sampleTs: timestamp,
sampleY: event.nativeEvent.contentOffset.y,
previousSampleTs: samples.sampleTs,
previousSampleY: samples.sampleY,
},
};
}
export function releaseDrag(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
if (gesture.phase === "idle") {
return { gesture, shouldDismiss: false };
}
const releaseTs = resolveEventTimeMs(event);
const releaseY = event.nativeEvent.contentOffset.y;
const { samples } = gesture;
// The release event carries the gesture's true endpoint. The final onScroll
// event can still be stale when a short flick lands.
let spanStartTs = samples.startTs;
let spanStartY = samples.startY;
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
spanStartTs = samples.sampleTs;
spanStartY = samples.sampleY;
} else if (samples.previousSampleTs > 0) {
spanStartTs = samples.previousSampleTs;
spanStartY = samples.previousSampleY;
}
const spanDurationMs = releaseTs - spanStartTs;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}

View File

@@ -1,57 +0,0 @@
import { useRef } from "react";
import {
Keyboard,
TextInput,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
} from "./model";
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the FlatList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
const { shift } = useKeyboardShift();
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
gestureRef.current = beginDrag(event);
});
const onScroll = useStableEvent((event: ScrollEvent) => {
gestureRef.current = recordScroll(gestureRef.current, event);
});
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
const release = releaseDrag(gestureRef.current, event);
gestureRef.current = release.gesture;
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
// second calculation on JS, this prevents a hardware keyboard's focused
// composer from being blurred when no software keyboard occupies space.
if (!release.shouldDismiss || shift.value <= 0) {
return;
}
// Keep blur and dismiss paired: this exact sequence was validated on a
// physical Android device to clear both input focus and the IME inset.
const focusedInput = TextInput.State.currentlyFocusedInput();
if (focusedInput) {
TextInput.State.blurTextInput(focusedInput);
}
Keyboard.dismiss();
});
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
}

View File

@@ -24,7 +24,6 @@ import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
@@ -53,6 +52,7 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -110,7 +110,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
@@ -336,8 +335,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
scrollKeyboardDismiss.onScroll(event);
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
@@ -368,7 +365,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const handleScrollBeginDrag = useStableEvent(() => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
@@ -376,7 +373,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
@@ -385,8 +381,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;

View File

@@ -41,6 +41,7 @@ import {
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
@@ -239,6 +240,7 @@ export interface AgentStreamViewProps {
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
pendingMessageSubmissions?: readonly PendingMessageSubmission[];
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
@@ -265,6 +267,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function buildChatHistoryAttachment(input: {
@@ -327,6 +330,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
@@ -341,6 +345,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
[pendingMessageSubmissions],
);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
() =>
@@ -656,7 +664,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.id}
messageId={item.messageId}
message={item.text}
images={item.images}
attachments={item.attachments}
@@ -665,10 +673,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
isPending={
item.clientMessageId !== undefined &&
pendingClientMessageIds.has(item.clientMessageId)
}
/>
);
},
[context.capabilities, agentId, client, resolvedServerId],
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -878,7 +890,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const showRunningTurnFooter =
context.status === "running" || pendingMessageSubmissions.length > 0;
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -1157,6 +1170,9 @@ function agentStreamViewPropsEqual(
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) {
reasons.push("pendingMessageSubmissions");
}
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
) {

View File

@@ -1,11 +1,11 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { normalizeProjectSettingsRouteKey } from "@/utils/host-routes";
export default function SettingsProjectDetailRoute() {
const params = useLocalSearchParams<{ projectKey?: string | string[] }>();
const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey;
const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : "";
const projectKey = normalizeProjectSettingsRouteKey(params.projectKey);
const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]);
return <SettingsScreen view={view} />;

View File

@@ -132,6 +132,7 @@ interface UserMessageProps {
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
isPending?: boolean;
disableOuterSpacing?: boolean;
}
@@ -430,6 +431,7 @@ export const UserMessage = memo(function UserMessage({
client,
isFirstInGroup = true,
isLastInGroup = true,
isPending = false,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
@@ -441,7 +443,7 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -494,7 +496,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle} testID="user-message">
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -538,9 +540,15 @@ export const UserMessage = memo(function UserMessage({
) : null}
</View>
{hasText ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
testID="user-message-trailing-row"
>
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
{formattedTimestamp}
</Text>
{capabilities && messageId ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}

View File

@@ -7,7 +7,6 @@ import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
@@ -36,13 +35,6 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
if (input.serverId) {
const session = useSessionStore.getState().sessions[input.serverId];
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
});
}
const cursor = input.serverId
? useSessionStore
.getState()

View File

@@ -83,6 +83,7 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId,
projectKey: input.projectId,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,

View File

@@ -48,7 +48,11 @@ import {
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -58,6 +62,7 @@ import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sec
import { useHostFeatureMap } from "@/runtime/host-features";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { resolveProjectSettingsRouteKey } from "@/projects/project-settings-target";
import {
buildNewWorkspaceRoute,
buildProjectSettingsRoute,
@@ -115,6 +120,7 @@ import type { PrHint } from "@/git/use-pr-status-query";
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
type SidebarProjectHostTarget,
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -132,6 +138,7 @@ import {
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -258,6 +265,7 @@ interface ProjectHeaderRowProps {
chevron: "expand" | "collapse" | null;
onPress: () => void;
worktreeTarget: SidebarProjectHostTarget | null;
onlineServerIds: ReadonlySet<string>;
isProjectActive?: boolean;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
@@ -487,6 +495,7 @@ function ProjectRowTrailingActions({
project,
displayName,
worktreeTarget,
onlineServerIds,
isHovered,
isMobileBreakpoint,
isProjectActive,
@@ -497,6 +506,7 @@ function ProjectRowTrailingActions({
project: SidebarProjectEntry;
displayName: string;
worktreeTarget: SidebarProjectHostTarget | null;
onlineServerIds: ReadonlySet<string>;
isHovered: boolean;
isMobileBreakpoint: boolean;
isProjectActive: boolean;
@@ -505,6 +515,8 @@ function ProjectRowTrailingActions({
removeProjectStatus: "idle" | "pending" | "success";
}) {
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
const localDaemonServerId = useLocalDaemonServerId();
const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId);
return (
<View style={styles.projectTrailingActions}>
{worktreeTarget ? (
@@ -523,7 +535,14 @@ function ProjectRowTrailingActions({
>
<ProjectKebabMenu
projectKey={project.projectKey}
projectPath={project.iconWorkingDir}
projectSettingsKey={resolveProjectSettingsRouteKey({
...project,
hosts: project.hosts.map((host) => ({
...host,
isOnline: onlineServerIds.has(host.serverId),
})),
})}
projectPath={localProjectPath}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
/>
@@ -550,11 +569,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
function ProjectKebabMenu({
projectKey,
projectSettingsKey,
projectPath,
onRemoveProject,
removeProjectStatus,
}: {
projectKey: string;
projectSettingsKey: string;
projectPath: string;
onRemoveProject: () => void;
removeProjectStatus: "idle" | "pending" | "success";
@@ -562,10 +583,10 @@ function ProjectKebabMenu({
const { t } = useTranslation();
const toast = useToast();
const handleOpenProjectSettings = useCallback(() => {
if (projectKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
const canOpenProjectSettings = projectKey.trim().length > 0;
if (projectSettingsKey.trim().length === 0) return;
router.navigate(buildProjectSettingsRoute(projectSettingsKey));
}, [projectSettingsKey]);
const canOpenProjectSettings = projectSettingsKey.trim().length > 0;
// Desktop-only: open a second window that lands on this project via the same
// open-project flow as a CLI launch. The project stays visible here too — no
// ownership, no move.
@@ -908,7 +929,7 @@ function NewWorkspaceGhostRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId ?? project.projectKey,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
@@ -963,6 +984,7 @@ function ProjectHeaderRow({
chevron,
onPress,
worktreeTarget,
onlineServerIds,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated: _onWorktreeCreated,
@@ -988,7 +1010,7 @@ function ProjectHeaderRow({
serverId: worktreeTarget.serverId,
sourceDirectory: worktreeTarget.iconWorkingDir,
displayName,
projectId: project.projectKey,
projectId: worktreeTarget.projectId ?? project.projectKey,
}) as Href,
);
}, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]);
@@ -1048,6 +1070,7 @@ function ProjectHeaderRow({
project={project}
displayName={displayName}
worktreeTarget={worktreeTarget}
onlineServerIds={onlineServerIds}
isHovered={isHovered}
isMobileBreakpoint={isMobileBreakpoint}
isProjectActive={isProjectActive}
@@ -1602,6 +1625,7 @@ function ProjectBlock({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
@@ -1627,6 +1651,7 @@ function ProjectBlock({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
onlineServerIds: ReadonlySet<string>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -1642,8 +1667,9 @@ function ProjectBlock({
project,
collapsed,
supportsMultiplicityByServerId,
onlineServerIds,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, onlineServerIds, project, supportsMultiplicityByServerId],
);
const active = isProjectSelectedByRoute({
@@ -1755,7 +1781,6 @@ function ProjectBlock({
}
void removeProjectFromHosts({
projectKey: project.projectKey,
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
})
@@ -1836,6 +1861,7 @@ function ProjectBlock({
worktreeTarget={
rowModel.trailingAction.kind === "new_workspace" ? rowModel.trailingAction.target : null
}
onlineServerIds={onlineServerIds}
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
@@ -1869,6 +1895,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.hostLabelByServerId === next.hostLabelByServerId &&
previous.showHostLabels === next.showHostLabels &&
previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId &&
previous.onlineServerIds === next.onlineServerIds &&
previous.supportsPinningByServerId === next.supportsPinningByServerId &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.parentGestureRef === next.parentGestureRef &&
@@ -1942,6 +1969,14 @@ export function SidebarWorkspaceList({
}, [hosts]);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const connectionStatusByServerId = useHostRuntimeConnectionStatuses(serverIds);
const onlineServerIds = useMemo(
() =>
new Set(
serverIds.filter((serverId) => connectionStatusByServerId.get(serverId) === "online"),
),
[connectionStatusByServerId, serverIds],
);
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]);
@@ -1978,6 +2013,7 @@ export function SidebarWorkspaceList({
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
onlineServerIds={onlineServerIds}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -2049,6 +2085,7 @@ function ProjectModeList({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
}: Omit<
@@ -2059,6 +2096,7 @@ function ProjectModeList({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
onlineServerIds: ReadonlySet<string>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
@@ -2271,6 +2309,7 @@ function ProjectModeList({
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
onlineServerIds={onlineServerIds}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
@@ -2284,6 +2323,7 @@ function ProjectModeList({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
onlineServerIds,
supportsPinningByServerId,
onToggleWorkspacePin,
onWorkspacePress,

View File

@@ -8,7 +8,7 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";

View File

@@ -11,32 +11,35 @@ function gitWorkspace(
): WorktreeSetupWorkspaceInput {
return {
projectId: "project-1",
projectKey: "project-1",
projectKind: "git",
projectRootPath: "/repo/project-1",
project: { checkout: { mainRepoRoot: "/repo/main-project-1" } },
...overrides,
};
}
describe("selectActiveGitWorkspaceProject", () => {
it("selects the active git workspace project from checkout metadata", () => {
it("selects the exact active git workspace project root", () => {
expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({
serverId: "server-1",
projectId: "project-1",
projectKey: "project-1",
repoRoot: "/repo/main-project-1",
repoRoot: "/repo/project-1",
});
});
it("falls back to the workspace project root when checkout metadata has no main root", () => {
it("uses the persisted project key for the settings route", () => {
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }),
gitWorkspace({
projectId: "prj_local",
projectKey: "remote:github.com/acme/project",
}),
),
).toEqual({
serverId: "server-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
).toMatchObject({
projectId: "prj_local",
projectKey: "remote:github.com/acme/project",
});
});
@@ -48,10 +51,7 @@ describe("selectActiveGitWorkspaceProject", () => {
null,
);
expect(
selectActiveGitWorkspaceProject(
"server-1",
gitWorkspace({ projectRootPath: " ", project: null }),
),
selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectRootPath: " " })),
).toBe(null);
});
});
@@ -85,19 +85,61 @@ describe("buildWorktreeSetupCalloutPolicy", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectId: "project-1",
projectKey: "project-1",
repoRoot: "/repo/project-1",
}),
).toEqual({
id: "worktree-setup-missing:project-1",
dismissalKey: "worktree-setup-missing:project-1",
id: "worktree-setup-missing:host:8:server-1:project:9:project-1",
dismissalKey: "worktree-setup-missing:host:8:server-1:project:9:project-1",
priority: 100,
title: "Set up worktree scripts",
description:
"Add setup commands so new worktrees can install dependencies and prepare themselves automatically.",
actionLabel: "Open project settings",
projectSettingsRoute: "/settings/projects/project-1",
projectSettingsRoute: "/settings/projects/host%3A8%3Aserver-1%3Aproject%3A9%3Aproject-1",
testID: "worktree-setup-callout-project-1",
});
});
it("keeps the action route stable when the structural project key changes", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-1",
projectId: "prj_local",
projectKey: "remote:github.com/acme/project",
repoRoot: "/repo/project",
}).projectSettingsRoute,
).toBe("/settings/projects/host%3A8%3Aserver-1%3Aproject%3A9%3Aprj_local");
});
it("keeps dismissals scoped to the host placement", () => {
const hostA = buildWorktreeSetupCalloutPolicy({
serverId: "host-a",
projectId: "project-a",
projectKey: "remote:github.com/acme/project",
repoRoot: "/host-a/project",
});
const hostB = buildWorktreeSetupCalloutPolicy({
serverId: "host-b",
projectId: "project-b",
projectKey: "remote:github.com/acme/project",
repoRoot: "/host-b/project",
});
expect(hostA.dismissalKey).not.toBe(hostB.dismissalKey);
});
it("scopes retained legacy project IDs to the active host", () => {
expect(
buildWorktreeSetupCalloutPolicy({
serverId: "server-2",
projectId: "remote:github.com/acme/project",
projectKey: "remote:github.com/acme/project-fork",
repoRoot: "/repo/project",
}).projectSettingsRoute,
).toBe(
"/settings/projects/host%3A8%3Aserver-2%3Aproject%3A30%3Aremote%3Agithub.com%2Facme%2Fproject",
);
});
});

View File

@@ -1,20 +1,19 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { resolveProjectKey } from "@/projects/project-key";
import { resolveHostProjectSettingsRouteKey } from "@/projects/project-settings-target";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
export interface WorktreeSetupWorkspaceInput {
projectId: string;
projectKey?: string | null;
projectKind: string;
projectRootPath: string;
project?: {
checkout?: {
mainRepoRoot?: string | null;
} | null;
} | null;
}
export interface ActiveGitWorkspaceProject {
serverId: string;
projectId: string;
projectKey: string;
repoRoot: string;
}
@@ -43,13 +42,18 @@ export function selectActiveGitWorkspaceProject(
return null;
}
const projectKey = workspace.projectId.trim();
const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim();
if (!projectKey || !repoRoot) {
const projectId = workspace.projectId.trim();
const projectKey = resolveProjectKey({
serverId,
projectId,
projectKey: workspace.projectKey,
});
const repoRoot = workspace.projectRootPath.trim();
if (!projectId || !repoRoot) {
return null;
}
return { serverId, projectKey, repoRoot };
return { serverId, projectId, projectKey, repoRoot };
}
export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) {
@@ -59,7 +63,11 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu
export function buildWorktreeSetupCalloutPolicy(
project: ActiveGitWorkspaceProject,
): WorktreeSetupCalloutPolicy {
const calloutKey = `worktree-setup-missing:${project.projectKey}`;
const projectSettingsKey = resolveHostProjectSettingsRouteKey({
serverId: project.serverId,
projectId: project.projectId,
});
const calloutKey = `worktree-setup-missing:${projectSettingsKey ?? project.projectKey}`;
return {
id: calloutKey,
@@ -68,7 +76,7 @@ export function buildWorktreeSetupCalloutPolicy(
title: i18n.t("sidebar.worktreeSetup.title"),
description: i18n.t("sidebar.worktreeSetup.description"),
actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"),
projectSettingsRoute: buildProjectSettingsRoute(project.projectKey),
projectSettingsRoute: buildProjectSettingsRoute(projectSettingsKey ?? project.projectKey),
testID: `worktree-setup-callout-${project.projectKey}`,
};
}

View File

@@ -3,6 +3,7 @@ import { useRouter } from "expo-router";
import { useEffect, useMemo } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useProjects } from "@/hooks/use-projects";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
@@ -14,11 +15,25 @@ import {
export function WorktreeSetupCalloutSource() {
const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
const selectedWorkspaceProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,
(workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace),
);
const { projects } = useProjects();
const activeProject = useMemo(() => {
if (!selectedWorkspaceProject) return null;
const structuralProject = projects.find((project) =>
project.hosts.some(
(host) =>
host.serverId === selectedWorkspaceProject.serverId &&
host.projectId === selectedWorkspaceProject.projectId,
),
);
return structuralProject
? { ...selectedWorkspaceProject, projectKey: structuralProject.projectKey }
: selectedWorkspaceProject;
}, [projects, selectedWorkspaceProject]);
const client = useHostRuntimeClient(activeProject?.serverId ?? "");
const callouts = useSidebarCallouts();
const router = useRouter();

View File

@@ -6,7 +6,17 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { StreamItem } from "@/types/stream";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
type StreamItem,
} from "@/types/stream";
import {
acceptMessageSubmission,
beginMessageSubmission,
rejectMessageSubmission,
type MessageSubmissionRecord,
} from "@/composer/submission/model";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
@@ -20,7 +30,7 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter,
type MessageSubmissionWriter,
type AttachmentPersister,
type ComposerCancelClient,
type ComposerSendClient,
@@ -168,14 +178,16 @@ interface FakeSendCall {
}
function createFakeSendClient(
options: { rejection?: Error } = {},
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {},
): ComposerSendClient & { calls: FakeSendCall[] } {
const calls: FakeSendCall[] = [];
return {
calls,
sendAgentMessage: async (agentId, text, opts) => {
calls.push({ agentId, text, options: opts });
const call = { agentId, text, options: opts };
calls.push(call);
if (options.rejection) {
options.beforeRejection?.(call);
throw options.rejection;
}
},
@@ -183,7 +195,7 @@ function createFakeSendClient(
};
}
interface FakeStream extends AgentStreamWriter {
interface FakeStream extends MessageSubmissionWriter {
head: Map<string, StreamItem[]>;
tail: Map<string, StreamItem[]>;
}
@@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
getTail: (agentId) => fake.tail.get(agentId),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
begin: (agentId, message) => {
const current = readSubmission(fake, agentId);
const stream = appendSubmittedUserMessage({
tail: current.tail,
head: current.head,
message,
});
writeSubmission(fake, agentId, {
...stream,
submissions: beginMessageSubmission(current.submissions, {
clientMessageId: message.clientMessageId!,
submittedAt: message.timestamp,
}),
});
},
setTail: (updater) => {
fake.tail = updater(fake.tail);
accept: (agentId, clientMessageId) => {
const current = readSubmission(fake, agentId);
writeSubmission(fake, agentId, {
...current,
submissions: acceptMessageSubmission(current.submissions, clientMessageId, true, false),
});
},
reject: (agentId, clientMessageId) => {
const current = readSubmission(fake, agentId);
const result = rejectMessageSubmission(current.submissions, clientMessageId);
const stream =
result.outcome === "rejected"
? removeSubmittedUserMessage({
tail: current.tail,
head: current.head,
clientMessageId,
})
: current;
writeSubmission(fake, agentId, { ...stream, submissions: result.submissions });
return result.outcome;
},
};
return fake;
}
const submissionsByFakeStream = new WeakMap<FakeStream, Map<string, MessageSubmissionRecord[]>>();
interface FakeSubmissionState {
tail: StreamItem[];
head: StreamItem[];
submissions: MessageSubmissionRecord[];
}
function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState {
return {
tail: fake.tail.get(agentId) ?? [],
head: fake.head.get(agentId) ?? [],
submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [],
};
}
function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void {
fake.tail = new Map(fake.tail).set(agentId, state.tail);
fake.head = new Map(fake.head).set(agentId, state.head);
const submissions = submissionsByFakeStream.get(fake) ?? new Map();
submissions.set(agentId, state.submissions);
submissionsByFakeStream.set(fake, submissions);
}
function createFakeQueue(
initial: Map<string, QueuedComposerMessage[]> = new Map(),
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
@@ -337,7 +401,7 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("removes the optimistic prompt when the host rejects it", async () => {
it("removes the submitted prompt when the host rejects it", async () => {
const rejection = new Error("Host rejected prompt");
const client = createFakeSendClient({ rejection });
const stream = createFakeStream();
@@ -349,14 +413,54 @@ describe("dispatchComposerAgentMessage", () => {
text: "rejected prompt",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
}),
).rejects.toBe(rejection);
expect(stream.head.get("agent")).toBeUndefined();
expect(stream.head.get("agent")).toEqual([]);
expect(stream.tail.get("agent") ?? []).toEqual([]);
});
it("rolls back an already-running force send when its RPC fails", async () => {
const stream = createFakeStream();
const transportError = new Error("Force send failed while the prior turn was running");
const client = createFakeSendClient({ rejection: transportError });
await expect(
dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "force send",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
}),
).rejects.toBe(transportError);
expect(stream.tail.get("agent") ?? []).toEqual([]);
});
it("does not swallow a transport error when submission state is missing", async () => {
const transportError = new Error("Connection lost with unknown submission state");
const client = createFakeSendClient({ rejection: transportError });
const submission: MessageSubmissionWriter = {
begin: () => {},
accept: () => {},
reject: () => "unknown",
};
await expect(
dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "unknown state",
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
}),
).rejects.toBe(transportError);
});
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
@@ -371,7 +475,7 @@ describe("dispatchComposerAgentMessage", () => {
{ kind: "github_pr", item: prItem },
],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls).toHaveLength(1);
@@ -393,7 +497,7 @@ describe("dispatchComposerAgentMessage", () => {
},
]);
expect(stream.head.get("agent")).toBeUndefined();
expect(stream.head.get("agent")).toEqual([]);
const tail = stream.tail.get("agent");
expect(tail).toHaveLength(1);
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
@@ -402,7 +506,8 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.optimistic).toBe(true);
expect(userMessage.clientMessageId).toBe(call.options.messageId);
expect(userMessage.messageId).toBeUndefined();
});
it("can send legacy GitHub attachment payloads for old daemons", async () => {
@@ -416,7 +521,7 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [{ kind: "forge_change_request", item: prItem }],
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0].options.attachments).toEqual([
@@ -449,11 +554,11 @@ describe("dispatchComposerAgentMessage", () => {
text: "next message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(stream.head.get("agent")).toHaveLength(2);
expect(stream.tail.get("agent")).toBeUndefined();
expect(stream.tail.get("agent")).toEqual([]);
});
it("submits empty wire arrays when no attachments are provided", async () => {
@@ -466,7 +571,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "plain message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0]?.options).toMatchObject({
@@ -486,7 +591,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "review this",
attachments: [review],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -504,7 +609,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "inspect element",
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0]?.options.attachments).toEqual([

View File

@@ -12,13 +12,8 @@ import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
@@ -51,7 +46,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
) => Promise<void | { outOfBand?: boolean }>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
@@ -70,11 +65,10 @@ export interface ComposerCancelClient {
cancelAgent: (agentId: string) => Promise<void> | void;
}
export interface AgentStreamWriter {
getTail: (agentId: string) => StreamItem[] | undefined;
getHead: (agentId: string) => StreamItem[] | undefined;
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
export interface MessageSubmissionWriter {
begin: (agentId: string, message: UserMessageItem) => void;
accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void;
reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome;
}
export interface QueueWriter {
@@ -169,7 +163,7 @@ export interface DispatchComposerAgentMessageInput {
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
stream: AgentStreamWriter;
submission: MessageSubmissionWriter;
}
export async function dispatchComposerAgentMessage(
@@ -178,60 +172,30 @@ export async function dispatchComposerAgentMessage(
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
const clientMessageId = generateMessageId();
const userMessage = createUserMessage({
clientMessageId,
text: input.text,
timestamp: new Date(),
images: wirePayload.images,
attachments: wirePayload.attachments,
});
const rollbackOptimisticMessage = appendUserMessageToStream(
input.agentId,
userMessage,
input.stream,
);
input.submission.begin(input.agentId, userMessage);
try {
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
messageId: clientMessageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
} catch (error) {
rollbackOptimisticMessage();
const outcome = input.submission.reject(input.agentId, clientMessageId);
if (outcome === "accepted") return;
throw error;
}
}
function appendUserMessageToStream(
agentId: string,
userMessage: UserMessageItem,
stream: AgentStreamWriter,
): () => void {
const result = appendOptimisticUserMessageToStream({
tail: stream.getTail(agentId) ?? [],
head: stream.getHead(agentId) ?? [],
message: userMessage,
placement: "active-head",
});
const write = result.changedHead ? stream.setHead : stream.setTail;
const items = result.changedHead ? result.head : result.tail;
write((prev) => new Map(prev).set(agentId, items));
return () => {
write((prev) => {
const current = prev.get(agentId);
if (!current) return prev;
const nextItems = current.filter(
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
);
if (nextItems.length === current.length) return prev;
return new Map(prev).set(agentId, nextItems);
});
};
}
export interface QueueComposerMessageInput {
agentId: string;
text: string;

View File

@@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
it("renders a prepared new-workspace submission before continuing it", async () => {
const image: UserMessageImageAttachment = {
id: "image-1",
mimeType: "image/png",
@@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => {
expect(result.current.isSubmitting).toBe(true);
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
expect(result.current.optimisticStreamItems).toEqual([
expect(result.current.submittedStreamItems).toEqual([
{
kind: "user_message",
id: "msg-prepared",
clientMessageId: "msg-prepared",
text: "build this",
timestamp: attempt.timestamp,
optimistic: true,
images: [image],
attachments: [attachment],
},

View File

@@ -8,12 +8,13 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
buildOptimisticUserMessage,
createUserMessage,
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PendingMessageSubmission } from "@/composer/submission/model";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -133,7 +134,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
const submittedStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
@@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
return [
buildOptimisticUserMessage({
id: machine.attempt.clientMessageId,
createUserMessage({
clientMessageId: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
images: machine.attempt.images,
@@ -156,6 +157,15 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}),
];
}, [machine]);
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
if (machine.tag !== "creating") return [];
return [
{
clientMessageId: machine.attempt.clientMessageId,
submittedAt: machine.attempt.timestamp,
},
];
}, [machine]);
const draftAgent = useMemo<TDraftAgent | null>(() => {
if (machine.tag !== "creating") {
@@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
handoffCreatedAgentUserMessage(
pendingServerId,
createResult.agentId,
buildOptimisticUserMessage({
id: attempt.clientMessageId,
createUserMessage({
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
@@ -326,7 +336,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,

View File

@@ -479,7 +479,8 @@ export function WorkspaceDraftAgentTab({
const {
formErrorMessage,
isSubmitting,
optimisticStreamItems,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -642,7 +643,8 @@ export function WorkspaceDraftAgentTab({
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={optimisticStreamItems}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>

View File

@@ -64,7 +64,6 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
@@ -91,6 +90,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
@@ -1079,8 +1079,6 @@ export function Composer({
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
@@ -1283,12 +1281,6 @@ export function Composer({
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
};
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
@@ -1298,19 +1290,11 @@ export function Composer({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
stream,
submission: createMessageSubmissionWriter(serverId),
});
onAttentionPromptSend?.();
};
}, [
client,
onAttentionPromptSend,
serverId,
setAgentStreamTail,
setAgentStreamHead,
supportsForgeSearch,
t,
]);
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
acceptMessageSubmission,
beginMessageSubmission,
getActiveMessageSubmissions,
getSendingClientMessageIds,
observeAcceptedMessageSubmissionsRunning,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
} from "./model";
const submittedAt = new Date("2026-07-26T10:00:00.000Z");
describe("message submission transactions", () => {
it("tracks every in-flight submission independently", () => {
const first = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const both = beginMessageSubmission(first, {
clientMessageId: "client-2",
submittedAt: new Date(submittedAt.getTime() + 1),
});
expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([
"client-1",
"client-2",
]);
expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]);
});
it("removes only the RPC-accepted transaction", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
{ clientMessageId: "client-2", submittedAt },
);
expect(acceptMessageSubmission(both, "client-1", true, false)).toEqual([
{
clientMessageId: "client-2",
submittedAt,
rpcAccepted: false,
providerAcknowledged: false,
},
]);
});
it("bridges an accepted RPC until the correlated running state is observed", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
expect(getActiveMessageSubmissions(accepted)).toHaveLength(1);
expect(accepted[0].rpcAccepted).toBe(true);
expect(observeAcceptedMessageSubmissionsRunning(accepted)).toEqual([]);
});
it("settles an accepted RPC when provider acknowledgement arrives after running was missed", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
});
it("settles an explicitly out-of-band acceptance without lifecycle inference", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(acceptMessageSubmission(sending, "client-1", false, true)).toEqual([]);
});
it("settles an idle acceptance from a daemon without submission disposition", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(acceptMessageSubmission(sending, "client-1", false, undefined)).toEqual([]);
});
it("records provider acknowledgement without settling another transaction", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
{ clientMessageId: "client-2", submittedAt },
);
const observed = observeMessageSubmissionCanonical(both, ["client-1"]);
expect(observed).toEqual([
{
clientMessageId: "client-1",
submittedAt,
rpcAccepted: false,
providerAcknowledged: true,
},
{
clientMessageId: "client-2",
submittedAt,
rpcAccepted: false,
providerAcknowledged: false,
},
]);
expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]);
});
it("does not roll back a provider-acknowledged prompt on a later transport error", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const observed = observeMessageSubmissionCanonical(sending, ["client-1"]);
expect(rejectMessageSubmission(observed, "client-1")).toEqual({
outcome: "accepted",
submissions: [],
});
});
it("rejects an unacknowledged transaction", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(rejectMessageSubmission(sending, "client-1")).toEqual({
outcome: "rejected",
submissions: [],
});
});
it("does not create duplicate transaction identity", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(() =>
beginMessageSubmission(sending, { clientMessageId: "client-1", submittedAt }),
).toThrow("Message submission already exists");
});
});

View File

@@ -0,0 +1,108 @@
export interface PendingMessageSubmission {
clientMessageId: string;
submittedAt: Date;
}
export type MessageSubmissionRecord = PendingMessageSubmission & {
rpcAccepted: boolean;
providerAcknowledged: boolean;
};
const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = [];
export function getActiveMessageSubmissions(
submissions: readonly MessageSubmissionRecord[] | null | undefined,
): readonly PendingMessageSubmission[] {
return submissions ?? EMPTY_MESSAGE_SUBMISSIONS;
}
export function getSendingClientMessageIds(
submissions: readonly MessageSubmissionRecord[] | null | undefined,
): string[] {
return (submissions ?? [])
.filter((submission) => !submission.providerAcknowledged)
.map((submission) => submission.clientMessageId);
}
export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown";
export interface MessageSubmissionRejectionResult {
submissions: MessageSubmissionRecord[];
outcome: MessageSubmissionRejectionOutcome;
}
export function beginMessageSubmission(
submissions: readonly MessageSubmissionRecord[],
input: PendingMessageSubmission,
): MessageSubmissionRecord[] {
if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) {
throw new Error(`Message submission already exists: ${input.clientMessageId}`);
}
return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }];
}
export function acceptMessageSubmission(
submissions: readonly MessageSubmissionRecord[],
clientMessageId: string,
isAgentRunning: boolean,
outOfBand: boolean | undefined,
): MessageSubmissionRecord[] {
const index = submissions.findIndex(
(submission) => submission.clientMessageId === clientMessageId,
);
if (index < 0) return submissions as MessageSubmissionRecord[];
// COMPAT(messageSubmissionDisposition): daemons before v0.2.3 omitted outOfBand.
// Their normal-send response follows the ordered running/canonical events, while an
// out-of-band response arrives with the agent still idle. Remove after 2027-01-27.
const legacyOutOfBand = outOfBand === undefined && !isAgentRunning;
if (
outOfBand === true ||
legacyOutOfBand ||
isAgentRunning ||
submissions[index].providerAcknowledged
) {
return submissions.filter((_, submissionIndex) => submissionIndex !== index);
}
if (submissions[index].rpcAccepted) return submissions as MessageSubmissionRecord[];
const next = submissions.slice();
next[index] = { ...next[index], rpcAccepted: true };
return next;
}
export function observeAcceptedMessageSubmissionsRunning(
submissions: readonly MessageSubmissionRecord[],
): MessageSubmissionRecord[] {
const next = submissions.filter((submission) => !submission.rpcAccepted);
return next.length === submissions.length ? (submissions as MessageSubmissionRecord[]) : next;
}
export function observeMessageSubmissionCanonical(
submissions: readonly MessageSubmissionRecord[],
clientMessageIds: readonly string[],
): MessageSubmissionRecord[] {
if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[];
const observed = new Set(clientMessageIds);
let changed = false;
const next = submissions.flatMap((submission): MessageSubmissionRecord[] => {
if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) {
return [submission];
}
changed = true;
return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }];
});
return changed ? next : (submissions as MessageSubmissionRecord[]);
}
export function rejectMessageSubmission(
submissions: readonly MessageSubmissionRecord[],
clientMessageId: string,
): MessageSubmissionRejectionResult {
const submission = submissions.find((item) => item.clientMessageId === clientMessageId);
if (!submission) {
return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] };
}
return {
outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected",
submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId),
};
}

View File

@@ -0,0 +1,20 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
/**
* Binds the submission lifecycle to a host session. Every path that sends a message to an
* agent — composer send, queued send-now, automatic queue drain — goes through this so a
* submitted row and its pending state are always created together.
*/
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
return {
begin: (agentId, message) =>
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
accept: (agentId, clientMessageId, outOfBand) =>
useSessionStore
.getState()
.acceptAgentMessageSubmission(serverId, agentId, clientMessageId, outOfBand),
reject: (agentId, clientMessageId) =>
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
};
}

View File

@@ -51,7 +51,7 @@ export async function submitAgentInput<TAttachment>(
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
// Clear immediately so the submitted timeline row and composer state stay in sync.
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);

View File

@@ -53,6 +53,7 @@ import {
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import type { AttachmentMetadata } from "@/attachments/types";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { useToast } from "@/contexts/toast-context";
@@ -191,9 +192,7 @@ type WorkspaceSetupProgressPayload = Extract<
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"];
type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"];
type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"];
type SetAgentStreamState = SessionStoreActions["setAgentStreamState"];
type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"];
type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"];
type SetAgentAuthoritativeHistoryApplied =
@@ -236,9 +235,7 @@ function applyTimelineStreamPatches(input: {
serverId: string;
currentTail: StreamItem[];
currentHead: StreamItem[];
setAgentStreamTail: SetAgentStreamTail;
setAgentStreamHead: SetAgentStreamHead;
clearAgentStreamHead: ClearAgentStreamHead;
setAgentStreamState: SetAgentStreamState;
setAgentTimelineCursor: SetAgentTimelineCursor;
}): void {
const {
@@ -247,32 +244,24 @@ function applyTimelineStreamPatches(input: {
serverId,
currentTail,
currentHead,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentStreamState,
setAgentTimelineCursor,
} = input;
if (result.tail !== currentTail) {
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
return next;
if (
result.tail !== currentTail ||
result.head !== currentHead ||
result.acknowledgedClientMessageIds.length > 0
) {
setAgentStreamState(serverId, agentId, {
...(result.tail !== currentTail ? { tail: result.tail } : {}),
...(result.head !== currentHead ? { head: result.head } : {}),
...(result.acknowledgedClientMessageIds.length > 0
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
: {}),
});
}
if (result.head !== currentHead) {
if (result.head.length === 0) {
clearAgentStreamHead(serverId, agentId);
} else {
setAgentStreamHead(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, result.head);
return next;
});
}
}
if (result.cursorChanged) {
setAgentTimelineCursor(serverId, (prev) => {
const current = prev.get(agentId);
@@ -658,6 +647,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const currentCursor = session?.agentTimelineCursor.get(agentId);
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
const sendingClientMessageIds = getSendingClientMessageIds(
session?.messageSubmissions.get(agentId),
);
setAgentTimelineHasOlder(serverId, (prev) => {
if (prev.get(agentId) === payload.hasOlder) {
@@ -677,6 +669,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
isInitializing,
hasActiveInitDeferred,
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds,
});
if (result.error) {
@@ -696,9 +689,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
currentTail,
currentHead,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentStreamState,
setAgentTimelineCursor,
});
@@ -720,13 +711,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
},
[
clearAgentStreamHead,
markAgentHistorySynchronized,
recoverTimelineGap,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamHead,
setAgentStreamTail,
setAgentStreamState,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
@@ -808,7 +797,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
recoverTimelineGap,
});
@@ -825,7 +813,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
agentStreamReducerQueue.enqueue(agentId, {
event: streamEvent,
seq,

View File

@@ -1,18 +0,0 @@
import { createContext, useContext } from "react";
import type { SharedValue } from "react-native-reanimated";
export interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
/** Read the app-wide keyboard inset without loading its native provider implementation. */
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}

View File

@@ -115,12 +115,14 @@ function workspace(input: {
name: string;
projectId: string;
projectDisplayName: string;
projectKey?: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId,
projectKey: input.projectKey,
projectDisplayName: input.projectDisplayName,
projectRootPath: `/repo/${input.projectId}`,
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
@@ -401,6 +403,36 @@ describe("shared sidebar workspace model", () => {
expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one"));
expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two"));
});
it("keeps a structurally disambiguated project key in status entries", () => {
const projectKey = "host:srv:project:prj_a";
const model = buildSidebarWorkspacePlacementModel({
projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })],
});
const entries = buildSidebarWorkspaceEntries({
placements: model.workspaces,
sessions: [
{
serverId: "srv",
workspaceAgentActivity: new Map(),
workspaces: new Map([
[
"clone-a",
workspace({
id: "clone-a",
name: "main",
projectId: "prj_a",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
}),
],
]),
},
],
});
expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey);
});
});
describe("shouldShowSidebarHostLabels", () => {

View File

@@ -8,6 +8,7 @@ import type {
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { resolveProjectKey } from "@/projects/project-key";
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
@@ -141,10 +142,17 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
projectKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
const projectKey =
input.projectKey ??
resolveProjectKey({
serverId: input.serverId,
projectId: input.workspace.projectId,
projectKey: input.workspace.projectKey,
});
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
@@ -323,6 +331,7 @@ export function buildSidebarWorkspaceEntries(input: {
const entry = createSidebarWorkspaceEntry({
serverId: placement.serverId,
workspace,
projectKey: placement.projectKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceAgentActivity: session.workspaceAgentActivity,
});

View File

@@ -1,4 +1,11 @@
import { createElement, useEffect, useMemo, type ReactNode } from "react";
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -16,10 +23,16 @@ import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
} from "@/hooks/keyboard-shift-policy";
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
type KeyboardShiftMode = "translate" | "padding";
interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
@@ -65,6 +78,14 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
return createElement(KeyboardShiftContext.Provider, { value }, children);
}
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;

View File

@@ -93,6 +93,7 @@ describe("openProjectDirectly", () => {
serverId: SERVER_ID,
project: {
projectId: "project-1",
projectKey: null,
projectDisplayName: "project",
projectCustomName: null,
projectKind: "git",
@@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => {
project: {
...projectPayload,
projectCustomName: null,
projectKey: null,
},
},
]);

View File

@@ -225,6 +225,9 @@ describe("deriveProjectsFromReplica", () => {
"gitRuntime",
"githubRuntime",
"isOnline",
"projectCustomName",
"projectId",
"projectName",
"repoRoot",
"serverId",
"serverName",

View File

@@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { SidebarCallout } from "@/components/sidebar-callout";
import { Composer } from "@/composer";
import { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import { getProviderIcon } from "@/components/provider-icons";
import {
@@ -442,6 +443,7 @@ export function useDraftPanelDescriptor(
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_MESSAGE_SUBMISSIONS = [] as const;
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
@@ -1288,6 +1290,11 @@ const AgentStreamSection = memo(function AgentStreamSection({
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const pendingMessageSubmissions = useSessionStore((state) =>
agentId
? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId))
: EMPTY_MESSAGE_SUBMISSIONS,
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
@@ -1327,6 +1334,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);

View File

@@ -3,6 +3,7 @@ import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
import { resolveProjectKey } from "@/projects/project-key";
export interface HostProjectListItem {
projectKey: string;
@@ -58,6 +59,7 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec
hosts: [
{
serverId: route.serverId,
projectId: projectKey,
iconWorkingDir,
canCreateWorktree: true,
},
@@ -73,7 +75,11 @@ export function hostProjectFromWorkspace(input: {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const projectKey = resolveProjectKey({
serverId: input.serverId,
projectId: input.workspace.projectId.trim(),
projectKey: input.workspace.projectKey,
});
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
return null;
@@ -87,6 +93,7 @@ export function hostProjectFromWorkspace(input: {
hosts: [
{
serverId: input.serverId,
projectId: input.workspace.projectId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
@@ -106,6 +113,10 @@ export function getHostProjectSourceDirectory(
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
}
export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.projectId ?? project.projectKey;
}
export function canCreateWorkspaceForHostProject(input: {
project: HostProjectListItem;
serverId: string;
@@ -144,8 +155,20 @@ export function resolveInitialWorkspaceProject(input: {
if (!candidate) {
continue;
}
const candidatePlacement = candidate.hosts.find(
(host) => host.serverId === input.serverId && host.projectId,
);
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
(candidatePlacement
? input.projects.find((project) =>
project.hosts.some(
(host) =>
host.serverId === input.serverId && host.projectId === candidatePlacement.projectId,
),
)
: undefined) ??
input.projects.find((project) => project.projectKey === candidate.projectKey) ??
candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,

View File

@@ -53,6 +53,7 @@ function workspace(input: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
return {
id: input.id ?? "workspace-a",
projectId: input.projectId ?? "project-a",
projectKey: input.projectKey ?? input.projectId ?? "project-a",
projectDisplayName: input.projectDisplayName ?? "Project A",
projectRootPath: input.projectRootPath ?? "/repo/a",
workspaceDirectory: input.workspaceDirectory ?? "/repo/a",
@@ -189,7 +190,14 @@ describe("host project list", () => {
it("filters new-workspace projects to the selected host", () => {
const hostAOnly = hostProject({
projectKey: "host-a-project",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
});
const hostBOnly = hostProject({
projectKey: "host-b-project",
@@ -247,6 +255,38 @@ describe("host project list", () => {
).toEqual(selectedHostProject);
});
it("hydrates a route project by its host-local project id", () => {
const hydratedDirectory = hostProject({
projectKey: "host:host-a:project:project-a",
projectName: "Notes",
projectKind: "directory",
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/notes",
canCreateWorktree: false,
},
],
});
const routeDirectory = hostProjectFromRoute({
serverId: "host-a",
projectId: "project-a",
displayName: "Notes",
sourceDirectory: "/notes",
});
expect(
resolveInitialWorkspaceProject({
routeProject: routeDirectory,
lastActiveProject: null,
projects: [hydratedDirectory],
serverId: "host-a",
allowAllProjects: true,
}),
).toEqual(hydratedDirectory);
});
it("resolves the selected host project source directory", () => {
const project = hostProject({
hosts: [
@@ -283,7 +323,14 @@ describe("host project list", () => {
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: [],
});
expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull();
@@ -295,7 +342,14 @@ describe("host project list", () => {
projectName: "Project A",
projectKind: "git",
iconWorkingDir: "/repo/a",
hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }],
hosts: [
{
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/repo/a",
canCreateWorktree: true,
},
],
workspaceKeys: ["host-a:workspace-a"],
});

View File

@@ -8,6 +8,7 @@ export {
canCreateWorktreeForProjectKind,
filterWorkspaceProjectsForHost,
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
resolveInitialWorkspaceProject,

View File

@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { resolveProjectKey } from "./project-key";
describe("resolveProjectKey", () => {
test("keeps legacy path-shaped project IDs local to their host", () => {
const resolve = (serverId: string) =>
resolveProjectKey({ serverId, projectId: "/workspace/app" });
expect(resolve("host-a")).not.toBe(resolve("host-b"));
});
test("keeps recognized legacy remote project IDs shared across hosts", () => {
const resolve = (serverId: string) =>
resolveProjectKey({ serverId, projectId: "remote:github.com/acme/app" });
expect(resolve("host-a")).toBe(resolve("host-b"));
});
});

View File

@@ -0,0 +1,15 @@
export function resolveProjectKey(input: {
serverId: string;
projectId: string;
projectKey?: string | null;
}): string {
if (input.projectKey) return input.projectKey;
// COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove after 2027-01-28.
// Older daemons used recognized remote-shaped project IDs as their grouping key. Their
// path-shaped and opaque IDs are host-local when the new field is absent.
return input.projectId.startsWith("remote:") ? input.projectId : frameHostProjectKey(input);
}
export function frameHostProjectKey(input: { serverId: string; projectId: string }): string {
return `host:${input.serverId.length}:${input.serverId}:project:${input.projectId.length}:${input.projectId}`;
}

View File

@@ -7,7 +7,10 @@ import {
const project: ProjectRemoveProject = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a" }, { serverId: "host-b" }],
hosts: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
};
function createProjectRemoveClient() {
@@ -46,12 +49,14 @@ describe("project remove policy", () => {
expect(readiness).toEqual({
kind: "ready",
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
});
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: readiness.kind === "ready" ? readiness.targets : [],
getClient: (serverId) => {
if (serverId === "host-a") return hostA.client;
if (serverId === "host-b") return hostB.client;
@@ -60,8 +65,8 @@ describe("project remove policy", () => {
});
expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] });
expect(hostA.removedProjectKeys).toEqual([project.projectKey]);
expect(hostB.removedProjectKeys).toEqual([project.projectKey]);
expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]);
expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]);
});
it("reports disconnected hosts before sending any remove request", async () => {
@@ -69,7 +74,10 @@ describe("project remove policy", () => {
const outcome = await removeProjectFromHosts({
projectKey: project.projectKey,
targets: [{ serverId: "host-a" }, { serverId: "host-b" }],
targets: [
{ serverId: "host-a", projectId: "prj_host_a" },
{ serverId: "host-b", projectId: "prj_host_b" },
],
getClient: (serverId) => (serverId === "host-a" ? hostA.client : null),
});

View File

@@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features";
interface ProjectRemoveHost {
serverId: string;
projectId?: string;
}
export interface ProjectRemoveProject {
@@ -13,6 +14,7 @@ export interface ProjectRemoveProject {
export interface ProjectRemoveTarget {
serverId: string;
projectId?: string;
}
export type ProjectRemoveReadiness =
@@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: {
unsupportedServerIds.push(host.serverId);
continue;
}
targets.push({ serverId: host.serverId });
targets.push({
serverId: host.serverId,
projectId: host.projectId ?? input.project.projectKey,
});
}
if (unsupportedServerIds.length > 0) {
@@ -59,11 +64,11 @@ export function getCurrentProjectRemoveReadiness(
}
export async function removeProjectFromHosts(input: {
projectKey: string;
projectKey?: string;
targets: readonly ProjectRemoveTarget[];
getClient: (serverId: string) => ProjectRemoveClient | null;
}): Promise<ProjectRemoveOutcome> {
const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = [];
const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = [];
const disconnectedServerIds: string[] = [];
for (const target of input.targets) {
@@ -72,7 +77,11 @@ export async function removeProjectFromHosts(input: {
disconnectedServerIds.push(target.serverId);
continue;
}
clients.push({ serverId: target.serverId, client });
const projectId = target.projectId ?? input.projectKey;
if (!projectId) {
return { kind: "failed", serverIds: [target.serverId] };
}
clients.push({ serverId: target.serverId, projectId, client });
}
if (disconnectedServerIds.length > 0) {
@@ -80,8 +89,8 @@ export async function removeProjectFromHosts(input: {
}
const results = await Promise.allSettled(
clients.map(async ({ client }) => {
await client.removeProject(input.projectKey);
clients.map(async ({ client, projectId }) => {
await client.removeProject(projectId);
}),
);
const failedServerIds: string[] = [];

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
findProjectSettingsRouteTarget,
findProjectSettingsTarget,
resolveHostProjectSettingsRouteKey,
resolveProjectSettingsServerId,
resolveProjectSettingsRouteKey,
} from "./project-settings-target";
describe("project settings target", () => {
const project = {
projectKey: "remote:github.com/acme/app",
hosts: [{ serverId: "host-a", projectId: "prj_1234" }],
};
it("builds settings routes from stable host-local identity", () => {
expect(resolveProjectSettingsRouteKey(project)).toBe("host:6:host-a:project:8:prj_1234");
});
it("keeps a host-local route valid after the structural key changes", () => {
expect(findProjectSettingsTarget([project], "host:6:host-a:project:8:prj_1234")).toBe(project);
});
it("keeps legacy project IDs scoped to their host", () => {
const legacyKey = "remote:github.com/acme/app";
const unchangedProject = {
projectKey: legacyKey,
hosts: [{ serverId: "host-a", projectId: legacyKey }],
};
const changedProject = {
projectKey: "remote:github.com/acme/app-fork",
hosts: [{ serverId: "host-b", projectId: legacyKey }],
};
const routeKey = resolveProjectSettingsRouteKey(changedProject);
expect(routeKey).toBe(`host:6:host-b:project:${legacyKey.length}:${legacyKey}`);
expect(findProjectSettingsTarget([unchangedProject, changedProject], routeKey)).toBe(
changedProject,
);
});
it("frames opaque host and project IDs without collisions", () => {
const first = resolveHostProjectSettingsRouteKey({
serverId: "a",
projectId: "b:project:c",
});
const second = resolveHostProjectSettingsRouteKey({
serverId: "a:project:b",
projectId: "c",
});
expect(first).not.toBe(second);
});
it("preserves whitespace in opaque project IDs", () => {
const withoutTrailingSpace = resolveHostProjectSettingsRouteKey({
serverId: "host-a",
projectId: "/repo/foo",
});
const withTrailingSpace = resolveHostProjectSettingsRouteKey({
serverId: "host-a",
projectId: "/repo/foo ",
});
expect(withTrailingSpace).not.toBe(withoutTrailingSpace);
});
it("prefers an online host for a generic grouped settings route", () => {
const groupedProject = {
projectKey: "remote:github.com/acme/app",
hosts: [
{ serverId: "host-a", projectId: "project-a", isOnline: false },
{ serverId: "host-b", projectId: "project-b", isOnline: true },
],
};
expect(resolveProjectSettingsRouteKey(groupedProject)).toBe(
resolveHostProjectSettingsRouteKey(groupedProject.hosts[1]),
);
});
it("preserves the host identified by a grouped settings route", () => {
const groupedProject = {
projectKey: "remote:github.com/acme/app",
hosts: [
{ serverId: "host-a", projectId: "project-a" },
{ serverId: "host-b", projectId: "project-b" },
],
};
const routeKey = resolveHostProjectSettingsRouteKey(groupedProject.hosts[1]);
expect(routeKey).not.toBeNull();
expect(findProjectSettingsRouteTarget([groupedProject], routeKey ?? "")).toEqual({
project: groupedProject,
serverId: "host-b",
});
});
it("does not retarget an unavailable routed host", () => {
expect(
resolveProjectSettingsServerId({
projectKey: "remote:github.com/acme/app",
editableServerIds: ["host-a"],
routedServerId: "host-b",
hostSelection: { routeKey: "", serverId: "" },
}),
).toBe("host-b");
});
});

View File

@@ -0,0 +1,68 @@
import { frameHostProjectKey } from "./project-key";
interface ProjectSettingsTarget {
projectKey: string;
hosts: ReadonlyArray<{ serverId: string; projectId?: string; isOnline?: boolean }>;
}
export function resolveHostProjectSettingsRouteKey(host: {
serverId: string;
projectId?: string;
}): string | null {
const projectId = host.projectId;
if (!projectId?.trim()) return null;
return frameHostProjectKey({ serverId: host.serverId, projectId });
}
export function resolveProjectSettingsRouteKey(project: ProjectSettingsTarget): string {
for (const host of project.hosts) {
if (!host.isOnline) continue;
const hostLocalKey = resolveHostProjectSettingsRouteKey(host);
if (hostLocalKey) return hostLocalKey;
}
for (const host of project.hosts) {
const hostLocalKey = resolveHostProjectSettingsRouteKey(host);
if (hostLocalKey) return hostLocalKey;
}
return project.projectKey;
}
export function findProjectSettingsTarget<T extends ProjectSettingsTarget>(
projects: readonly T[],
routeKey: string,
): T | undefined {
return findProjectSettingsRouteTarget(projects, routeKey)?.project;
}
export function findProjectSettingsRouteTarget<T extends ProjectSettingsTarget>(
projects: readonly T[],
routeKey: string,
): { project: T; serverId: string | null } | undefined {
const exactProject = projects.find((project) => project.projectKey === routeKey);
if (exactProject) return { project: exactProject, serverId: null };
for (const project of projects) {
const host = project.hosts.find(
(candidate) => resolveHostProjectSettingsRouteKey(candidate) === routeKey,
);
if (host) return { project, serverId: host.serverId };
}
return undefined;
}
export function resolveProjectSettingsServerId(input: {
projectKey: string;
editableServerIds: readonly string[];
routedServerId: string | null;
hostSelection: { routeKey: string; serverId: string };
}): string {
const editableServerIds = new Set(input.editableServerIds);
if (
input.hostSelection.routeKey === input.projectKey &&
editableServerIds.has(input.hostSelection.serverId)
) {
return input.hostSelection.serverId;
}
if (input.routedServerId) return input.routedServerId;
return input.editableServerIds[0] ?? "";
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceStructureProjects } from "./workspace-structure";
function workspace(input: {
id: string;
projectName: string;
projectCustomName: string | null;
projectId?: string;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? `project-${input.id}`,
projectKey: "remote:github.com/acme/app",
projectDisplayName: input.projectName,
projectCustomName: input.projectCustomName,
projectRootPath: `/repo/${input.id}`,
workspaceDirectory: `/repo/${input.id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("buildWorkspaceStructureProjects", () => {
it("promotes a later grouped host's custom project name", () => {
const projects = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "host-a",
workspaces: [workspace({ id: "a", projectName: "acme/app", projectCustomName: null })],
},
{
serverId: "host-b",
workspaces: [
workspace({ id: "b", projectName: "acme/app", projectCustomName: "My App" }),
],
},
],
});
expect(projects).toEqual([
expect.objectContaining({
projectKey: "remote:github.com/acme/app",
projectName: "My App",
}),
]);
});
it("frames ambiguous host placements without alias collisions", () => {
const projects = buildWorkspaceStructureProjects({
sessions: [
{
serverId: "a",
workspaces: [
workspace({
id: "a-1",
projectId: "remote:x/foo:project:/c",
projectName: "first",
projectCustomName: null,
}),
workspace({
id: "a-2",
projectId: "other",
projectName: "other",
projectCustomName: null,
}),
],
},
{
serverId: "a:project:remote:x/foo",
workspaces: [
workspace({
id: "b",
projectId: "/c",
projectName: "second",
projectCustomName: null,
}),
],
},
],
});
expect(projects).toHaveLength(3);
expect(new Set(projects.map((project) => project.projectKey)).size).toBe(3);
});
});

View File

@@ -1,8 +1,10 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import { frameHostProjectKey, resolveProjectKey } from "@/projects/project-key";
export interface WorkspaceStructureHostPlacement {
serverId: string;
projectId?: string;
iconWorkingDir: string;
canCreateWorktree: boolean;
}
@@ -57,14 +59,67 @@ interface WorkspaceStructureSession {
emptyProjects?: Iterable<EmptyProjectDescriptor>;
}
interface MaterializedWorkspaceStructureSession {
serverId: string;
workspaces: WorkspaceDescriptor[];
emptyProjects: EmptyProjectDescriptor[];
}
function findAmbiguousProjectKeys(sessions: MaterializedWorkspaceStructureSession[]): Set<string> {
const projectIdsByHostByGroupKey = new Map<string, Map<string, Set<string>>>();
for (const session of sessions) {
const projects = [
...session.emptyProjects.map((project) => ({
projectId: project.projectId,
projectKey: project.projectKey,
})),
...session.workspaces.map((workspace) => ({
projectId: workspace.projectId,
projectKey: workspace.projectKey,
})),
];
for (const project of projects) {
const groupKey = resolveProjectKey({ serverId: session.serverId, ...project });
const byHost = projectIdsByHostByGroupKey.get(groupKey) ?? new Map();
const projectIds = byHost.get(session.serverId) ?? new Set();
projectIds.add(project.projectId);
byHost.set(session.serverId, projectIds);
projectIdsByHostByGroupKey.set(groupKey, byHost);
}
}
return new Set(
[...projectIdsByHostByGroupKey].flatMap(([groupKey, byHost]) =>
[...byHost.values()].some((projectIds) => projectIds.size > 1) ? [groupKey] : [],
),
);
}
function resolveUnambiguousProjectKey(input: {
serverId: string;
projectId: string;
projectKey?: string | null;
ambiguousGroupKeys: ReadonlySet<string>;
}): string {
const groupKey = resolveProjectKey(input);
return input.ambiguousGroupKeys.has(groupKey) ? frameHostProjectKey(input) : groupKey;
}
export function buildWorkspaceStructureProjects(input: {
sessions: WorkspaceStructureSession[];
}): WorkspaceStructureProject[] {
const sessions = input.sessions.map((session) => ({
serverId: session.serverId,
workspaces: [...session.workspaces],
emptyProjects: [...(session.emptyProjects ?? [])],
}));
const ambiguousGroupKeys = findAmbiguousProjectKeys(sessions);
const byProject = new Map<
string,
{
projectKey: string;
projectName: string;
hasCustomName: boolean;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: Map<string, WorkspaceStructureHostPlacement>;
@@ -72,11 +127,17 @@ export function buildWorkspaceStructureProjects(input: {
}
>();
for (const session of input.sessions) {
for (const emptyProject of session.emptyProjects ?? []) {
const projectKey = emptyProject.projectId;
for (const session of sessions) {
for (const emptyProject of session.emptyProjects) {
const projectKey = resolveUnambiguousProjectKey({
serverId: session.serverId,
projectId: emptyProject.projectId,
projectKey: emptyProject.projectKey,
ambiguousGroupKeys,
});
const placement = {
serverId: session.serverId,
projectId: emptyProject.projectId,
iconWorkingDir: emptyProject.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind),
};
@@ -89,6 +150,7 @@ export function buildWorkspaceStructureProjects(input: {
emptyProject.projectCustomName ??
emptyProject.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
hasCustomName: Boolean(emptyProject.projectCustomName),
projectKind: emptyProject.projectKind,
iconWorkingDir: emptyProject.projectRootPath,
hosts: new Map([[session.serverId, placement]]),
@@ -97,11 +159,20 @@ export function buildWorkspaceStructureProjects(input: {
continue;
}
if (emptyProject.projectCustomName && !existing.hasCustomName) {
existing.projectName = emptyProject.projectCustomName;
existing.hasCustomName = true;
}
existing.hosts.set(session.serverId, placement);
}
for (const workspace of session.workspaces) {
const projectKey = workspace.project?.projectKey ?? workspace.projectId;
const projectKey = resolveUnambiguousProjectKey({
serverId: session.serverId,
projectId: workspace.projectId,
projectKey: workspace.projectKey,
ambiguousGroupKeys,
});
const existing = byProject.get(projectKey);
if (!existing) {
@@ -111,6 +182,7 @@ export function buildWorkspaceStructureProjects(input: {
workspace.projectCustomName ??
workspace.projectDisplayName ??
projectDisplayNameFromProjectId(projectKey),
hasCustomName: Boolean(workspace.projectCustomName),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
hosts: new Map([
@@ -118,6 +190,7 @@ export function buildWorkspaceStructureProjects(input: {
session.serverId,
{
serverId: session.serverId,
projectId: workspace.projectId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
},
@@ -134,8 +207,13 @@ export function buildWorkspaceStructureProjects(input: {
continue;
}
if (workspace.projectCustomName && !existing.hasCustomName) {
existing.projectName = workspace.projectCustomName;
existing.hasCustomName = true;
}
existing.hosts.set(session.serverId, {
serverId: session.serverId,
projectId: workspace.projectId,
iconWorkingDir: workspace.projectRootPath,
canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind),
});

View File

@@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
kind: "upsert",
project: {
projectId: "attached",
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
@@ -124,12 +125,14 @@ it("commits the authoritative snapshot before buffered project updates", () => {
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectCustomName: "Personal name",
projectRootPath: "/moved/attached",
projectKind: "directory",
});
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
projectKey: "remote:github.com/acme/attached",
projectDisplayName: "Renamed attached project",
projectRootPath: "/moved/attached",
});

View File

@@ -42,6 +42,7 @@ function applyProjectDelta(
hasAttachedWorkspace = true;
snapshot.workspaces.set(workspaceId, {
...workspace,
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectRootPath: project.projectRootPath,

View File

@@ -80,13 +80,16 @@ class FakeDaemonClient {
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
}
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
async sendAgentMessage(
...args: Parameters<DaemonClient["sendAgentMessage"]>
): ReturnType<DaemonClient["sendAgentMessage"]> {
this.sentAgentMessages.push(args);
for (const waiter of this.sentMessageWaiters) waiter();
const response = this.sendAgentMessageResponses.shift();
if (response) await response;
const failure = this.sendAgentMessageFailures.shift();
if (failure) throw failure;
return {};
}
async waitForSentMessages(count: number): Promise<void> {
@@ -2259,6 +2262,66 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("submits an automatically drained message through the submission producer", async () => {
const host = makeHost({ serverId: "srv_drain_submission" });
const fakeClient = new FakeDaemonClient();
const send = new Deferred<void>();
fakeClient.sendAgentMessageResponses.push(send.promise);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_drain_submission",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
[
"agent",
[
{
id: "queued-with-attachment",
text: "read this file",
attachments: [
{
kind: "workspace_file" as const,
path: "src/main.ts",
selection: { kind: "whole_file" as const },
},
],
},
],
],
]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
await fakeClient.waitForSentMessages(1);
// The row and the pending submission must exist while the RPC is still in flight —
// the user sees their message and the working footer immediately, exactly as when
// they press send.
const session = useSessionStore.getState().sessions[host.serverId];
const tail = session?.agentStreamTail.get("agent") ?? [];
expect(tail).toHaveLength(1);
expect(tail[0]).toMatchObject({
kind: "user_message",
text: "read this file",
attachments: [{ type: "text", title: "main.ts", text: "Workspace file: src/main.ts" }],
});
expect(session?.messageSubmissions.get("agent")).toBeDefined();
send.resolve();
useSessionStore.getState().clearSession(host.serverId);
});
it("restores an automatically drained message when sending fails", async () => {
const host = makeHost({ serverId: "srv_failed_queue_drain" });
const fakeClient = new FakeDaemonClient();

View File

@@ -49,11 +49,9 @@ import {
} from "@/data/push-router";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { sendQueuedComposerMessageNow } from "@/composer/actions";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache";
@@ -2071,14 +2069,16 @@ export class HostRuntimeStore {
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }),
});
const images = await encodeImages(wirePayload.images);
await client.sendAgentMessage(agentId, text, {
messageId: next.id,
...(images && images.length > 0 ? { images } : {}),
attachments: wirePayload.attachments,
await dispatchComposerAgentMessage({
client,
agentId,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
});
},
})

View File

@@ -19,9 +19,10 @@ import {
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { getSendingClientMessageIds } from "@/composer/submission/model";
const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 1;
const CACHE_VERSION = 2;
const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 1024 * 1024;
@@ -192,6 +193,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
return {
id: workspace.id,
projectId: workspace.projectId,
...(workspace.projectKey ? { projectKey: workspace.projectKey } : {}),
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
@@ -370,7 +372,22 @@ export class ReplicaCache {
(workspace) => workspace.workspaceDirectory === focusedAgent.cwd,
))
: undefined;
const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined;
const localSubmissionIds = new Set(
getSendingClientMessageIds(
focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined,
),
);
const items = focusedAgentId
? session.agentStreamTail
.get(focusedAgentId)
?.filter(
(item) =>
item.kind !== "user_message" ||
item.messageId !== undefined ||
!item.clientMessageId ||
!localSubmissionIds.has(item.clientMessageId),
)
: undefined;
const timeline =
focusedAgent && items
? {

View File

@@ -21,6 +21,8 @@ function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
return {
serverId: "host-1",
projectName: "Project",
projectCustomName: null,
serverName: "Host 1",
isOnline: true,
repoRoot: "/tmp/project",

View File

@@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import {
getHostProjectSourceDirectory,
getHostProjectId,
hostProjectFromRoute,
hostProjectFromWorkspace,
useHostProjects,
@@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: {
serverId: string;
createFailedMessage: string;
}): Promise<ReturnType<typeof normalizeWorkspaceDescriptor>> {
const projectId = getHostProjectId(input.project, input.serverId);
if (!projectId) throw new Error("Project is not available on the selected host");
const isWorktree = input.isolation === "worktree";
const checkoutRequest = isWorktree
? resolveCheckoutRequest(input.selectedItem, input.currentBranch)
@@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: {
? {
kind: "worktree",
cwd: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
worktreeSlug: createNameId(),
...checkoutRequest,
}
: {
kind: "directory",
path: input.sourceDirectory,
projectId: input.project.projectKey,
projectId,
},
...(firstAgentContext ? { firstAgentContext } : {}),
});
@@ -1889,16 +1892,20 @@ export function NewWorkspaceScreen({
}
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
const firstAgentContext = buildFirstAgentContext(input);
const hostProjectId = getHostProjectId(selectedProject, selectedServerId);
if (!hostProjectId) {
throw new Error("Project is not available on the selected host");
}
return {
cwd: selectedSourceDirectory,
projectId: selectedProject.projectKey,
projectId: hostProjectId,
worktreeSlug: createNameId(),
...(firstAgentContext ? { firstAgentContext } : {}),
...checkoutRequest,
};
},
[currentBranch, selectedItem, selectedProject, selectedSourceDirectory],
[currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory],
);
const ensureWorkspace = useCallback(

View File

@@ -31,6 +31,10 @@ import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects";
import {
findProjectSettingsRouteTarget,
resolveProjectSettingsServerId,
} from "@/projects/project-settings-target";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
@@ -89,23 +93,24 @@ export interface ProjectSettingsScreenProps {
export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) {
const { projects } = useProjects();
const project = useMemo(
() => projects.find((entry) => entry.projectKey === projectKey),
const routeTarget = useMemo(
() => findProjectSettingsRouteTarget(projects, projectKey),
[projects, projectKey],
);
const project = routeTarget?.project;
const editableHosts = useMemo(() => filterEditableHosts(project), [project]);
const [selectedServerId, setSelectedServerId] = useState<string>(
() => editableHosts[0]?.serverId ?? "",
const [hostSelection, setHostSelection] = useState({ routeKey: "", serverId: "" });
const selectedServerId = resolveProjectSettingsServerId({
projectKey,
editableServerIds: editableHosts.map((host) => host.serverId),
routedServerId: routeTarget?.serverId ?? null,
hostSelection,
});
const setSelectedServerId = useCallback(
(serverId: string) => setHostSelection({ routeKey: projectKey, serverId }),
[projectKey],
);
useEffect(() => {
const stillValid = editableHosts.some((host) => host.serverId === selectedServerId);
if (!stillValid) {
setSelectedServerId(editableHosts[0]?.serverId ?? "");
}
}, [editableHosts, selectedServerId]);
const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId);
const isHostGone =
Boolean(selectedServerId) &&
@@ -241,7 +246,13 @@ function ProjectSettingsBody({
projectName={project.projectName}
projectKey={project.projectKey}
/>
<ProjectNameEditor project={project} client={client} />
<ProjectNameEditor
key={`${selectedHost.serverId}:${selectedHost.projectId ?? project.projectKey}`}
projectName={selectedHost.projectName}
projectCustomName={selectedHost.projectCustomName}
projectId={selectedHost.projectId ?? project.projectKey}
client={client}
/>
</View>
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
</View>
@@ -789,19 +800,26 @@ function ResolveSpinnerColor(): string {
}
interface ProjectNameEditorProps {
project: ProjectSummary;
projectName: string;
projectCustomName: string | null;
projectId: string;
client: DaemonClient;
}
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
function ProjectNameEditor({
projectName,
projectCustomName,
projectId,
client,
}: ProjectNameEditorProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(project.projectCustomName ?? "");
const [value, setValue] = useState(projectCustomName ?? "");
const renameMutation = useMutation({
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
mutationFn: (customName: string | null) => client.renameProject(projectId, customName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsEditing(false);
@@ -815,24 +833,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
});
const handleStartEdit = useCallback(() => {
setValue(project.projectCustomName ?? "");
setValue(projectCustomName ?? "");
setIsEditing(true);
}, [project.projectCustomName]);
}, [projectCustomName]);
const handleCancel = useCallback(() => {
setIsEditing(false);
setValue(project.projectCustomName ?? "");
}, [project.projectCustomName]);
setValue(projectCustomName ?? "");
}, [projectCustomName]);
const handleSave = useCallback(() => {
const trimmed = value.trim();
const next = trimmed.length === 0 ? null : trimmed;
if (next === (project.projectCustomName ?? null)) {
if (next === projectCustomName) {
setIsEditing(false);
return;
}
renameMutation.mutate(next);
}, [value, project.projectCustomName, renameMutation]);
}, [value, projectCustomName, renameMutation]);
const handleReset = useCallback(() => {
renameMutation.mutate(null);
@@ -842,7 +860,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
return (
<View style={styles.nameEditorRow}>
<Text style={styles.projectTitle} numberOfLines={1}>
{project.projectName}
{projectName}
</Text>
<Pressable
testID="project-name-edit-button"
@@ -853,7 +871,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
>
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
</Pressable>
{project.projectCustomName ? (
{projectCustomName ? (
<Pressable
testID="project-name-reset-button"
accessibilityLabel={t("settings.project.rename.resetLabel")}
@@ -876,7 +894,7 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
accessibilityLabel={t("settings.project.rename.projectNameLabel")}
value={value}
onChangeText={setValue}
placeholder={project.projectName}
placeholder={projectName}
placeholderTextColor={styles.placeholderColor.color}
autoFocus
style={styles.nameEditorInput}

View File

@@ -221,6 +221,8 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
function hostEntry(overrides: Partial<ProjectHostEntry> = {}): ProjectHostEntry {
return {
serverId: "host-a",
projectName: "Project",
projectCustomName: null,
serverName: "alpha",
isOnline: true,
repoRoot: "/home/me/proj",

View File

@@ -8,6 +8,10 @@ import { ProjectIconView } from "@/components/project-icon-view";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import {
findProjectSettingsTarget,
resolveProjectSettingsRouteKey,
} from "@/projects/project-settings-target";
import { settingsStyles } from "@/styles/settings";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import type { ProjectSummary } from "@/utils/projects";
@@ -19,7 +23,10 @@ interface ProjectsScreenProps {
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
const { t } = useTranslation();
const { projects, hostErrors, isLoading } = useProjects();
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
const selectedProjectKey =
view.kind === "project"
? (findProjectSettingsTarget(projects, view.projectKey)?.projectKey ?? null)
: null;
const iconTargets = useMemo(
() =>
projects.flatMap((project) => {
@@ -100,10 +107,11 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro
const { t } = useTranslation();
const { theme } = useUnistyles();
const { projectKey, projectName } = project;
const settingsRouteKey = resolveProjectSettingsRouteKey(project);
const handleNavigate = useCallback(() => {
router.navigate(buildProjectSettingsRoute(projectKey));
}, [projectKey]);
router.navigate(buildProjectSettingsRoute(settingsRouteKey));
}, [settingsRouteKey]);
const rowStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [

View File

@@ -109,7 +109,10 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId,
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {

View File

@@ -31,6 +31,7 @@ function createWorkspace(
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectKey: input.projectKey ?? input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
@@ -297,6 +298,7 @@ describe("workspace structure composition", () => {
});
const emptyProject: EmptyProjectDescriptor = {
projectId: "project-a",
projectKey: "project-a",
projectDisplayName: "Project A",
projectCustomName: null,
projectRootPath: "/repo/a",
@@ -351,6 +353,7 @@ describe("workspace structure composition", () => {
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
{
projectId: "empty-project",
projectKey: "empty-project",
projectDisplayName: "Empty Project",
projectCustomName: null,
projectRootPath: "/repo/empty",

View File

@@ -263,6 +263,41 @@ describe("normalizeWorkspaceDescriptor", () => {
mainRepoRoot: null,
},
});
expect(workspace.projectKey).toBe("remote:github.com/acme/app");
});
it("leaves legacy path-shaped placement keys host-local", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
projectId: "/repo/app",
projectDisplayName: "app",
projectRootPath: "/repo/app",
workspaceDirectory: "/repo/app",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
project: {
projectKey: "/repo/app",
projectName: "app",
checkout: {
cwd: "/repo/app",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
});
expect(workspace.projectKey).toBeNull();
});
});

View File

@@ -5,10 +5,21 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import {
appendSubmittedUserMessage,
handoffCreatedAgentUserMessageToStream,
removeSubmittedUserMessage,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import {
acceptMessageSubmission,
beginMessageSubmission,
observeAcceptedMessageSubmissionsRunning,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
type MessageSubmissionRecord,
type MessageSubmissionRejectionOutcome,
} from "@/composer/submission/model";
import type { PendingPermission } from "@/types/shared";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
@@ -127,6 +138,7 @@ export interface Agent {
export interface WorkspaceDescriptor {
id: string;
projectId: string;
projectKey?: string | null;
projectDisplayName: string;
projectCustomName?: string | null;
projectRootPath: string;
@@ -158,6 +170,7 @@ export function normalizeWorkspaceDescriptor(
return {
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
projectKey: normalizeWorkspaceProjectKey(payload),
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -182,8 +195,18 @@ export function normalizeWorkspaceDescriptor(
};
}
function normalizeWorkspaceProjectKey(payload: WorkspaceDescriptorPayload): string | null {
if (payload.projectKey !== undefined) return payload.projectKey;
// COMPAT(projectKey): added in v0.2.4 on 2026-07-29; remove after 2027-01-29.
// Older daemons used remote-shaped placement keys for cross-host identity. Their path-shaped
// placement keys remain absent here so resolveProjectKey can scope them to the host.
const legacyProjectKey = payload.project?.projectKey;
return legacyProjectKey?.startsWith("remote:") ? legacyProjectKey : null;
}
export interface EmptyProjectDescriptor {
projectId: string;
projectKey?: string | null;
projectDisplayName: string;
projectCustomName: string | null;
projectRootPath: string;
@@ -195,6 +218,7 @@ export function normalizeEmptyProjectDescriptor(
): EmptyProjectDescriptor {
return {
projectId: payload.projectId,
projectKey: payload.projectKey ?? null,
projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null,
projectRootPath: payload.projectRootPath,
@@ -242,6 +266,7 @@ function emptyProjectDescriptorFromWorkspace(
): EmptyProjectDescriptor {
return {
projectId: workspace.projectId,
...(workspace.projectKey ? { projectKey: workspace.projectKey } : {}),
projectDisplayName: workspace.projectDisplayName,
projectCustomName: workspace.projectCustomName ?? null,
projectRootPath: workspace.projectRootPath,
@@ -368,6 +393,7 @@ export interface SessionState {
// Stream state (head/tail model)
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
@@ -459,8 +485,28 @@ interface SessionStoreActions {
setAgentStreamState: (
serverId: string,
agentId: string,
state: { tail?: StreamItem[]; head?: StreamItem[] },
state: {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
},
) => void;
beginAgentMessageSubmission: (
serverId: string,
agentId: string,
message: UserMessageItem,
) => void;
acceptAgentMessageSubmission: (
serverId: string,
agentId: string,
clientMessageId: string,
outOfBand: boolean | undefined,
) => void;
rejectAgentMessageSubmission: (
serverId: string,
agentId: string,
clientMessageId: string,
) => MessageSubmissionRejectionOutcome;
handoffCreatedAgentUserMessage: (
serverId: string,
agentId: string,
@@ -567,6 +613,27 @@ type SessionStore = SessionStoreState & SessionStoreActions;
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
function applyRunningAgentsToAcceptedSubmissions(input: {
previousAgents: Map<string, Agent>;
nextAgents: Map<string, Agent>;
submissions: Map<string, MessageSubmissionRecord[]>;
}): Map<string, MessageSubmissionRecord[]> {
let nextSubmissions = input.submissions;
for (const [agentId, submissions] of input.submissions) {
const previousAgent = input.previousAgents.get(agentId);
const nextAgent = input.nextAgents.get(agentId);
if (!nextAgent || previousAgent?.status === "running" || nextAgent.status !== "running") {
continue;
}
const remaining = observeAcceptedMessageSubmissionsRunning(submissions);
if (remaining === submissions) continue;
if (nextSubmissions === input.submissions) nextSubmissions = new Map(input.submissions);
if (remaining.length > 0) nextSubmissions.set(agentId, remaining);
else nextSubmissions.delete(agentId);
}
return nextSubmissions;
}
// Helper to create initial session state
function createInitialSessionState(
serverId: string,
@@ -588,6 +655,7 @@ function createInitialSessionState(
currentAssistantMessage: "",
agentStreamTail: new Map(),
agentStreamHead: new Map(),
messageSubmissions: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
@@ -1047,10 +1115,27 @@ export const useSessionStore = create<SessionStore>()(
}
}
if (!changedTail && !changedHead) {
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
state.acknowledgedClientMessageIds ?? [],
);
const changedSubmissions = observedSubmissions !== currentSubmissions;
if (!changedTail && !changedHead && !changedSubmissions) {
return prev;
}
let messageSubmissions = session.messageSubmissions;
if (changedSubmissions) {
messageSubmissions = new Map(session.messageSubmissions);
if (observedSubmissions.length > 0) {
messageSubmissions.set(agentId, observedSubmissions);
} else {
messageSubmissions.delete(agentId);
}
}
return {
...prev,
sessions: {
@@ -1059,12 +1144,129 @@ export const useSessionStore = create<SessionStore>()(
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
messageSubmissions,
},
},
};
});
},
beginAgentMessageSubmission: (serverId, agentId, message) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
if (!message.clientMessageId) {
throw new Error("Beginning a message submission requires client identity");
}
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const stream = appendSubmittedUserMessage({
tail: currentTail,
head: currentHead,
message,
});
const submissions = beginMessageSubmission(
session.messageSubmissions.get(agentId) ?? [],
{ clientMessageId: message.clientMessageId, submittedAt: message.timestamp },
);
const messageSubmissions = new Map(session.messageSubmissions);
messageSubmissions.set(agentId, submissions);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail:
stream.tail === currentTail
? session.agentStreamTail
: new Map(session.agentStreamTail).set(agentId, stream.tail),
agentStreamHead:
stream.head === currentHead
? session.agentStreamHead
: new Map(session.agentStreamHead).set(agentId, stream.head),
messageSubmissions,
},
},
};
});
},
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId, outOfBand) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const submissions = acceptMessageSubmission(
currentSubmissions,
clientMessageId,
session.agents.get(agentId)?.status === "running",
outOfBand,
);
if (submissions === currentSubmissions) return prev;
const messageSubmissions = new Map(session.messageSubmissions);
if (submissions.length > 0) {
messageSubmissions.set(agentId, submissions);
} else {
messageSubmissions.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, messageSubmissions },
},
};
});
},
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
let outcome: MessageSubmissionRejectionOutcome = "unknown";
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
outcome = result.outcome;
if (outcome === "unknown") return prev;
const stream =
outcome === "rejected"
? removeSubmittedUserMessage({
tail: currentTail,
head: currentHead,
clientMessageId,
})
: { tail: currentTail, head: currentHead };
const messageSubmissions = new Map(session.messageSubmissions);
if (result.submissions.length > 0) {
messageSubmissions.set(agentId, result.submissions);
} else {
messageSubmissions.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail:
stream.tail === currentTail
? session.agentStreamTail
: new Map(session.agentStreamTail).set(agentId, stream.tail),
agentStreamHead:
stream.head === currentHead
? session.agentStreamHead
: new Map(session.agentStreamHead).set(agentId, stream.head),
messageSubmissions,
},
},
};
});
return outcome;
},
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
let didHandoff = false;
set((prev) => {
@@ -1298,7 +1500,12 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
const nextAgents = typeof agents === "function" ? agents(session.agents) : agents;
if (session.agents === nextAgents) {
const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({
previousAgents: session.agents,
nextAgents,
submissions: session.messageSubmissions,
});
if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) {
return prev;
}
return {
@@ -1308,10 +1515,11 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
agents: nextAgents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
nextAgents,
session.workspaceAgentActivity,
),
messageSubmissions,
workspaceAgentActivity:
nextAgents === session.agents
? session.workspaceAgentActivity
: buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity),
},
},
};

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import {
buildOptimisticUserMessage,
createUserMessage,
hydrateStreamState,
type AgentToolCallItem,
type StreamItem,
@@ -119,12 +119,12 @@ function makeAssistantItem(
};
}
function makeOptimisticUserMessage(
function makeSubmittedUserMessage(
text: string,
id = `optimistic-${text.length}`,
id = `submitted-${text.length}`,
): Extract<StreamItem, { kind: "user_message" }> {
return buildOptimisticUserMessage({
id,
return createUserMessage({
clientMessageId: id,
text,
timestamp: new Date(1000),
});
@@ -172,6 +172,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
isInitializing: false,
hasActiveInitDeferred: false,
initRequestDirection: "tail",
sendingClientMessageIds: [],
};
const baseStreamInput: ProcessAgentStreamEventInput = {
@@ -181,7 +182,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
timestamp: new Date(2000),
};
@@ -293,6 +293,75 @@ describe("processTimelineResponse", () => {
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("keeps a live assistant and submitted head prompt in one lane during replacement", () => {
const submitted = makeSubmittedUserMessage("New prompt", "client-new-prompt");
const liveAssistant = {
...makeAssistantItem("Live answer", "answer-1"),
messageId: "answer-1",
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [],
currentHead: [liveAssistant, submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["client-new-prompt"],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
{
...makeTimelineEntry(1, "Live", "assistant_message"),
item: {
type: "assistant_message",
text: "Live",
messageId: "answer-1",
},
},
],
},
});
expect(result.tail).toEqual([]);
expect(result.head).toEqual([{ ...liveAssistant, text: "Live" }, submitted]);
});
it("preserves newer live head items when canonical replacement ends in a tool call", () => {
const liveThought: StreamItem = {
kind: "thought",
id: "live-thought",
text: "newer reasoning",
timestamp: new Date(3000),
status: "loading",
};
const liveAssistant = makeAssistantItem("newer answer", "live-answer");
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: [liveThought, liveAssistant],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
makeToolCallTimelineEntry(1, "canonical-call", "completed", {
type: "read",
filePath: "/tmp/older.ts",
}),
],
},
});
expect(result.tail.map((item) => item.kind)).toEqual(["tool_call"]);
expect(result.head).toEqual([liveThought, liveAssistant]);
});
it("uses the timeline entry timestamp as canonical", () => {
const result = processTimelineResponse({
...baseTimelineInput,
@@ -321,12 +390,12 @@ describe("processTimelineResponse", () => {
expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z");
});
it("reconciles an optimistic user message during tail replacement", () => {
it("reconciles a submitted user message during tail replacement", () => {
const image = {
id: "optimistic-image",
id: "submitted-image",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "optimistic-image",
storageKey: "submitted-image",
createdAt: 1000,
};
const attachment = {
@@ -335,8 +404,8 @@ describe("processTimelineResponse", () => {
text: "attached context",
title: "context.txt",
};
const optimistic = buildOptimisticUserMessage({
id: "optimistic-create-user",
const submitted = createUserMessage({
clientMessageId: "submitted-create-user",
text: "Analyze this",
timestamp: new Date(1000),
images: [image],
@@ -345,7 +414,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
payload: {
...baseTimelineInput.payload,
reset: true,
@@ -358,6 +427,7 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "canonical-create-user",
clientMessageId: "submitted-create-user",
},
},
],
@@ -367,13 +437,14 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]).toMatchObject({
id: "canonical-create-user",
id: "submitted-create-user",
clientMessageId: "submitted-create-user",
messageId: "canonical-create-user",
text: "Analyze this",
timestamp: new Date(1000),
images: [image],
attachments: [attachment],
});
expect(userMessages[0]?.optimistic).toBeUndefined();
const repeated = processTimelineResponse({
...baseTimelineInput,
@@ -390,6 +461,7 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "canonical-create-user",
clientMessageId: "submitted-create-user",
},
},
],
@@ -399,24 +471,93 @@ describe("processTimelineResponse", () => {
expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages);
});
it("keeps an unmatched optimistic user message during tail replacement", () => {
const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched");
it("keeps an unmatched submitted user message during tail replacement", () => {
const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["submitted-unmatched"],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([optimistic]);
expect(result.tail).toEqual([submitted]);
});
it("does not move an unmatched submission during timeline replacement", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
it("keeps every unresolved submission during replacement", () => {
const first = makeSubmittedUserMessage("first pending", "client-first");
const second = makeSubmittedUserMessage("second pending", "client-second");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [first, second],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["client-first", "client-second"],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([first, second]);
});
it("drops an acknowledged local row omitted by a same-epoch replacement", () => {
const acknowledged = createUserMessage({
clientMessageId: "client-local-only",
text: "provider may not echo this",
timestamp: new Date(1000),
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [acknowledged],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: [],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-1",
entries: [],
},
});
expect(result.tail).toEqual([]);
});
it("drops an acknowledged local row omitted by a known epoch change", () => {
const acknowledged = createUserMessage({
clientMessageId: "client-prior-epoch",
text: "prior prompt",
timestamp: new Date(1000),
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [acknowledged],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: [],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([]);
});
it("keeps an unmatched submission after the canonical replacement range", () => {
const unmatched = makeSubmittedUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
@@ -437,6 +578,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [unmatched, ...acknowledged],
sendingClientMessageIds: ["client-first"],
payload: {
...baseTimelineInput.payload,
reset: true,
@@ -462,10 +604,10 @@ describe("processTimelineResponse", () => {
},
},
{
...makeTimelineEntry(4, "response to all three submissions"),
...makeTimelineEntry(4, "response to canonical submissions"),
item: {
type: "assistant_message",
text: "response to all three submissions",
text: "response to canonical submissions",
messageId: "assistant-response",
},
},
@@ -480,14 +622,14 @@ describe("processTimelineResponse", () => {
text: "text" in item ? item.text : undefined,
})),
).toEqual([
{ kind: "user_message", id: "client-first", text: "first submission" },
{ kind: "user_message", id: "provider-second", text: "second submission" },
{ kind: "user_message", id: "provider-third", text: "third submission" },
{
kind: "assistant_message",
id: "assistant-response",
text: "response to all three submissions",
text: "response to canonical submissions",
},
{ kind: "user_message", id: "client-first", text: "first submission" },
]);
});
@@ -620,11 +762,11 @@ describe("processTimelineResponse", () => {
startSeq: 1,
endSeq: 1,
};
const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after");
const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
@@ -644,16 +786,19 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]?.id).toBe("canonical-after");
expect(userMessages[0]?.optimistic).toBeUndefined();
expect(userMessages[0]).toMatchObject({
id: "submitted-after",
clientMessageId: "submitted-after",
messageId: "canonical-after",
});
});
it("reconciles an optimistic user message by client message id", () => {
const optimistic = makeOptimisticUserMessage("local presentation", "client-message");
it("reconciles a submitted user message by client message id", () => {
const submitted = makeSubmittedUserMessage("local presentation", "client-message");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
...baseTimelineInput.payload,
@@ -675,20 +820,21 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toEqual([
expect.objectContaining({
id: "provider-message",
id: "client-message",
clientMessageId: "client-message",
messageId: "provider-message",
text: "local presentation",
}),
]);
expect(userMessages[0]?.optimistic).toBeUndefined();
expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]);
});
it("reconciles multiple optimistic user messages in canonical order", () => {
it("reconciles multiple submitted user messages in canonical order", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
makeOptimisticUserMessage("first prompt", "optimistic-first"),
makeOptimisticUserMessage("second prompt", "optimistic-second"),
makeSubmittedUserMessage("first prompt", "submitted-first"),
makeSubmittedUserMessage("second prompt", "submitted-second"),
],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
@@ -699,14 +845,14 @@ describe("processTimelineResponse", () => {
entries: [
{
...makeTimelineEntry(2, "first prompt", "user_message"),
item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" },
item: { type: "user_message", text: "first prompt", messageId: "submitted-first" },
},
{
...makeTimelineEntry(3, "second prompt", "user_message"),
item: {
type: "user_message",
text: "second prompt",
messageId: "optimistic-second",
messageId: "submitted-second",
},
},
],
@@ -716,15 +862,15 @@ describe("processTimelineResponse", () => {
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })),
.map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })),
).toEqual([
{ id: "optimistic-first", text: "first prompt", optimistic: undefined },
{ id: "optimistic-second", text: "second prompt", optimistic: undefined },
{ id: "submitted-first", text: "first prompt", messageId: "submitted-first" },
{ id: "submitted-second", text: "second prompt", messageId: "submitted-second" },
]);
});
it("keeps a tail optimistic prompt before a reconciled live assistant head", () => {
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
it("keeps a tail submitted prompt before a reconciled live assistant head", () => {
const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -763,8 +909,8 @@ describe("processTimelineResponse", () => {
).toEqual(["new prompt", "Hello"]);
});
it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => {
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
it("keeps a tail submitted prompt before a live head flushed by catch-up", () => {
const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -835,7 +981,6 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
const result = processTimelineResponse({
@@ -913,7 +1058,6 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
expect(getAssistantTexts(live.tail)).toHaveLength(1);
expect(getAssistantTexts(live.head)).toHaveLength(1);
@@ -961,7 +1105,6 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1000,7 +1143,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a submitted prompt when catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1024,7 +1167,7 @@ describe("processTimelineResponse", () => {
});
it("does not move an unmatched head prompt when catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1052,8 +1195,8 @@ describe("processTimelineResponse", () => {
]);
});
it("acknowledges a head prompt in place while catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
it("moves an acknowledged head prompt to its catch-up sequence position", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1084,18 +1227,18 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"assistant_message",
"user_message",
"tool_call",
"user_message",
]);
expect(
[...result.tail, ...result.head]
.filter((item) => item.kind === "user_message")
.map((item) => item.optimistic),
).toEqual([undefined]);
.map((item) => item.clientMessageId),
).toEqual(["new-prompt"]);
});
it("does not move a prompt around unrelated catch-up history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1141,7 +1284,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a prompt or its live answer around catch-up history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
@@ -1152,7 +1295,6 @@ describe("processTimelineResponse", () => {
currentTail: [prompt],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1186,7 +1328,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a prompt or its live answer around catch-up tool history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
@@ -1197,7 +1339,6 @@ describe("processTimelineResponse", () => {
currentTail: [prompt],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1228,7 +1369,7 @@ describe("processTimelineResponse", () => {
});
it("never moves submitted messages behind a later assistant response", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
const unmatched = makeSubmittedUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
@@ -1286,8 +1427,8 @@ describe("processTimelineResponse", () => {
]);
});
it("acknowledges a local prompt in place when a remote user row also arrives", () => {
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
it("places a local prompt after an earlier remote canonical row", () => {
const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1320,17 +1461,12 @@ describe("processTimelineResponse", () => {
});
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
).toEqual([
{ text: "Local prompt", optimistic: undefined },
{ text: "Remote prompt", optimistic: undefined },
]);
result.tail.filter((item) => item.kind === "user_message").map((item) => item.text),
).toEqual(["Remote prompt", "Local prompt"]);
});
it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => {
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => {
const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1355,17 +1491,12 @@ describe("processTimelineResponse", () => {
});
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
).toEqual([
{ text: "Local prompt", optimistic: true },
{ text: "Remote prompt", optimistic: undefined },
]);
result.tail.filter((item) => item.kind === "user_message").map((item) => item.text),
).toEqual(["Local prompt", "Remote prompt"]);
});
it("does not match equal prompt text when canonical client message ids differ", () => {
const prompt = makeOptimisticUserMessage("continue", "local-prompt");
const prompt = makeSubmittedUserMessage("continue", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1393,10 +1524,10 @@ describe("processTimelineResponse", () => {
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, optimistic: item.optimistic })),
.map((item) => ({ id: item.id, messageId: item.messageId })),
).toEqual([
{ id: "local-prompt", optimistic: true },
{ id: "remote-prompt", optimistic: undefined },
{ id: "local-prompt", messageId: undefined },
{ id: "remote-prompt", messageId: "remote-prompt" },
]);
});
@@ -1639,8 +1770,8 @@ describe("processTimelineResponse", () => {
});
});
it("does not reconcile an active optimistic user message from a before-page response", () => {
const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active");
it("does not reconcile an active submitted user message from a before-page response", () => {
const submitted = makeSubmittedUserMessage("active prompt", "submitted-active");
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
@@ -1649,7 +1780,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
@@ -1672,8 +1803,8 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(2);
expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]);
expect(userMessages[1]?.optimistic).toBe(true);
expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "submitted-active"]);
expect(userMessages[1]?.clientMessageId).toBe("submitted-active");
});
it("leaves the cursor alone when a before page makes no progress", () => {
@@ -1813,6 +1944,68 @@ describe("processTimelineResponse", () => {
]);
});
it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => {
const clientMessageId = "client-boundary-prompt";
const callId = "toolu_submitted_boundary";
const currentTail = [
makeSubmittedUserMessage("Inspect the file", clientMessageId),
...hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(3, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(3000),
},
],
{ source: "canonical" },
),
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
{
...makeTimelineEntry(1, "Inspect the file", "user_message"),
item: {
type: "user_message",
text: "Inspect the file",
messageId: "provider-boundary-prompt",
clientMessageId,
},
},
makeToolCallTimelineEntry(2, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
],
},
});
expect(result.tail.filter((item) => item.kind === "user_message")).toHaveLength(1);
expect(getAgentToolCalls(result.tail)).toEqual([
expect.objectContaining({
payload: expect.objectContaining({
data: expect.objectContaining({ callId, status: "completed" }),
}),
}),
]);
});
it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => {
const callId = "toolu_not_boundary";
const currentTail = hydrateStreamState(
@@ -2224,152 +2417,6 @@ describe("processAgentStreamEvent", () => {
endSeq: 1,
});
});
it("derives optimistic idle status on turn_completed for running agent", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent).not.toBe(null);
expect(result.agent!.status).toBe("idle");
expect(result.agent!.updatedAt.getTime()).toBe(2000);
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
});
it("derives optimistic error status on turn_failed for running agent", () => {
const turnFailedEvent: AgentStreamEventPayload = {
type: "turn_failed",
provider: "claude",
error: "something broke",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnFailedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.status).toBe("error");
});
it("does not derive optimistic idle status on turn_canceled for running agent", () => {
const turnCanceledEvent: AgentStreamEventPayload = {
type: "turn_canceled",
provider: "codex",
reason: "interrupted",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCanceledEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "idle",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when no agent is provided", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: null,
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("preserves updatedAt when agent timestamp is newer than event", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(5000),
lastActivityAt: new Date(5000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.updatedAt.getTime()).toBe(5000);
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
});
it("does not produce agent patch for non-terminal events", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("just text"),
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
seq: 1,
epoch: "epoch-1",
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
});
describe("processAgentStreamEvents", () => {
@@ -2382,7 +2429,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
@@ -2410,7 +2456,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
@@ -2433,7 +2478,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2451,7 +2495,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2469,7 +2512,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2501,7 +2543,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2527,7 +2568,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2552,7 +2592,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2644,7 +2683,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
@@ -2655,7 +2693,7 @@ describe("processAgentStreamEvents", () => {
]);
});
it("returns the final optimistic lifecycle patch across a batch", () => {
it("does not derive lifecycle state from a terminal event in a batch", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Done"), 1),
@@ -2669,21 +2707,10 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
});
expect(result.head).toEqual([]);
expect(result.tail).toHaveLength(1);
expect(result.agentChanged).toBe(true);
expect(result.agent).toMatchObject({
status: "idle",
updatedAt: new Date(3000),
lastActivityAt: new Date(3000),
});
});
it("keeps a live Claude assistant paragraph contiguous when init tail hydration lands mid-stream", () => {
@@ -2832,7 +2859,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail,
currentHead,
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
currentTail = result.tail;
@@ -2874,7 +2900,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(
@@ -2904,7 +2929,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail,
currentHead,
currentCursor,
currentAgent: null,
}),
commit: (_agentId, result) => {
currentTail = result.tail;
@@ -2954,7 +2978,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(

View File

@@ -1,15 +1,15 @@
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import type { AssistantMessageItem, StreamItem } from "@/types/stream";
import {
applyStreamEvent,
flushHeadToTail,
hydrateStreamState,
isAgentToolCallItem,
mergeAgentToolCallItem,
replaceWithCanonicalStream,
reduceStreamUpdate,
upsertUserMessageAcrossStream,
} from "@/types/stream";
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
@@ -88,6 +88,7 @@ export interface ProcessTimelineResponseInput {
isInitializing: boolean;
hasActiveInitDeferred: boolean;
initRequestDirection: InitRequestDirection;
sendingClientMessageIds: readonly string[];
}
export interface ProcessTimelineResponseOutput {
@@ -99,6 +100,7 @@ export interface ProcessTimelineResponseOutput {
clearInitializing: boolean;
error: string | null;
sideEffects: TimelineReducerSideEffect[];
acknowledgedClientMessageIds: string[];
}
interface TimelineUnit {
@@ -115,6 +117,7 @@ interface TimelinePathResult {
cursor: TimelineCursor | null | undefined;
cursorChanged: boolean;
sideEffects: TimelineReducerSideEffect[];
acknowledgedClientMessageIds: string[];
}
function classifySessionTimelineSeq({
@@ -201,76 +204,35 @@ function shouldResolveTimelineInit({
return responseDirection === initRequestDirection;
}
function deriveOptimisticLifecycleStatus(
currentStatus: AgentLifecycleStatus,
event: AgentStreamEventPayload,
): AgentLifecycleStatus | null {
if (currentStatus !== "running") {
return null;
}
switch (event.type) {
case "turn_completed":
return "idle";
case "turn_failed":
return "error";
case "turn_canceled":
// A canceled turn can be either a final user cancel or an interrupt before
// a replacement turn starts. The daemon snapshot is authoritative here.
return null;
default:
return null;
}
}
function preserveReplacePathAssistantHead(params: {
tail: StreamItem[];
currentHead: StreamItem[];
}): {
tail: StreamItem[];
head: StreamItem[];
} {
const { tail, currentHead } = params;
const liveAssistant = currentHead.findLast(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
if (!liveAssistant) {
return { tail, head: [] };
}
const tailAssistant = tail.at(-1);
if (!tailAssistant || tailAssistant.kind !== "assistant_message") {
return { tail, head: currentHead };
}
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
return { tail, head: [] };
}
return {
tail: tail.slice(0, -1),
head: [{ ...liveAssistant, text: tailAssistant.text }],
};
}
function applyTimelineReplacePath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
bootstrapPolicy: ReturnType<typeof deriveBootstrapTailTimelinePolicy>;
currentTail: StreamItem[];
currentHead: StreamItem[];
sendingClientMessageIds: readonly string[];
preserveLiveHead: boolean;
toHydratedEvents: (
units: TimelineUnit[],
) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>;
}): TimelinePathResult {
const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } =
args;
const {
timelineUnits,
payload,
bootstrapPolicy,
currentTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead,
toHydratedEvents,
} = args;
const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" });
const reconciledTail = reconcileLocalUserPresentationAfterReplace({
canonicalTail: hydratedTail,
const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({
canonical: hydratedTail,
previousTail: currentTail,
previousHead: currentHead,
});
const { tail, head } = preserveReplacePathAssistantHead({
tail: reconciledTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead,
});
const cursor: TimelineCursor | null =
payload.startCursor && payload.endCursor
@@ -284,136 +246,16 @@ function applyTimelineReplacePath(args: {
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({ type: "catch_up", cursor: bootstrapPolicy.catchUpCursor });
}
return { tail, head, cursor, cursorChanged: true, sideEffects };
}
function collectLocallyPresentedUserMessages(items: StreamItem[]): Array<{
ordinal: number;
item: UserMessageItem;
}> {
const localUsers: Array<{ ordinal: number; item: UserMessageItem }> = [];
let ordinal = 0;
for (const item of items) {
if (item.kind !== "user_message") {
continue;
}
if (item.optimistic || item.images?.length || item.attachments?.length) {
localUsers.push({ ordinal, item });
}
ordinal += 1;
}
return localUsers;
}
function mergeCanonicalUserWithLocalPresentation(
canonical: UserMessageItem,
local: UserMessageItem,
): UserMessageItem {
return {
kind: "user_message",
id: canonical.id,
...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}),
text: local.text,
timestamp: local.timestamp,
...(local.images && local.images.length > 0 ? { images: local.images } : {}),
...(local.attachments && local.attachments.length > 0
? { attachments: local.attachments }
: {}),
tail,
head,
cursor,
cursorChanged: true,
sideEffects,
acknowledgedClientMessageIds,
};
}
interface CanonicalUserMessageIdentity {
messageId?: string;
clientMessageId?: string;
text: string;
}
function matchesLocalUserMessageIdentity(
canonical: CanonicalUserMessageIdentity,
optimistic: UserMessageItem,
): boolean {
if (canonical.clientMessageId !== undefined) {
return canonical.clientMessageId === optimistic.id;
}
if (canonical.messageId === optimistic.id) {
return true;
}
// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once
// the supported daemon floor emits clientMessageId on submitted user messages.
return canonical.text.length > 0 && canonical.text === optimistic.text;
}
function reconcileLocalUserPresentationAfterReplace(params: {
canonicalTail: StreamItem[];
previousTail: StreamItem[];
previousHead: StreamItem[];
}): StreamItem[] {
const localUsers = collectLocallyPresentedUserMessages([
...params.previousTail,
...params.previousHead,
]);
if (localUsers.length === 0) {
return params.canonicalTail;
}
const canonicalUserIndexes: number[] = [];
params.canonicalTail.forEach((item, index) => {
if (item.kind === "user_message") {
canonicalUserIndexes.push(index);
}
});
const nextTail = [...params.canonicalTail];
const claimedCanonicalIndexes = new Set<number>();
const unmatched: UserMessageItem[] = [];
for (const local of localUsers) {
const exactIndex = canonicalUserIndexes.find((index) => {
if (claimedCanonicalIndexes.has(index)) return false;
const canonical = params.canonicalTail[index];
return (
canonical?.kind === "user_message" &&
matchesLocalUserMessageIdentity(
{
messageId: canonical.id,
clientMessageId: canonical.clientMessageId,
text: canonical.text,
},
local.item,
)
);
});
const ordinalIndex = canonicalUserIndexes[local.ordinal];
const ordinalItem = ordinalIndex === undefined ? undefined : params.canonicalTail[ordinalIndex];
const canonicalIndex =
exactIndex ??
(ordinalIndex !== undefined &&
!claimedCanonicalIndexes.has(ordinalIndex) &&
ordinalItem?.kind === "user_message" &&
ordinalItem.clientMessageId === undefined
? ordinalIndex
: undefined);
const canonicalItem = canonicalIndex === undefined ? undefined : nextTail[canonicalIndex];
if (canonicalIndex === undefined || !canonicalItem || canonicalItem.kind !== "user_message") {
if (local.item.optimistic) {
unmatched.push(local.item);
}
continue;
}
nextTail[canonicalIndex] = mergeCanonicalUserWithLocalPresentation(canonicalItem, local.item);
claimedCanonicalIndexes.add(canonicalIndex);
}
for (const item of unmatched) {
const insertionIndex = nextTail.findIndex(
(canonical) => canonical.timestamp.getTime() > item.timestamp.getTime(),
);
nextTail.splice(insertionIndex < 0 ? nextTail.length : insertionIndex, 0, item);
}
return nextTail;
}
interface IncrementalAcceptResult {
acceptedUnits: TimelineUnit[];
cursor: TimelineCursor | undefined;
@@ -515,6 +357,34 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
return olderTail;
}
const remainingOlder: StreamItem[] = [];
let reconciledCurrent = currentTail;
for (const item of olderTail) {
if (item.kind !== "user_message") {
remainingOlder.push(item);
continue;
}
const result = upsertUserMessageAcrossStream({
tail: reconciledCurrent,
head: [],
message: item,
insert: "prepend-tail",
presentation: "existing",
});
if (result.location?.matched) {
remainingOlder.push(result.location.message);
reconciledCurrent = [
...result.tail.slice(0, result.location.index),
...result.tail.slice(result.location.index + 1),
];
} else {
remainingOlder.push(item);
}
}
olderTail = remainingOlder;
currentTail = reconciledCurrent;
if (olderTail.length === 0) return currentTail;
const olderLast = olderTail.at(-1);
const currentFirst = currentTail[0];
@@ -745,9 +615,24 @@ function applyCanonicalForwardUnit(params: {
head: StreamItem[];
unit: TimelineUnit;
epoch: string;
}): { tail: StreamItem[]; head: StreamItem[] } {
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
const { event, timestamp, seqEnd } = params.unit;
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
if (event.type === "timeline" && event.item.type === "user_message") {
const applied = applyStreamEvent({
tail: params.tail,
head: params.head,
event,
timestamp,
source: "canonical",
timelineCursor,
});
return {
tail: applied.tail,
head: applied.head,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
};
}
if (params.head.length === 0) {
return {
tail: reduceStreamUpdate(params.tail, event, timestamp, {
@@ -755,6 +640,7 @@ function applyCanonicalForwardUnit(params: {
timelineCursor,
}),
head: params.head,
acknowledgedClientMessageIds: [],
};
}
const replacedHead = replaceLiveAssistantWithProjectedText({
@@ -763,7 +649,9 @@ function applyCanonicalForwardUnit(params: {
timestamp,
timelineCursor,
});
if (replacedHead) return { tail: params.tail, head: replacedHead };
if (replacedHead) {
return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] };
}
const activeAssistant = params.head.findLast(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
@@ -781,6 +669,7 @@ function applyCanonicalForwardUnit(params: {
source: "canonical",
timelineCursor,
}),
acknowledgedClientMessageIds: [],
};
}
@@ -792,7 +681,11 @@ function applyCanonicalForwardUnit(params: {
source: "canonical",
timelineCursor,
});
return { tail: applied.tail, head: applied.head };
return {
tail: applied.tail,
head: applied.head,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
};
}
function applyAcceptedForwardTimelineUnits(params: {
@@ -801,7 +694,7 @@ function applyAcceptedForwardTimelineUnits(params: {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentEndSeq: number | undefined;
}): { tail: StreamItem[]; head: StreamItem[] } {
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
const reconciled = reconcileOverlappingProjectedStreamItems({
tail: params.currentTail,
head: params.currentHead,
@@ -811,15 +704,19 @@ function applyAcceptedForwardTimelineUnits(params: {
});
let tail = reconciled.tail;
let head = reconciled.head;
const acknowledgedClientMessageIds = new Set<string>();
for (const unit of params.units) {
if (reconciled.reconciledUnits.has(unit)) continue;
const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch });
tail = applied.tail;
head = applied.head;
for (const clientMessageId of applied.acknowledgedClientMessageIds) {
acknowledgedClientMessageIds.add(clientMessageId);
}
}
return { tail, head };
return { tail, head, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] };
}
function applyTimelineIncrementalPath(args: {
@@ -835,9 +732,17 @@ function applyTimelineIncrementalPath(args: {
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
let acknowledgedClientMessageIds: string[] = [];
if (timelineUnits.length === 0) {
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
sideEffects,
acknowledgedClientMessageIds,
};
}
const { acceptedUnits, cursor, gapCursor } =
@@ -874,6 +779,7 @@ function applyTimelineIncrementalPath(args: {
});
nextTail = applied.tail;
nextHead = applied.head;
acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds;
}
}
@@ -892,7 +798,14 @@ function applyTimelineIncrementalPath(args: {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
sideEffects,
acknowledgedClientMessageIds,
};
}
export function processTimelineResponse(
@@ -906,6 +819,7 @@ export function processTimelineResponse(
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
sendingClientMessageIds,
} = input;
// ------------------------------------------------------------------
@@ -921,6 +835,7 @@ export function processTimelineResponse(
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
acknowledgedClientMessageIds: [],
};
}
@@ -967,7 +882,6 @@ export function processTimelineResponse(
hasActiveInitDeferred,
});
const replace = bootstrapPolicy.replace;
const sideEffects: TimelineReducerSideEffect[] = [];
const timelineResult = replace
? applyTimelineReplacePath({
@@ -976,6 +890,8 @@ export function processTimelineResponse(
bootstrapPolicy,
currentTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead: currentCursor?.epoch === payload.epoch,
toHydratedEvents,
})
: applyTimelineIncrementalPath({
@@ -1024,6 +940,7 @@ export function processTimelineResponse(
clearInitializing,
error: null,
sideEffects,
acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds,
};
}
@@ -1038,20 +955,9 @@ export interface ProcessAgentStreamEventInput {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
currentAgent: {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
} | null;
timestamp: Date;
}
export interface AgentPatch {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
}
export interface ProcessAgentStreamEventOutput {
tail: StreamItem[];
head: StreamItem[];
@@ -1059,8 +965,7 @@ export interface ProcessAgentStreamEventOutput {
changedHead: boolean;
cursor: TimelineCursor | null;
cursorChanged: boolean;
agent: AgentPatch | null;
agentChanged: boolean;
acknowledgedClientMessageIds: string[];
sideEffects: AgentStreamReducerSideEffect[];
}
@@ -1079,18 +984,11 @@ interface TimelineSequencingGateResult {
sideEffects: AgentStreamReducerSideEffect[];
}
export interface AgentStreamReducerAgentSnapshot {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
}
export interface ProcessAgentStreamEventsInput {
events: AgentStreamReducerEvent[];
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
currentAgent: AgentStreamReducerAgentSnapshot | null;
}
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
@@ -1114,20 +1012,6 @@ export interface CreateAgentStreamReducerQueueInput {
cancelFlush: (id: number) => void;
}
function applyAgentPatch(
currentAgent: AgentStreamReducerAgentSnapshot | null,
patch: AgentPatch | null,
): AgentStreamReducerAgentSnapshot | null {
if (!currentAgent || !patch) {
return currentAgent;
}
return {
status: patch.status,
updatedAt: patch.updatedAt,
lastActivityAt: patch.lastActivityAt,
};
}
function processTimelineSequencingGate(input: {
event: AgentStreamEventPayload;
seq: number | undefined;
@@ -1201,8 +1085,7 @@ function processTimelineSequencingGate(input: {
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
const { event, seq, epoch, currentTail, currentHead, currentCursor, timestamp } = input;
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
const timelineCursor =
@@ -1213,7 +1096,7 @@ export function processAgentStreamEvent(
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
const applied = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
@@ -1229,43 +1112,14 @@ export function processAgentStreamEvent(
changedHead: false,
};
// ------------------------------------------------------------------
// Optimistic lifecycle status
// ------------------------------------------------------------------
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
if (
currentAgent &&
(event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed")
) {
const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event);
if (optimisticStatus) {
const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime());
const nextLastActivityAtMs = Math.max(
currentAgent.lastActivityAt.getTime(),
timestamp.getTime(),
);
agentPatch = {
status: optimisticStatus,
updatedAt: new Date(nextUpdatedAtMs),
lastActivityAt: new Date(nextLastActivityAtMs),
};
agentChanged = true;
}
}
return {
tail,
head,
changedTail,
changedHead,
tail: applied.tail,
head: applied.head,
changedTail: applied.changedTail,
changedHead: applied.changedHead,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
agent: agentPatch,
agentChanged,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
sideEffects: sequencing.sideEffects,
};
}
@@ -1276,12 +1130,10 @@ export function processAgentStreamEvents(
let tail = input.currentTail;
let head = input.currentHead;
let cursor = input.currentCursor;
let agent = input.currentAgent;
let changedTail = false;
let changedHead = false;
let cursorChanged = false;
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
const acknowledgedClientMessageIds = new Set<string>();
const sideEffects: AgentStreamReducerSideEffect[] = [];
for (const reducerEvent of input.events) {
@@ -1292,7 +1144,6 @@ export function processAgentStreamEvents(
currentTail: tail,
currentHead: head,
currentCursor: cursor,
currentAgent: agent,
timestamp: reducerEvent.timestamp,
});
@@ -1301,17 +1152,14 @@ export function processAgentStreamEvents(
changedTail = changedTail || result.changedTail;
changedHead = changedHead || result.changedHead;
sideEffects.push(...result.sideEffects);
for (const clientMessageId of result.acknowledgedClientMessageIds) {
acknowledgedClientMessageIds.add(clientMessageId);
}
if (result.cursorChanged) {
cursor = result.cursor ?? undefined;
cursorChanged = true;
}
if (result.agentChanged) {
agentPatch = result.agent;
agentChanged = true;
agent = applyAgentPatch(agent, result.agent);
}
}
return {
@@ -1321,8 +1169,7 @@ export function processAgentStreamEvents(
changedHead,
cursor: cursor ?? null,
cursorChanged,
agent: agentPatch,
agentChanged,
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
sideEffects,
};
}
@@ -1405,6 +1252,7 @@ export function createAgentStreamReducerQueue(
interface StreamStatePatch {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
}
export interface CreateSessionAgentStreamReducerQueueInput {
@@ -1414,7 +1262,6 @@ export interface CreateSessionAgentStreamReducerQueueInput {
serverId: string,
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
) => void;
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
}
@@ -1429,31 +1276,29 @@ function cancelAgentStreamReducerFlush(id: number) {
export function createSessionAgentStreamReducerQueue(
input: CreateSessionAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } =
input;
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input;
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
const session = useSessionStore.getState().sessions[serverId];
const currentAgentEntry = session?.agents.get(agentId);
return {
currentTail: session?.agentStreamTail.get(agentId) ?? [],
currentHead: session?.agentStreamHead.get(agentId) ?? [],
currentCursor: session?.agentTimelineCursor.get(agentId),
currentAgent: currentAgentEntry
? {
status: currentAgentEntry.status,
updatedAt: currentAgentEntry.updatedAt,
lastActivityAt: currentAgentEntry.lastActivityAt,
}
: null,
};
},
commit: (agentId, result, events) => {
if (result.changedTail || result.changedHead) {
if (
result.changedTail ||
result.changedHead ||
result.acknowledgedClientMessageIds.length > 0
) {
setAgentStreamState(serverId, agentId, {
...(result.changedTail ? { tail: result.tail } : {}),
...(result.changedHead ? { head: result.head } : {}),
...(result.acknowledgedClientMessageIds.length > 0
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
: {}),
});
}
@@ -1486,24 +1331,6 @@ export function createSessionAgentStreamReducerQueue(
return next;
});
}
if (result.agentChanged && result.agent) {
const nextAgent = result.agent;
setAgents(serverId, (prev) => {
const current = prev.get(agentId);
if (!current) {
return prev;
}
const next = new Map(prev);
next.set(agentId, {
...current,
status: nextAgent.status,
updatedAt: nextAgent.updatedAt,
lastActivityAt: nextAgent.lastActivityAt,
});
return next;
});
}
},
handleSideEffects: (agentId, sideEffects) => {
for (const effect of sideEffects) {

View File

@@ -22,34 +22,16 @@ function assistant(id: string, timestamp: Date): StreamItem {
}
describe("deriveStreamTurnTiming", () => {
it("reserves a running footer for an optimistic prompt before the host starts the turn", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
const timing = deriveStreamTurnTiming({
agentStatus: "idle",
tail: [],
head: [optimisticPrompt],
});
assert.equal(timing.isActive, true);
});
it("does not start elapsed time from an optimistic prompt", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
it("starts elapsed time from the submitted prompt", () => {
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
const timing = deriveStreamTurnTiming({
agentStatus: "running",
tail: [],
head: [optimisticPrompt],
head: [user("submitted", submittedAt)],
});
assert.equal(timing.runningStartedAt, null);
assert.equal(timing.runningStartedAt, submittedAt);
});
it("uses the last user message as the running turn start", () => {

View File

@@ -9,7 +9,6 @@ export interface TurnTiming {
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
isActive: boolean;
}
export function deriveStreamTurnTiming(params: {
@@ -19,8 +18,6 @@ export function deriveStreamTurnTiming(params: {
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentAuthoritativeUserAt: Date | null = null;
let currentUserIsOptimistic = false;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
@@ -42,8 +39,6 @@ export function deriveStreamTurnTiming(params: {
if (item.kind === "user_message") {
flushCompletedTurn();
currentUserAt = item.timestamp;
currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp;
currentUserIsOptimistic = item.optimistic === true;
currentLastItemAt = null;
currentAssistantIds = [];
return;
@@ -65,7 +60,7 @@ export function deriveStreamTurnTiming(params: {
}
const isRunning = params.agentStatus === "running";
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
const runningStartedAt = isRunning ? currentUserAt : null;
if (params.agentStatus !== "running") {
flushCompletedTurn();
}
@@ -73,6 +68,5 @@ export function deriveStreamTurnTiming(params: {
return {
byAssistantId,
runningStartedAt,
isActive: isRunning || currentUserIsOptimistic,
};
}

View File

@@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest";
import {
applyStreamEvent,
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
clearOptimisticUserMessages,
createUserMessage,
handoffCreatedAgentUserMessageToStream,
hydrateStreamState,
mergeToolCallDetail,
@@ -14,6 +12,8 @@ import {
type AgentToolCallItem,
type StreamItem,
isAgentToolCallItem,
upsertUserMessage,
upsertUserMessageAcrossStream,
} from "./stream";
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
@@ -21,6 +21,109 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
describe("user message identity", () => {
it("adds provider identity without replacing local presentation", () => {
const timestamp = new Date("2026-07-26T10:00:00.000Z");
const local = createUserMessage({
clientMessageId: "client-1",
text: "local text",
timestamp,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1.png",
createdAt: timestamp.getTime(),
},
],
attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }],
});
const canonical = createUserMessage({
id: "provider-1",
messageId: "provider-1",
clientMessageId: "client-1",
text: "provider text",
timestamp: new Date("2026-07-26T10:00:01.000Z"),
});
const first = upsertUserMessage([local], canonical);
const second = upsertUserMessage(first, canonical);
expect(first).toEqual([
{
...local,
messageId: "provider-1",
clientMessageId: "client-1",
},
]);
expect(first[0]).toBe(second[0]);
});
it("keeps local presentation when a later canonical row omits provider identity", () => {
const timestamp = new Date("2026-07-27T10:00:00.000Z");
const local = createUserMessage({
clientMessageId: "client-1",
messageId: "provider-1",
text: "local text",
timestamp,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1.png",
createdAt: timestamp.getTime(),
},
],
attachments: [{ type: "text", mimeType: "text/plain", text: "local attachment" }],
});
const canonicalWithoutProviderIdentity = createUserMessage({
id: "canonical-page-row",
clientMessageId: "client-1",
text: "provider-shaped text",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
});
const result = upsertUserMessage([local], canonicalWithoutProviderIdentity);
expect(result).toEqual([local]);
});
it("matches a submitted message against a legacy canonical row that has no client identity", () => {
// Daemons before v0.2.0 do not echo clientMessageId. During agent creation the
// legacy canonical row can land before the local submission is handed off, so the
// submitted row arrives as `incoming` and must still match by text.
const timestamp = new Date("2026-07-27T11:00:00.000Z");
const legacyCanonical = createUserMessage({
id: "provider-1",
messageId: "provider-1",
text: "review this",
timestamp,
});
const submitted = createUserMessage({
clientMessageId: "client-1",
text: "review this",
timestamp: new Date("2026-07-27T11:00:01.000Z"),
attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }],
});
const result = handoffCreatedAgentUserMessageToStream({
tail: [legacyCanonical],
head: [],
message: submitted,
});
expect(result.tail).toEqual([
{
...submitted,
id: "client-1",
messageId: "provider-1",
},
]);
});
});
function assistantTimeline(
text: string,
provider: AgentProvider = "claude",
@@ -895,15 +998,15 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[0]?.text, "Task 1");
});
it("preserves optimistic user message images when authoritative user message arrives", () => {
it("preserves submitted user message images when authoritative user message arrives", () => {
const messageId = "msg-user-images";
const optimisticTimestamp = new Date("2025-01-01T11:10:00Z");
const optimisticImages = [
const submittedTimestamp = new Date("2025-01-01T11:10:00Z");
const submittedImages = [
{
id: "att-optimistic",
id: "att-submitted",
mimeType: "image/jpeg",
storageType: "native-file" as const,
storageKey: "/tmp/optimistic.jpg",
storageKey: "/tmp/submitted.jpg",
createdAt: Date.now(),
},
];
@@ -911,10 +1014,10 @@ describe("stream reducer canonical tool calls", () => {
{
kind: "user_message",
id: messageId,
clientMessageId: messageId,
text: "Analyze this image",
timestamp: optimisticTimestamp,
optimistic: true,
images: optimisticImages,
timestamp: submittedTimestamp,
images: submittedImages,
},
];
const event: AgentStreamEventPayload = {
@@ -933,9 +1036,9 @@ describe("stream reducer canonical tool calls", () => {
assert.ok(message);
assert.strictEqual(message.id, messageId);
assert.deepStrictEqual(message.images, optimisticImages);
assert.deepStrictEqual(message.images, submittedImages);
assert.strictEqual(message.text, "Analyze this image");
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime());
});
it("keeps canonical assistant/user/assistant order during replay", () => {
@@ -981,7 +1084,7 @@ describe("stream reducer canonical tool calls", () => {
);
});
it("keeps live optimistic assistant merge behavior", () => {
it("keeps live submitted assistant merge behavior", () => {
const state: StreamItem[] = [
{
kind: "assistant_message",
@@ -1117,23 +1220,23 @@ describe("turn lifecycle events", () => {
});
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
"replaces a submitted user message when a live %s provider-owned id echo arrives without text matching",
(provider) => {
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
const submittedTimestamp = new Date("2025-01-01T15:02:00Z");
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
const optimistic: StreamItem = {
const submitted: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
id: "msg_submitted",
clientMessageId: "msg_submitted",
text: "same user text",
timestamp: optimisticTimestamp,
optimistic: true,
timestamp: submittedTimestamp,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1",
createdAt: optimisticTimestamp.getTime(),
createdAt: submittedTimestamp.getTime(),
},
],
attachments: [
@@ -1147,7 +1250,7 @@ describe("turn lifecycle events", () => {
};
const state = reduceStreamUpdate(
[optimistic],
[submitted],
{
type: "timeline",
provider,
@@ -1155,6 +1258,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "server-owned rendered text",
messageId: "provider-owned-id",
clientMessageId: "msg_submitted",
},
},
serverTimestamp,
@@ -1165,28 +1269,28 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "provider-owned-id");
assert.strictEqual(userMessage.text, optimistic.text);
assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.deepStrictEqual(userMessage.images, optimistic.images);
assert.deepStrictEqual(userMessage.attachments, optimistic.attachments);
assert.strictEqual(userMessage.id, "msg_submitted");
assert.strictEqual(userMessage.messageId, "provider-owned-id");
assert.strictEqual(userMessage.text, submitted.text);
assert.strictEqual(userMessage.timestamp.getTime(), submitted.timestamp.getTime());
assert.deepStrictEqual(userMessage.images, submitted.images);
assert.deepStrictEqual(userMessage.attachments, submitted.attachments);
},
);
it("replaces one optimistic plain-text user message with the next live server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
it("replaces one submitted plain-text user message with the next live server user message", () => {
const submittedTimestamp = new Date("2025-01-01T15:03:00Z");
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
const optimistic: StreamItem = {
const submitted: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
id: "msg_submitted",
clientMessageId: "msg_submitted",
text: "typed plain text",
timestamp: optimisticTimestamp,
optimistic: true,
timestamp: submittedTimestamp,
};
const state = reduceStreamUpdate(
[optimistic],
[submitted],
{
type: "timeline",
provider: "opencode",
@@ -1204,20 +1308,20 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.id, "msg_submitted");
assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.text, "typed plain text");
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
});
it("replaces an optimistic image user message with the next canonical server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:10Z");
it("replaces a submitted image user message with the next canonical server user message", () => {
const submittedTimestamp = new Date("2025-01-01T15:03:10Z");
const image = {
id: "image-canonical",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-canonical",
createdAt: optimisticTimestamp.getTime(),
createdAt: submittedTimestamp.getTime(),
};
const attachment = {
type: "text" as const,
@@ -1225,16 +1329,16 @@ describe("turn lifecycle events", () => {
text: "context",
title: "context.txt",
};
const optimistic = buildOptimisticUserMessage({
id: "msg_optimistic_canonical",
const submitted = createUserMessage({
clientMessageId: "msg_submitted_canonical",
text: "Analyze this",
timestamp: optimisticTimestamp,
timestamp: submittedTimestamp,
images: [image],
attachments: [attachment],
});
const state = reduceStreamUpdate(
[optimistic],
[submitted],
{
type: "timeline",
provider: "claude",
@@ -1242,7 +1346,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "provider-owned-canonical",
clientMessageId: optimistic.id,
clientMessageId: submitted.id,
},
},
new Date("2025-01-01T15:03:11Z"),
@@ -1253,17 +1357,17 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "provider-owned-canonical");
assert.strictEqual(userMessage.id, "msg_submitted_canonical");
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
assert.strictEqual(userMessage.text, "Analyze this");
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
assert.deepStrictEqual(userMessage.images, [image]);
assert.deepStrictEqual(userMessage.attachments, [attachment]);
});
it("places optimistic user messages through one append helper", () => {
const optimistic = buildOptimisticUserMessage({
id: "msg_append_once",
it("places submitted user messages through the identity producer", () => {
const submitted = createUserMessage({
clientMessageId: "msg_append_once",
text: "append once",
timestamp: new Date("2025-01-01T15:03:20Z"),
});
@@ -1274,28 +1378,30 @@ describe("turn lifecycle events", () => {
timestamp: new Date("2025-01-01T15:03:19Z"),
};
const first = appendOptimisticUserMessageToStream({
const first = upsertUserMessageAcrossStream({
tail: [],
head: [headItem],
message: optimistic,
placement: "active-head",
message: submitted,
insert: "head",
presentation: "existing",
});
const second = appendOptimisticUserMessageToStream({
const second = upsertUserMessageAcrossStream({
tail: first.tail,
head: first.head,
message: optimistic,
placement: "active-head",
message: submitted,
insert: "head",
presentation: "existing",
});
assert.deepStrictEqual(first.tail, []);
assert.deepStrictEqual(first.head, [headItem, optimistic]);
assert.deepStrictEqual(first.head, [headItem, submitted]);
assert.strictEqual(second.changedHead, false);
assert.strictEqual(second.head, first.head);
});
it("hands rich optimistic content to an authoritative create message without duplicating it", () => {
it("hands rich submitted content to its create message without overwriting an earlier user row", () => {
const timestamp = new Date("2025-01-01T15:03:20Z");
const optimistic = buildOptimisticUserMessage({
id: "client-user",
const submitted = createUserMessage({
clientMessageId: "client-user",
text: "",
timestamp,
images: [
@@ -1317,32 +1423,44 @@ describe("turn lifecycle events", () => {
},
],
});
const precedingProviderRow: StreamItem = {
kind: "user_message",
id: "provider-system-user",
messageId: "provider-system-user",
text: "provider setup prompt",
timestamp: new Date("2025-01-01T15:03:20.500Z"),
};
const canonical: StreamItem = {
kind: "user_message",
id: "provider-user",
messageId: "provider-user",
clientMessageId: "client-user",
text: "server-rendered attachment text",
timestamp: new Date("2025-01-01T15:03:21Z"),
};
const handedOff = handoffCreatedAgentUserMessageToStream({
tail: [canonical],
tail: [precedingProviderRow, canonical],
head: [],
message: optimistic,
message: submitted,
});
const repeated = handoffCreatedAgentUserMessageToStream({
tail: handedOff.tail,
head: handedOff.head,
message: optimistic,
message: submitted,
});
assert.deepStrictEqual(handedOff.tail, [
precedingProviderRow,
{
kind: "user_message",
id: "provider-user",
text: optimistic.text,
timestamp: optimistic.timestamp,
images: optimistic.images,
attachments: optimistic.attachments,
id: "client-user",
clientMessageId: "client-user",
messageId: "provider-user",
text: submitted.text,
timestamp: submitted.timestamp,
images: submitted.images,
attachments: submitted.attachments,
},
]);
assert.deepStrictEqual(handedOff.head, []);
@@ -1364,22 +1482,22 @@ describe("turn lifecycle events", () => {
);
assert.deepStrictEqual(
afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id),
["provider-user", "provider-next-user"],
["provider-system-user", "client-user", "provider-next-user"],
);
});
it("reconciles an optimistic user message that was pending in the streaming head", () => {
const optimistic: StreamItem = {
it("flushes an interrupted head when its submitted prompt becomes canonical", () => {
const submitted: StreamItem = {
kind: "user_message",
id: "msg_head_optimistic",
id: "msg_head_submitted",
clientMessageId: "msg_head_submitted",
text: "plain text in head",
timestamp: new Date("2025-01-01T15:03:02Z"),
optimistic: true,
};
const result = applyStreamEvent({
tail: [],
head: [optimistic],
head: [submitted],
event: {
type: "timeline",
provider: "opencode",
@@ -1393,33 +1511,80 @@ describe("turn lifecycle events", () => {
source: "live",
});
assert.strictEqual(result.head.length, 0);
assert.deepStrictEqual(result.head, []);
const userMessages = result.tail.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
assert.strictEqual(userMessages[0]?.id, "msg_head_submitted");
assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head");
});
it("replaces multiple optimistic user messages in FIFO order", () => {
const optimisticTimestamp = new Date("2025-01-01T15:04:00Z");
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
const firstOptimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic_1",
text: "first typed text",
timestamp: optimisticTimestamp,
optimistic: true,
it("keeps a replacement assistant separate after an interrupted prompt is reconciled", () => {
const interruptedAssistant: StreamItem = {
kind: "assistant_message",
id: "interrupted",
text: "old answer",
timestamp: new Date("2025-01-01T15:03:01Z"),
};
const secondOptimistic: StreamItem = {
const submitted = createUserMessage({
clientMessageId: "msg_interrupt",
text: "replacement prompt",
timestamp: new Date("2025-01-01T15:03:02Z"),
});
const reconciled = applyStreamEvent({
tail: [],
head: [interruptedAssistant, submitted],
event: {
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: submitted.text,
messageId: "provider-prompt",
clientMessageId: submitted.clientMessageId,
},
},
timestamp: new Date("2025-01-01T15:03:03Z"),
});
const replacement = applyStreamEvent({
tail: reconciled.tail,
head: reconciled.head,
event: {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: "new answer" },
},
timestamp: new Date("2025-01-01T15:03:04Z"),
});
expect(replacement.tail.map((item) => item.kind)).toEqual([
"assistant_message",
"user_message",
]);
expect(replacement.head).toEqual([
expect.objectContaining({ kind: "assistant_message", text: "new answer" }),
]);
});
it("replaces multiple submitted user messages in FIFO order", () => {
const submittedTimestamp = new Date("2025-01-01T15:04:00Z");
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
const firstSubmitted: StreamItem = {
kind: "user_message",
id: "msg_optimistic_2",
id: "msg_submitted_1",
clientMessageId: "msg_submitted_1",
text: "first typed text",
timestamp: submittedTimestamp,
};
const secondSubmitted: StreamItem = {
kind: "user_message",
id: "msg_submitted_2",
clientMessageId: "msg_submitted_2",
text: "second typed text",
timestamp: new Date("2025-01-01T15:04:00.500Z"),
optimistic: true,
};
const afterFirstEcho = reduceStreamUpdate(
[firstOptimistic, secondOptimistic],
[firstSubmitted, secondSubmitted],
{
type: "timeline",
provider: "opencode",
@@ -1427,6 +1592,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "first server text",
messageId: "provider-owned-first",
clientMessageId: "msg_submitted_1",
},
},
serverTimestamp,
@@ -1441,6 +1607,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "second server text",
messageId: "provider-owned-second",
clientMessageId: "msg_submitted_2",
},
},
new Date("2025-01-01T15:04:02Z"),
@@ -1450,30 +1617,30 @@ describe("turn lifecycle events", () => {
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 2);
assert.deepStrictEqual(
userMessages.map((item) => [item.id, item.text, item.optimistic]),
userMessages.map((item) => [item.id, item.text, item.messageId]),
[
["provider-owned-first", "first typed text", undefined],
["provider-owned-second", "second typed text", undefined],
["msg_submitted_1", "first typed text", "provider-owned-first"],
["msg_submitted_2", "second typed text", "provider-owned-second"],
],
);
});
it("does not shift later prompts when an earlier optimistic prompt has no canonical echo", () => {
it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => {
const staleTimestamp = new Date("2025-01-01T15:04:00Z");
const submittedTimestamp = new Date("2025-01-01T15:04:01Z");
const stalePrompt: StreamItem = {
kind: "user_message",
id: "msg_stale",
clientMessageId: "msg_stale",
text: "first prompt without an echo",
timestamp: staleTimestamp,
optimistic: true,
};
const submittedPrompt: StreamItem = {
kind: "user_message",
id: "msg_submitted",
clientMessageId: "msg_submitted",
text: "later submitted prompt",
timestamp: submittedTimestamp,
optimistic: true,
};
const state = reduceStreamUpdate(
@@ -1496,15 +1663,16 @@ describe("turn lifecycle events", () => {
stalePrompt,
{
kind: "user_message",
id: "provider-owned-submitted",
id: "msg_submitted",
clientMessageId: submittedPrompt.id,
messageId: "provider-owned-submitted",
text: submittedPrompt.text,
timestamp: submittedPrompt.timestamp,
},
]);
});
it("appends a live server user message when no optimistic user message is pending", () => {
it("appends a live server user message when no submitted user message is pending", () => {
const state = reduceStreamUpdate(
[],
{
@@ -1523,21 +1691,11 @@ describe("turn lifecycle events", () => {
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-resume");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_rewound_optimistic",
text: "rewound text",
timestamp: new Date("2025-01-01T15:04:04Z"),
optimistic: true,
};
const cleared = clearOptimisticUserMessages([optimistic]);
it("appends a server user message after a rewound local row was removed", () => {
const state = reduceStreamUpdate(
cleared,
[],
{
type: "timeline",
provider: "opencode",
@@ -1555,7 +1713,6 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind");
assert.strictEqual(userMessages[0]?.text, "future server echo");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("keeps canonical repeated user messages distinct during hydration", () => {

View File

@@ -87,22 +87,440 @@ export interface UserMessageItem {
kind: "user_message";
id: string;
clientMessageId?: string;
text: string;
timestamp: Date;
optimistic?: true;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export interface OptimisticUserMessageInput {
id: string;
messageId?: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export type OptimisticUserMessagePlacement = "tail" | "active-head";
export interface UserMessageInput {
id?: string;
clientMessageId?: string;
messageId?: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export function createUserMessage(input: UserMessageInput): UserMessageItem {
const id = input.id ?? input.clientMessageId ?? input.messageId;
if (!id) {
throw new Error("User message identity is required");
}
return {
kind: "user_message",
id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
...(input.messageId ? { messageId: input.messageId } : {}),
text: input.text,
timestamp: input.timestamp,
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
};
}
export function appendSubmittedUserMessage(input: {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
}): { tail: StreamItem[]; head: StreamItem[] } {
const clientMessageId = input.message.clientMessageId;
if (!clientMessageId) {
throw new Error("Submitted user message requires client identity");
}
const alreadyExists = [...input.tail, ...input.head].some(
(item) => item.kind === "user_message" && item.clientMessageId === clientMessageId,
);
if (alreadyExists) {
throw new Error(`Submitted user message already exists: ${clientMessageId}`);
}
return input.head.length > 0
? { tail: input.tail, head: [...input.head, input.message] }
: { tail: [...input.tail, input.message], head: input.head };
}
export function removeSubmittedUserMessage(input: {
tail: StreamItem[];
head: StreamItem[];
clientMessageId: string;
}): { tail: StreamItem[]; head: StreamItem[] } {
const remove = (items: StreamItem[]) => {
const next = items.filter(
(item) => item.kind !== "user_message" || item.clientMessageId !== input.clientMessageId,
);
return next.length === items.length ? items : next;
};
return { tail: remove(input.tail), head: remove(input.head) };
}
// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once the
// supported daemon floor emits clientMessageId on submitted user messages. Until then a
// locally submitted row (clientMessageId, no messageId) and its canonical twin from an
// old daemon (messageId, no clientMessageId) share no identifier, so canonical ingestion
// may match an explicit local candidate by the id supplied over the wire or by text.
function matchesLegacyCanonicalUserMessage(
submitted: UserMessageItem,
canonical: UserMessageItem,
): boolean {
if (submitted.clientMessageId === undefined || submitted.messageId !== undefined) return false;
if (canonical.messageId === undefined) return false;
return canonical.messageId === submitted.clientMessageId || canonical.text === submitted.text;
}
type UserMessageMatchPolicy = "canonical-incoming" | "handoff";
function matchesUserMessage(
existing: UserMessageItem,
incoming: UserMessageItem,
policy: UserMessageMatchPolicy,
): boolean {
if (existing.clientMessageId && incoming.clientMessageId) {
return existing.clientMessageId === incoming.clientMessageId;
}
if (existing.messageId && incoming.messageId) {
return existing.messageId === incoming.messageId;
}
if (matchesLegacyCanonicalUserMessage(existing, incoming)) return true;
return policy === "handoff" && matchesLegacyCanonicalUserMessage(incoming, existing);
}
export function upsertUserMessage(
items: StreamItem[],
incoming: UserMessageItem,
insertAt = items.length,
): StreamItem[] {
return produceUserMessage(items, incoming, insertAt, "existing").items;
}
type UserMessagePresentationPolicy = "existing" | "incoming";
interface UserMessageProductionResult {
items: StreamItem[];
index: number;
message: UserMessageItem;
matched: boolean;
}
function produceUserMessage(
items: StreamItem[],
incoming: UserMessageItem,
insertAt: number | null,
presentationPolicy: UserMessagePresentationPolicy,
matchPolicy: UserMessageMatchPolicy = "canonical-incoming",
): UserMessageProductionResult {
const index = items.findIndex(
(item) => item.kind === "user_message" && matchesUserMessage(item, incoming, matchPolicy),
);
if (index < 0) {
if (insertAt === null) {
return { items, index: -1, message: incoming, matched: false };
}
return {
items: [...items.slice(0, insertAt), incoming, ...items.slice(insertAt)],
index: insertAt,
message: incoming,
matched: false,
};
}
const existing = items[index];
if (!existing || existing.kind !== "user_message") {
throw new Error("User message upsert matched a non-user row");
}
const presentation = presentationPolicy === "incoming" ? incoming : existing;
const merged = createUserMessage({
...presentation,
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
});
if (
existing.id === merged.id &&
existing.clientMessageId === merged.clientMessageId &&
existing.messageId === merged.messageId &&
existing.text === merged.text &&
existing.timestamp === merged.timestamp &&
existing.images === merged.images &&
existing.attachments === merged.attachments
) {
return { items, index, message: existing, matched: true };
}
const next = [...items];
next[index] = merged;
return { items: next, index, message: merged, matched: true };
}
export interface UserMessageStreamUpsertInput {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
insert: "tail" | "head" | "prepend-tail" | "none";
presentation: UserMessagePresentationPolicy;
matchPolicy?: UserMessageMatchPolicy;
}
export interface UserMessageStreamUpsertResult extends ApplyStreamEventResult {
location: {
lane: "tail" | "head";
index: number;
message: UserMessageItem;
matched: boolean;
} | null;
}
export function upsertUserMessageAcrossStream(
input: UserMessageStreamUpsertInput,
): UserMessageStreamUpsertResult {
const tailResult = produceUserMessage(
input.tail,
input.message,
null,
input.presentation,
input.matchPolicy,
);
if (tailResult.matched) {
return {
tail: tailResult.items,
head: input.head,
changedTail: tailResult.items !== input.tail,
changedHead: false,
location: {
lane: "tail",
index: tailResult.index,
message: tailResult.message,
matched: true,
},
};
}
const headResult = produceUserMessage(
input.head,
input.message,
null,
input.presentation,
input.matchPolicy,
);
if (headResult.matched) {
return {
tail: input.tail,
head: headResult.items,
changedTail: false,
changedHead: headResult.items !== input.head,
location: {
lane: "head",
index: headResult.index,
message: headResult.message,
matched: true,
},
};
}
if (input.insert === "none") {
return {
tail: input.tail,
head: input.head,
changedTail: false,
changedHead: false,
location: null,
};
}
if (input.insert === "head") {
const inserted = produceUserMessage(
input.head,
input.message,
input.head.length,
input.presentation,
input.matchPolicy,
);
return {
tail: input.tail,
head: inserted.items,
changedTail: false,
changedHead: true,
location: {
lane: "head",
index: inserted.index,
message: inserted.message,
matched: false,
},
};
}
const inserted = produceUserMessage(
input.tail,
input.message,
input.insert === "prepend-tail" ? 0 : input.tail.length,
input.presentation,
input.matchPolicy,
);
return {
tail: inserted.items,
head: input.head,
changedTail: true,
changedHead: false,
location: {
lane: "tail",
index: inserted.index,
message: inserted.message,
matched: false,
},
};
}
function placeCanonicalUserMessageAtTail(
tail: StreamItem[],
message: UserMessageItem,
insertWhenUnmatched: boolean,
): Pick<UserMessageProductionResult, "items" | "message" | "matched"> {
const produced = produceUserMessage(tail, message, null, "existing");
if (!produced.matched && !insertWhenUnmatched) {
return produced;
}
const preceding = produced.matched
? [...produced.items.slice(0, produced.index), ...produced.items.slice(produced.index + 1)]
: produced.items;
return {
items: [...preceding, produced.message],
message: produced.message,
matched: produced.matched,
};
}
export interface CanonicalStreamReplacementInput {
canonical: StreamItem[];
previousTail: StreamItem[];
previousHead: StreamItem[];
sendingClientMessageIds: readonly string[];
preserveLiveHead: boolean;
}
export interface CanonicalStreamReplacementResult {
tail: StreamItem[];
head: StreamItem[];
acknowledgedClientMessageIds: string[];
}
function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] {
return [...items.slice(0, index), ...items.slice(index + 1)];
}
function preserveReplacementHead(
tail: StreamItem[],
currentHead: StreamItem[],
preserveLiveHead: boolean,
sendingClientMessageIds: ReadonlySet<string>,
): CanonicalStreamReplacementResult {
const retainedHead = preserveLiveHead
? currentHead
: currentHead.filter(
(item) =>
item.kind === "user_message" &&
item.clientMessageId !== undefined &&
sendingClientMessageIds.has(item.clientMessageId),
);
const tailIds = new Set(tail.map((item) => item.id));
const unreconciledHead = retainedHead.filter(
(item) => item.kind === "assistant_message" || !tailIds.has(item.id),
);
const liveAssistantIndex = unreconciledHead.findLastIndex(
(item) => item.kind === "assistant_message",
);
if (liveAssistantIndex < 0) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const liveAssistant = unreconciledHead[liveAssistantIndex];
const tailAssistant = tail.at(-1);
if (
liveAssistant.kind !== "assistant_message" ||
!tailAssistant ||
tailAssistant.kind !== "assistant_message" ||
!liveAssistant.text.startsWith(tailAssistant.text)
) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text: tailAssistant.text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] };
}
export function replaceWithCanonicalStream(
input: CanonicalStreamReplacementInput,
): CanonicalStreamReplacementResult {
const sendingClientMessageIds = new Set(input.sendingClientMessageIds);
let unmatchedTailMessages = input.previousTail.filter(
(item): item is UserMessageItem =>
item.kind === "user_message" && item.clientMessageId !== undefined,
);
let nextHead = input.previousHead;
const nextTail: StreamItem[] = [];
const acknowledgedClientMessageIds = new Set<string>();
for (const item of input.canonical) {
if (item.kind !== "user_message") {
nextTail.push(item);
continue;
}
const tailResult = produceUserMessage(unmatchedTailMessages, item, null, "existing");
if (tailResult.matched) {
unmatchedTailMessages = removeUserMessageAt(unmatchedTailMessages, tailResult.index);
nextTail.push(tailResult.message);
if (
tailResult.message.clientMessageId &&
sendingClientMessageIds.has(tailResult.message.clientMessageId)
) {
acknowledgedClientMessageIds.add(tailResult.message.clientMessageId);
}
continue;
}
const headResult = produceUserMessage(nextHead, item, null, "existing");
if (headResult.matched) {
nextHead = [
...headResult.items.slice(0, headResult.index),
...headResult.items.slice(headResult.index + 1),
];
nextTail.push(headResult.message);
if (
headResult.message.clientMessageId &&
sendingClientMessageIds.has(headResult.message.clientMessageId)
) {
acknowledgedClientMessageIds.add(headResult.message.clientMessageId);
}
continue;
}
nextTail.push(item);
}
for (const local of unmatchedTailMessages) {
if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) {
continue;
}
nextTail.push(local);
}
nextHead = nextHead.filter((item) => {
if (item.kind !== "user_message" || !item.clientMessageId) return true;
return sendingClientMessageIds.has(item.clientMessageId);
});
const replacement = preserveReplacementHead(
nextTail,
nextHead,
input.preserveLiveHead,
sendingClientMessageIds,
);
return {
...replacement,
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
};
}
export interface AssistantMessageItem {
kind: "assistant_message";
@@ -237,124 +655,24 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
};
}
function buildUserMessageItem(input: {
id: string;
clientMessageId?: string;
text: string;
timestamp: Date;
optimistic?: UserMessageItem | null;
}): UserMessageItem {
if (input.optimistic) {
return {
kind: "user_message",
id: input.id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
text: input.optimistic.text,
timestamp: input.optimistic.timestamp,
...(input.optimistic.images && input.optimistic.images.length > 0
? { images: input.optimistic.images }
: {}),
...(input.optimistic.attachments && input.optimistic.attachments.length > 0
? { attachments: input.optimistic.attachments }
: {}),
};
}
return {
kind: "user_message",
id: input.id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
text: input.text,
timestamp: input.timestamp,
};
}
export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): UserMessageItem {
return {
kind: "user_message",
id: input.id,
text: input.text,
timestamp: input.timestamp,
optimistic: true,
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
};
}
export function appendOptimisticUserMessageToStream(params: {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
placement: OptimisticUserMessagePlacement;
}): ApplyStreamEventResult {
const { tail, head, message, placement } = params;
if (tail.some((item) => item.id === message.id) || head.some((item) => item.id === message.id)) {
return { tail, head, changedTail: false, changedHead: false };
}
if (placement === "active-head" && head.length > 0) {
return {
tail,
head: [...head, message],
changedTail: false,
changedHead: true,
};
}
return {
tail: [...tail, message],
head,
changedTail: true,
changedHead: false,
};
}
export function handoffCreatedAgentUserMessageToStream(params: {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
}): ApplyStreamEventResult {
const { tail, head, message } = params;
const items = [...tail, ...head];
const userIndex = items.findIndex((item) => item.kind === "user_message");
if (userIndex < 0) {
return appendOptimisticUserMessageToStream({
tail,
head,
message,
placement: "tail",
});
}
const userMessage = items[userIndex];
if (!userMessage || userMessage.kind !== "user_message" || userMessage.optimistic) {
return { tail, head, changedTail: false, changedHead: false };
}
const handedOffMessage = buildUserMessageItem({
id: userMessage.id,
text: message.text,
timestamp: message.timestamp,
optimistic: message,
return upsertUserMessageAcrossStream({
...params,
insert: "tail",
presentation: "incoming",
matchPolicy: "handoff",
});
if (userIndex < tail.length) {
const nextTail = [...tail];
nextTail[userIndex] = handedOffMessage;
return { tail: nextTail, head, changedTail: true, changedHead: false };
}
const nextHead = [...head];
nextHead[userIndex - tail.length] = handedOffMessage;
return { tail, head: nextHead, changedTail: false, changedHead: true };
}
function appendUserMessage(
state: StreamItem[],
text: string,
timestamp: Date,
source: StreamUpdateSource,
_source: StreamUpdateSource,
messageId?: string,
clientMessageId?: string,
): StreamItem[] {
@@ -364,37 +682,14 @@ function appendUserMessage(
}
const chunkSeed = chunk.trim() || chunk;
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
const optimisticIndex = state.findIndex(
(entry) =>
entry.kind === "user_message" &&
entry.optimistic &&
(clientMessageId !== undefined
? entry.id === clientMessageId
: source === "live" || entry.id === messageId || entry.text === chunk),
);
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
const nextItem = buildUserMessageItem({
id: entryId,
const nextItem = createUserMessage({
id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp),
clientMessageId,
messageId,
text: chunk,
timestamp,
optimistic,
});
if (optimisticIndex >= 0) {
const next = [...state];
next[optimisticIndex] = nextItem;
return next;
}
return [...state, nextItem];
}
export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] {
const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic);
return next.length === state.length ? state : next;
return upsertUserMessage(state, nextItem);
}
function appendAssistantMessage(
@@ -426,8 +721,8 @@ function appendAssistantMessage(
return [...state.slice(0, -1), updated];
}
// If the last item is a user_message (optimistic append to head during
// interrupt), look one further back for the streaming assistant_message.
// A submitted user row can follow the streaming assistant during interrupt.
// In that case, look one row further back for the assistant to extend.
const secondLast = state[state.length - 2];
if (
source === "live" &&
@@ -1149,7 +1444,6 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI
if (newItems.length === 0) {
return tail;
}
return [...tail, ...newItems];
}
@@ -1177,8 +1471,7 @@ function shouldFlushHead(input: {
return true;
}
// Find the last streamable item in head (skip trailing non-streamable
// items like an optimistic user_message appended during interrupt).
// Find the last streamable item in head (skip trailing non-streamable items).
let lastStreamable: StreamItem | undefined;
for (let i = head.length - 1; i >= 0; i--) {
if (isStreamableKind(head[i].kind)) {
@@ -1209,6 +1502,41 @@ export interface ApplyStreamEventResult {
head: StreamItem[];
changedTail: boolean;
changedHead: boolean;
acknowledgedClientMessageIds?: string[];
}
function applyCanonicalUserMessageEvent(params: {
tail: StreamItem[];
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
}): ApplyStreamEventResult | null {
const { tail, head, event, timestamp } = params;
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
const normalized = normalizeChunk(event.item.text);
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
const flushedHead = head.length > 0 ? [] : head;
const canonical = createUserMessage({
id:
event.item.messageId ??
createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp),
messageId: event.item.messageId,
clientMessageId: event.item.clientMessageId,
text: normalized.chunk,
timestamp,
});
const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent);
return {
tail: reconciled.items,
head: flushedHead,
changedTail: flushedTail !== tail || reconciled.items !== flushedTail,
changedHead: flushedHead !== head,
acknowledgedClientMessageIds:
reconciled.matched && reconciled.message.clientMessageId
? [reconciled.message.clientMessageId]
: [],
};
}
/**
@@ -1231,12 +1559,13 @@ export function applyStreamEvent(params: {
timelineCursor?: TimelinePosition;
}): ApplyStreamEventResult {
const { tail, head, event, timestamp } = params;
const canonicalUserResult = applyCanonicalUserMessageEvent({ tail, head, event, timestamp });
if (canonicalUserResult) return canonicalUserResult;
const source = params.source ?? "live";
let nextTail = tail;
let nextHead = head;
let changedTail = false;
let changedHead = false;
const flushHead = () => {
if (nextHead.length === 0) {
return;

View File

@@ -7,6 +7,7 @@ import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { createUserMessage } from "@/types/stream";
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
function createAgentPayload(
@@ -64,6 +65,111 @@ function permission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
function beginPendingSubmission(serverId: string, agentId: string): string {
const clientMessageId = `client-${agentId}`;
useSessionStore.getState().beginAgentMessageSubmission(
serverId,
agentId,
createUserMessage({
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
}),
);
return clientMessageId;
}
function applyAgentStatus(input: {
serverId: string;
agentId: string;
status: AgentSnapshotPayload["status"];
updatedAt: string;
}): void {
const agent = createAgentPayload({
id: input.agentId,
status: input.status,
updatedAt: input.updatedAt,
});
applyAgentDirectoryDelta({
serverId: input.serverId,
delta: { kind: "upsert", agent, project: createEntry(agent).project },
});
}
describe("message submission authority", () => {
it("does not settle a submission from an unrelated running transition", () => {
const serverId = "server-running-is-not-submission-ack";
const agentId = "agent-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
applyAgentStatus({
serverId,
agentId,
status: "idle",
updatedAt: "2026-07-27T10:00:00.000Z",
});
const clientMessageId = beginPendingSubmission(serverId, agentId);
applyAgentStatus({
serverId,
agentId,
status: "running",
updatedAt: "2026-07-27T10:00:01.000Z",
});
expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([
{
clientMessageId,
submittedAt: new Date("2026-07-27T10:00:00.000Z"),
rpcAccepted: false,
providerAcknowledged: false,
},
]);
store.clearSession(serverId);
});
it("settles provider acknowledgement only when timeline ingestion reports it", () => {
const serverId = "server-explicit-provider-ack";
const agentId = "agent-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
applyAgentStatus({
serverId,
agentId,
status: "idle",
updatedAt: "2026-07-27T10:00:00.000Z",
});
const clientMessageId = beginPendingSubmission(serverId, agentId);
store.setAgentStreamState(serverId, agentId, {
tail: [
createUserMessage({
id: "provider-message",
messageId: "provider-message",
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
}),
],
head: [],
});
expect(
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
?.providerAcknowledged,
).toBe(false);
store.setAgentStreamState(serverId, agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
expect(
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
?.providerAcknowledged,
).toBe(true);
store.clearSession(serverId);
});
});
describe("replaceFetchedAgentDirectory", () => {
it("preserves timeline initialization while replacing directory state", () => {
const serverId = "server-initializing";

View File

@@ -17,6 +17,7 @@ import {
encodeWorkspaceIdForPathSegment,
isSettingsSectionSlug,
normalizeHostSectionSlug,
normalizeProjectSettingsRouteKey,
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -214,6 +215,19 @@ describe("projects settings routes", () => {
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
});
it("preserves whitespace in opaque project keys", () => {
const projectKey = "host:6:host-a:project:10:/repo/foo ";
const route = buildProjectSettingsRoute(projectKey);
const segment = route.slice("/settings/projects/".length);
expect(decodeURIComponent(segment)).toBe(projectKey);
});
it("keeps an already-decoded opaque project key unchanged", () => {
const projectKey = "remote:github.com/acme/app#subdir:packages/my%20app";
expect(normalizeProjectSettingsRouteKey(projectKey)).toBe(projectKey);
});
});
describe("global routes", () => {

View File

@@ -567,9 +567,13 @@ export function buildProjectsSettingsRoute() {
}
export function buildProjectSettingsRoute(projectKey: string) {
const normalized = trimNonEmpty(projectKey);
if (!normalized) {
if (!projectKey.trim()) {
throw new Error("buildProjectSettingsRoute requires a non-empty projectKey");
}
return `/settings/projects/${encodeSegment(normalized)}` as const;
return `/settings/projects/${encodeSegment(projectKey)}` as const;
}
export function normalizeProjectSettingsRouteKey(value: string | string[] | undefined): string {
const projectKey = Array.isArray(value) ? value[0] : value;
return typeof projectKey === "string" ? projectKey : "";
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages";
import { frameHostProjectKey } from "@/projects/project-key";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjects } from "./projects";
@@ -30,6 +31,8 @@ function workspace(input: {
repoRoot: string;
project?: ProjectPlacementPayload;
projectId?: string;
projectKey?: string;
projectCustomName?: string | null;
projectName?: string;
remoteUrl?: string | null;
currentBranch?: string;
@@ -38,7 +41,9 @@ function workspace(input: {
return {
id: input.id,
projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot,
projectKey: input.projectKey,
projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project",
projectCustomName: input.projectCustomName,
projectRootPath: input.repoRoot,
workspaceDirectory: input.repoRoot,
projectKind: "git",
@@ -64,6 +69,188 @@ function workspace(input: {
}
describe("buildProjects", () => {
it("groups distinct host-local project IDs by their persisted project key", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [workspace({ id: "main-a", repoRoot: "/a", projectId: "prj_a", projectKey })],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [workspace({ id: "main-b", repoRoot: "/b", projectId: "prj_b", projectKey })],
},
],
});
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?.hosts).toMatchObject([
{ serverId: "desktop", projectId: "prj_a" },
{ serverId: "laptop", projectId: "prj_b" },
]);
});
it("keeps independent same-host clones separate when their project key is ambiguous", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectKey }),
],
},
],
});
expect(result.projects).toHaveLength(2);
expect(result.projects.map((project) => project.hosts[0]?.projectId).sort()).toEqual([
"prj_a",
"prj_b",
]);
});
it("uses one ambiguity scope across every host", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({ id: "clone-a", repoRoot: "/a", projectId: "prj_a", projectKey }),
workspace({ id: "clone-b", repoRoot: "/b", projectId: "prj_b", projectKey }),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({ id: "clone-c", repoRoot: "/c", projectId: "prj_c", projectKey }),
],
},
],
});
expect(result.projects.map((project) => project.projectKey).sort()).toEqual([
"host:6:laptop:project:5:prj_c",
"host:7:desktop:project:5:prj_a",
"host:7:desktop:project:5:prj_b",
]);
});
it("keeps host-local metadata when the grouping key differs from the project ID", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({
id: "main",
repoRoot: "/repo/app",
projectId: "prj_app",
projectKey,
projectName: "acme/app",
projectCustomName: "My App",
}),
],
},
],
});
expect(result.projects[0]).toMatchObject({
projectKey: projectKey,
projectName: "acme/app",
projectCustomName: "My App",
});
});
it("keeps custom-name state on the host that owns it", () => {
const projectKey = "remote:github.com/acme/app";
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [
workspace({
id: "desktop-main",
repoRoot: "/desktop/app",
projectId: "prj_desktop",
projectKey,
projectName: "My App",
projectCustomName: "My App",
}),
],
},
{
serverId: "laptop",
serverName: "Laptop",
isOnline: true,
workspaces: [
workspace({
id: "laptop-main",
repoRoot: "/laptop/app",
projectId: "prj_laptop",
projectKey,
projectName: "acme/app",
projectCustomName: null,
}),
],
},
],
});
expect(result.projects[0]?.hosts).toMatchObject([
{ serverId: "desktop", projectName: "My App", projectCustomName: "My App" },
{ serverId: "laptop", projectName: "acme/app", projectCustomName: null },
]);
});
it("preserves the root of a grouped project without workspaces", () => {
const result = buildProjects({
hosts: [
{
serverId: "desktop",
serverName: "Desktop",
isOnline: true,
workspaces: [],
emptyProjects: [
{
projectId: "prj_app",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "My Empty Project",
projectCustomName: "My Empty Project",
projectRootPath: "/repo/app",
projectKind: "git",
},
],
},
],
});
expect(result.projects[0]?.hosts[0]).toMatchObject({
projectId: "prj_app",
projectName: "My Empty Project",
projectCustomName: "My Empty Project",
repoRoot: "/repo/app",
});
});
it("normalizes detached and blank branches out of workspace summaries", () => {
const result = buildProjects({
hosts: [
@@ -225,7 +412,7 @@ describe("buildProjects", () => {
expect(result.projects[0]?.hostCount).toBe(1);
});
it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => {
it("uses the persisted selected project root instead of the checkout root", () => {
const result = buildProjects({
hosts: [
{
@@ -235,11 +422,11 @@ describe("buildProjects", () => {
workspaces: [
workspace({
id: "main",
repoRoot: "/worktrees/app/main",
repoRoot: "/worktrees/app/main/packages/client",
project: placement({
projectKey: "remote:github.com/acme/app",
projectName: "acme/app",
cwd: "/worktrees/app/main",
projectKey: "remote:github.com/acme/app#subdir:packages/client",
projectName: "client",
cwd: "/worktrees/app/main/packages/client",
remoteUrl: "https://github.com/acme/app.git",
mainRepoRoot: "/repo/app",
}),
@@ -263,11 +450,15 @@ describe("buildProjects", () => {
});
const acme = result.projects.find(
(project) => project.projectKey === "remote:github.com/acme/app",
(project) => project.projectKey === "remote:github.com/acme/app#subdir:packages/client",
);
const legacy = result.projects.find(
(project) =>
project.projectKey ===
frameHostProjectKey({ serverId: "legacy", projectId: "legacy-project" }),
);
const legacy = result.projects.find((project) => project.projectKey === "legacy-project");
expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app");
expect(acme?.hosts[0]?.repoRoot).toBe("/worktrees/app/main/packages/client");
expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy");
});
@@ -398,8 +589,8 @@ describe("buildProjects", () => {
});
expect(result.projects.map((project) => project.projectKey)).toEqual([
"/repo/one",
"/repo/two",
frameHostProjectKey({ serverId: "local", projectId: "/repo/one" }),
frameHostProjectKey({ serverId: "local", projectId: "/repo/two" }),
]);
});
@@ -460,7 +651,7 @@ describe("buildProjects", () => {
"remote:gitlab.com/acme/api",
"remote:bitbucket.org/acme/cli",
"remote:github.com/acme/web",
"/repo/local",
frameHostProjectKey({ serverId: "local", projectId: "/repo/local" }),
]);
});
@@ -553,7 +744,9 @@ describe("buildProjects", () => {
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("legacy-project");
expect(summary?.projectKey).toBe(
frameHostProjectKey({ serverId: "old-daemon", projectId: "legacy-project" }),
);
expect(summary?.projectName).toBe("Legacy");
expect(summary?.githubUrl).toBeUndefined();
expect(summary?.hosts).toHaveLength(1);
@@ -584,7 +777,9 @@ describe("buildProjects", () => {
expect(result.projects).toHaveLength(1);
const summary = result.projects[0];
expect(summary?.projectKey).toBe("/repo/fresh");
expect(summary?.projectKey).toBe(
frameHostProjectKey({ serverId: "local", projectId: "/repo/fresh" }),
);
expect(summary?.totalWorkspaceCount).toBe(0);
expect(summary?.hosts).toHaveLength(1);
// repoRoot must be non-empty or the project settings screen treats the host

View File

@@ -1,6 +1,7 @@
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model";
import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure";
import { resolveProjectKey } from "@/projects/project-key";
export interface WorkspaceSummary {
id: string;
@@ -14,6 +15,9 @@ export interface WorkspaceSummary {
export interface ProjectHostEntry {
serverId: string;
projectId?: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
repoRoot: string;
@@ -55,6 +59,9 @@ const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/;
interface HostGroup {
serverId: string;
projectId: string;
projectName: string;
projectCustomName: string | null;
serverName: string;
isOnline: boolean;
workspaces: WorkspaceDescriptor[];
@@ -70,31 +77,35 @@ interface ProjectGroup {
hostsByServerId: Map<string, HostGroup>;
}
function findProjectCustomName(
workspaces: WorkspaceDescriptor[],
function findProjectMetadata(
host: ProjectHost,
projectKey: string,
): { customName: string; displayName: string } | null {
for (const workspace of workspaces) {
if (workspace.projectId === projectKey && workspace.projectCustomName) {
): { customName: string | null; displayName: string } | null {
for (const workspace of host.workspaces) {
if (workspace.projectId === projectKey) {
return {
customName: workspace.projectCustomName,
customName: workspace.projectCustomName ?? null,
displayName: workspace.projectDisplayName,
};
}
}
return null;
const emptyProject = host.emptyProjects?.find((project) => project.projectId === projectKey);
return emptyProject
? {
customName: emptyProject.projectCustomName ?? null,
displayName: emptyProject.projectDisplayName,
}
: null;
}
function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] {
function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] {
return buildHostProjectList({
projects: buildWorkspaceStructureProjects({
sessions: [
{
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
},
],
sessions: hosts.map((host) => ({
serverId: host.serverId,
workspaces: host.workspaces,
emptyProjects: host.emptyProjects,
})),
}),
});
}
@@ -108,12 +119,6 @@ function deriveGithubUrl(projectKey: string): string | undefined {
}
function resolveHostRepoRoot(group: HostGroup): string {
for (const workspace of group.workspaces) {
const mainRepoRoot = workspace.project?.checkout.mainRepoRoot;
if (mainRepoRoot) {
return mainRepoRoot;
}
}
return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot;
}
@@ -137,6 +142,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry {
group.workspaces[0];
return {
serverId: group.serverId,
projectId: group.projectId,
projectName: group.projectName,
projectCustomName: group.projectCustomName,
serverName: group.serverName,
isOnline: group.isOnline,
repoRoot,
@@ -171,49 +179,84 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary {
};
}
function addHostProjects(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const emptyRepoRootByProjectId = new Map(
(host.emptyProjects ?? []).map((project) => [project.projectId, project.projectRootPath]),
);
for (const hostProject of hostProjects) {
const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId);
if (!placement) continue;
const projectId = placement.projectId ?? hostProject.projectKey;
const customName = findProjectMetadata(host, projectId);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName?.customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
projectId,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectId.get(projectId) ?? "",
});
}
}
}
function attachHostWorkspaces(
groups: Map<string, ProjectGroup>,
host: ProjectHost,
hostProjects: HostProjectListItem[],
): void {
const projectKeyByProjectId = new Map(
hostProjects.flatMap((project) =>
project.hosts
.filter((placement) => placement.serverId === host.serverId && placement.projectId)
.map((placement) => [placement.projectId!, project.projectKey] as const),
),
);
for (const workspace of host.workspaces) {
const key =
projectKeyByProjectId.get(workspace.projectId) ??
resolveProjectKey({
serverId: host.serverId,
projectId: workspace.projectId,
projectKey: workspace.projectKey,
});
groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace);
}
}
export function buildProjects(input: BuildProjectsInput): BuildProjectsResult {
const groups = new Map<string, ProjectGroup>();
const projectEntries = buildHostProjectEntries(input.hosts);
for (const host of input.hosts) {
const emptyRepoRootByProjectKey = new Map<string, string>();
for (const emptyProject of host.emptyProjects ?? []) {
emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath);
}
const hostProjects = buildHostProjectEntries(host);
for (const hostProject of hostProjects) {
const customName = findProjectCustomName(host.workspaces, hostProject.projectKey);
let group = groups.get(hostProject.projectKey);
if (!group) {
group = {
projectKey: hostProject.projectKey,
projectName: customName?.displayName ?? hostProject.projectName,
projectCustomName: customName?.customName ?? null,
hostsByServerId: new Map(),
};
groups.set(hostProject.projectKey, group);
} else if (customName && !group.projectCustomName) {
group.projectCustomName = customName.customName;
group.projectName = customName.displayName;
}
if (!group.hostsByServerId.has(host.serverId)) {
group.hostsByServerId.set(host.serverId, {
serverId: host.serverId,
serverName: host.serverName,
isOnline: host.isOnline,
workspaces: [],
fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "",
});
}
}
for (const workspace of host.workspaces) {
const group = groups.get(workspace.projectId);
const hostGroup = group?.hostsByServerId.get(host.serverId);
if (!hostGroup) continue;
hostGroup.workspaces.push(workspace);
}
const hostProjects = projectEntries.filter((project) =>
project.hosts.some((placement) => placement.serverId === host.serverId),
);
addHostProjects(groups, host, hostProjects);
attachHostWorkspaces(groups, host, hostProjects);
}
const projects = Array.from(groups.values()).map(toProjectSummary);

View File

@@ -6,6 +6,7 @@ import type {
import {
buildSidebarProjectRowModel,
resolveSidebarProjectIconTarget,
resolveSidebarProjectLocalPath,
} from "./sidebar-project-row-model";
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
@@ -140,6 +141,46 @@ describe("buildSidebarProjectRowModel", () => {
});
});
it("prefers an online host when a grouped project's first host is offline", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
}),
collapsed: false,
onlineServerIds: new Set(["host-b"]),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-b", iconWorkingDir: "/repo/b" },
},
});
});
it("falls back to the first capable host when every grouped host is offline", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true },
{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true },
],
}),
collapsed: false,
onlineServerIds: new Set(),
});
expect(result).toMatchObject({
trailingAction: {
kind: "new_workspace",
target: { serverId: "host-a", iconWorkingDir: "/repo/a" },
},
});
});
it("targets the first multiplicity-capable host for a non-git project", () => {
const result = buildSidebarProjectRowModel({
project: project({
@@ -196,6 +237,19 @@ describe("buildSidebarProjectRowModel", () => {
expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" });
});
it("resolves desktop file actions from the local project placement", () => {
const groupedProject = project({
iconWorkingDir: "/remote/repo",
hosts: [
{ serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true },
{ serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true },
],
});
expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo");
expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe("");
});
it("renders an empty project as an expandable section", () => {
const result = buildSidebarProjectRowModel({
project: project({ projectKind: "git", workspaces: [] }),

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