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
170 changed files with 4124 additions and 6678 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

@@ -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

@@ -37,12 +37,6 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
Reaching the history-start threshold loads one older page and preserves the visible content anchor.
Cursor progress does not trigger another page. The user must leave and return to the threshold unless
the anchored page still leaves the viewport at history start, as with short or compacted content; in
that case pagination continues as one loading operation until the page fills the viewport or history
is exhausted.
## Durable item anchors
Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows.
@@ -67,22 +61,6 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
submissions remain governed by the submission registry. This prevents a page from duplicating rows
it already covers without making the display replica authoritative.
## Selective and legacy delivery
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:

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

@@ -1,85 +0,0 @@
import { test as base } from "./fixtures";
import {
appendSettledTimelineTurns,
createNearTenMegabyteAssistantPng,
createSettledMockAgent,
createSmallAssistantPng,
emitSettledAssistantImage,
expectAssistantImageNotMounted,
expectAssistantImageRendered,
openAssistantImageTimeline,
openExistingImageAgentTabs,
remountAndRecoverAssistantImageFromHistory,
sendFollowUpAndExpectVisibleResponse,
switchAwayAndBackWithoutImageInstability,
userPagesUntilAssistantImageRenders,
} from "./helpers/assistant-images";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" });
try {
await provide(workspace);
} finally {
await workspace.cleanup();
}
},
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Real file image",
fileName: "assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Image timeline");
const otherAgent = await createSettledMockAgent(workspace, "Other timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openExistingImageAgentTabs(page, { imageAgent, otherAgent });
await expectAssistantImageRendered(page, image);
await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent });
});
test("a real assistant PNG remains reachable through pagination and remount", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Paginated real file image",
fileName: "paginated-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await appendSettledTimelineTurns(workspace.client, imageAgent, 40);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageNotMounted(page, image);
await userPagesUntilAssistantImageRenders(page, image);
await remountAndRecoverAssistantImageFromHistory(page, image);
});
test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(180_000);
const image = await createNearTenMegabyteAssistantPng(workspace, {
alt: "Large real file image",
fileName: "large-assistant-preview.png",
});
const imageAgent = await createSettledMockAgent(workspace, "Large image timeline");
await emitSettledAssistantImage(workspace.client, imageAgent, image);
await openAssistantImageTimeline(page, imageAgent);
await expectAssistantImageRendered(page, image);
await sendFollowUpAndExpectVisibleResponse(page, {
prompt: "confirm responsiveness: emit 1 coalesced agent stream updates",
response: "stress-update-0",
});
});

View File

@@ -1,197 +1,46 @@
import { expect, test } from "./fixtures";
import { test } from "./fixtures";
import {
expectSameOlderHistoryLoadingOperation,
expectTimelineAtHistoryStart,
expectLoadedTimelineDoesNotScroll,
expectTimelinePromptNotMounted,
expectTimelinePromptPositionPreserved,
expectTimelinePromptVisible,
holdBootstrapTimelinePage,
holdDaemonHydration,
holdOlderHistoryPages,
holdNextOlderTimelinePage,
makeLoadedTimelineFitViewport,
openAgentTimeline,
rememberOlderHistoryLoadingOperation,
rememberTimelineViewport,
rememberTimelinePromptPosition,
reloadAgentTimelineFromPersistedReplica,
scrollTimelineUntilOlderHistoryIsReachable,
scrollTimelineToNewestLoadedEdge,
seedLongMockAgentTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
userScrollsTimelineToHistoryStart,
} from "./helpers/timeline-pagination";
test.describe("Agent timeline pagination", () => {
test("loads one page each time the user returns to history start", async ({ page }) => {
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectSettledWithRequestedPages(1);
await scrollTimelineUntilOlderHistoryIsReachable(page);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(2);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const viewport = await rememberTimelineViewport(page);
history.releasePage(1);
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
} finally {
await agent.cleanup();
}
});
test("keeps visible history anchored when live output grows during a prepend", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
await agent.client.sendAgentMessage(
agent.agentId,
"timeline live during held older page: emit 20 coalesced agent stream updates",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
} finally {
await agent.cleanup();
}
});
test("finishes loading an older page while live output continues", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "running",
);
history.releasePage(1);
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
const running = await agent.client.fetchAgents({ scope: "active" });
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
"running",
);
} finally {
await agent.cleanup();
}
});
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 40 });
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentTimeline(page, agent);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
history.releasePage(1);
await expectTimelinePromptPositionPreserved(page, position);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("continues one loading operation while older pages still leave history start exposed", async ({
page,
}) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await makeLoadedTimelineFitViewport(page);
const history = await holdOlderHistoryPages(page, agent);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await history.expectRequestedPages(1);
const loading = await rememberOlderHistoryLoadingOperation(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectTimelineAtHistoryStart(page);
await expectSameOlderHistoryLoadingOperation(page, loading);
} finally {
await agent.cleanup();
}
});
test("keeps complete loaded history reachable after reload", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await expectTimelinePromptVisible(page, agent.oldestPrompt);
const hydration = await holdDaemonHydration(page);
await reloadAgentTimelineFromPersistedReplica(page, agent);
hydration.release();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
test("preserves a live row received before replica hydration", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await openAgentTimeline(page, agent);
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
const hydration = await holdBootstrapTimelinePage(page, agent);
await reloadAgentTimelineFromPersistedReplica(page, agent);
await hydration.waitForDelayedResponse();
const livePrompt = await sendLiveTurnBeforeHydration(agent);
await expectTimelinePromptVisible(page, livePrompt);
hydration.release();
await hydration.waitForDelayedCatchUp();
await expectTimelinePromptVisible(page, livePrompt);
hydration.releaseCatchUp();
await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt);
await expectTimelinePromptVisible(page, agent.oldestPrompt);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptVisible(page, livePrompt);
} finally {
await agent.cleanup();
}

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

@@ -15,21 +15,6 @@ export interface AgentTimelineResponseGate {
waitForDelayedResponse(): Promise<void>;
}
export interface OlderTimelinePagesGate {
getRequestCount(): number;
releasePage(pageNumber: number): void;
waitForRequestCount(count: number): Promise<void>;
}
export interface DaemonHydrationGate {
release(): void;
}
export interface BootstrapTimelineGate extends AgentTimelineResponseGate {
releaseCatchUp(): void;
waitForDelayedCatchUp(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -60,32 +45,6 @@ function getPayload(message: Record<string, unknown>): Record<string, unknown> |
: null;
}
export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGate> {
let released = false;
const delayedForwards: Array<() => void> = [];
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (released) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
});
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
};
}
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
@@ -168,142 +127,6 @@ export async function delayCreatedAgentInitialTailResponse(
export async function delayAgentOlderTimelineResponse(
page: Page,
agentId: string,
): Promise<AgentTimelineResponseGate> {
return delayAgentTimelineResponse(page, agentId, "before");
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
): Promise<OlderTimelinePagesGate> {
let requestCount = 0;
let responseCount = 0;
const releasedPages = new Set<number>();
const delayedForwards = new Map<number, Array<() => void>>();
const requestWaiters = new Map<number, Array<() => void>>();
const resolveRequestWaiters = () => {
for (const [count, resolvers] of requestWaiters) {
if (requestCount < count) continue;
requestWaiters.delete(count);
for (const resolve of resolvers) resolve();
}
};
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId &&
sessionMessage.direction === "before"
) {
requestCount += 1;
resolveRequestWaiters();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
) {
responseCount += 1;
const pageNumber = responseCount;
if (releasedPages.has(pageNumber)) {
ws.send(message);
return;
}
const forwards = delayedForwards.get(pageNumber) ?? [];
forwards.push(() => ws.send(message));
delayedForwards.set(pageNumber, forwards);
return;
}
ws.send(message);
});
});
return {
getRequestCount: () => requestCount,
releasePage(pageNumber) {
releasedPages.add(pageNumber);
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
delayedForwards.delete(pageNumber);
},
waitForRequestCount(count) {
if (requestCount >= count) return Promise.resolve();
return new Promise<void>((resolve) => {
const resolvers = requestWaiters.get(count) ?? [];
resolvers.push(resolve);
requestWaiters.set(count, resolvers);
});
},
};
}
export async function delayAgentBootstrapTailResponse(
page: Page,
agentId: string,
): Promise<BootstrapTimelineGate> {
let tailReleased = false;
let catchUpReleased = false;
const delayedTailForwards: Array<() => void> = [];
const delayedCatchUpForwards: Array<() => void> = [];
let resolveDelayedTail: (() => void) | null = null;
let resolveDelayedCatchUp: (() => void) | null = null;
const delayedTail = new Promise<void>((resolve) => {
resolveDelayedTail = resolve;
});
const delayedCatchUp = new Promise<void>((resolve) => {
resolveDelayedCatchUp = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
const isTimelineResponse =
sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId;
if (isTimelineResponse && payload.direction === "tail") {
resolveDelayedTail?.();
if (tailReleased) ws.send(message);
else delayedTailForwards.push(() => ws.send(message));
return;
}
if (isTimelineResponse && payload.direction === "after") {
resolveDelayedCatchUp?.();
if (catchUpReleased) ws.send(message);
else delayedCatchUpForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
tailReleased = true;
for (const forward of delayedTailForwards.splice(0)) forward();
},
releaseCatchUp() {
catchUpReleased = true;
for (const forward of delayedCatchUpForwards.splice(0)) forward();
},
waitForDelayedResponse: () => delayedTail,
waitForDelayedCatchUp: () => delayedCatchUp,
};
}
async function delayAgentTimelineResponse(
page: Page,
agentId: string,
direction: "before" | "tail",
): Promise<AgentTimelineResponseGate> {
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -325,7 +148,7 @@ async function delayAgentTimelineResponse(
!delayedResponseSeen &&
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === direction
payload.direction === "before"
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();

View File

@@ -1,343 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { deflateSync } from "node:zlib";
import { expect, type Page } from "@playwright/test";
import type { ArchiveTabAgent } from "./archive-tab";
import { openWorkspaceWithAgents } from "./archive-tab";
import { submitMessage } from "./composer";
import type { SeedDaemonClient, SeededWorkspace } from "./seed-client";
import { openAgentRoute } from "./mock-agent";
import { rememberTimelineViewport, userScrollsTimelineToHistoryStart } from "./timeline-pagination";
const IMAGE_PREVIEW_ERROR = "Unable to load image preview.";
const SMALL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
const TEN_MEBIBYTES = 10 * 1024 * 1024;
export interface AssistantImageFixture {
alt: string;
height: number;
relativePath: string;
size: number;
width: number;
}
export async function createSmallAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
): Promise<AssistantImageFixture> {
await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG);
return {
alt: input.alt,
height: 1,
relativePath: input.fileName,
size: SMALL_PNG.byteLength,
width: 1,
};
}
export async function createNearTenMegabyteAssistantPng(
workspace: SeededWorkspace,
input: { alt: string; fileName: string },
): Promise<AssistantImageFixture> {
const width = 2_048;
const height = 1_280;
const bytes = encodeDeterministicPng(width, height);
if (bytes.byteLength < TEN_MEBIBYTES * 0.95 || bytes.byteLength > TEN_MEBIBYTES * 1.05) {
throw new Error(`Expected a PNG near 10 MiB, encoded ${bytes.byteLength} bytes`);
}
await writeFile(path.join(workspace.repoPath, input.fileName), bytes);
return {
alt: input.alt,
height,
relativePath: input.fileName,
size: bytes.byteLength,
width,
};
}
export async function createSettledMockAgent(
workspace: SeededWorkspace,
title: string,
): Promise<ArchiveTabAgent> {
const agent = await workspace.client.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title,
});
await workspace.client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return {
id: agent.id,
title,
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
};
}
export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
throw new Error(
`Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`,
);
}
}
export async function appendSettledTimelineTurns(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
count: number,
): Promise<void> {
for (let index = 0; index < count; index += 1) {
await client.sendAgentMessage(
agent.id,
`image-history-turn-${index}: emit 1 coalesced agent stream updates`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
throw new Error(
`Assistant image history turn did not settle: ${result.final?.lastError ?? result.status}`,
);
}
}
}
export async function expectAssistantImageRendered(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt }).first();
await expect(rendered).toBeVisible({ timeout: 30_000 });
await expect
.poll(async () =>
rendered.evaluate((element) => {
const imageElement =
element instanceof HTMLImageElement ? element : element.querySelector("img");
return imageElement?.complete
? { height: imageElement.naturalHeight, width: imageElement.naturalWidth }
: null;
}),
)
.toEqual({ height: image.height, width: image.width });
}
export async function switchAwayAndBackWithoutImageInstability(
page: Page,
input: {
image: AssistantImageFixture;
imageAgent: ArchiveTabAgent;
otherAgent: ArchiveTabAgent;
},
): Promise<void> {
await beginVisibleImageStabilityObservation(page, input.image.alt, input.imageAgent.id);
await selectSettledAgentTab(page, input.otherAgent);
await selectSettledAgentTab(page, input.imageAgent);
await expectAssistantImageRendered(page, input.image);
await expectNoVisibleImageInstability(page);
}
export async function openExistingImageAgentTabs(
page: Page,
input: { imageAgent: ArchiveTabAgent; otherAgent: ArchiveTabAgent },
): Promise<void> {
await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]);
}
export async function openAssistantImageTimeline(
page: Page,
agent: ArchiveTabAgent,
): Promise<void> {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id });
}
export async function expectAssistantImageNotMounted(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0);
}
export async function userPagesUntilAssistantImageRenders(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
const rendered = page.getByRole("img", { name: image.alt });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await rendered.count()) > 0) {
await expectAssistantImageRendered(page, image);
return;
}
const previous = await rememberTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await rememberTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previous.scrollHeight);
}
await expectAssistantImageRendered(page, image);
}
export async function remountAndRecoverAssistantImageFromHistory(
page: Page,
image: AssistantImageFixture,
): Promise<void> {
await page.reload();
await userPagesUntilAssistantImageRenders(page, image);
}
export async function sendFollowUpAndExpectVisibleResponse(
page: Page,
input: { prompt: string; response: string },
): Promise<void> {
await submitMessage(page, input.prompt);
await expect(page.getByText(input.prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(input.response, { exact: true })).toBeVisible({ timeout: 30_000 });
}
async function selectSettledAgentTab(page: Page, agent: ArchiveTabAgent): Promise<void> {
const tab = page.getByRole("button", { name: agent.title, exact: true });
await tab.click();
await expect(page).toHaveTitle(agent.title);
await expect(tab).toHaveAttribute("aria-selected", "true");
await expect(page.locator('[data-testid="agent-chat-scroll"]:visible').first()).toBeVisible({
timeout: 30_000,
});
}
async function beginVisibleImageStabilityObservation(
page: Page,
alt: string,
imageAgentId: string,
): Promise<void> {
await page.evaluate(
({ accessibleName, errorText, tabTestId }) => {
const record = {
errorSeen: false,
missingSeen: false,
inspect: () => undefined,
observer: null as MutationObserver | null,
};
const isVisible = (element: Element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
record.inspect = () => {
record.errorSeen ||= Array.from(document.querySelectorAll("*")).some(
(element) =>
element.children.length === 0 &&
element.textContent?.trim() === errorText &&
isVisible(element),
);
const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`);
if (imageTab?.getAttribute("aria-selected") !== "true") return;
const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some(
(element) => element.getAttribute("aria-label") === accessibleName && isVisible(element),
);
record.missingSeen ||= !imageVisible;
};
record.observer = new MutationObserver(record.inspect);
record.observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
characterData: true,
});
record.inspect();
(
window as unknown as {
__paseoAssistantImageObservation?: typeof record;
}
).__paseoAssistantImageObservation = record;
},
{
accessibleName: alt,
errorText: IMAGE_PREVIEW_ERROR,
tabTestId: `workspace-tab-agent_${imageAgentId}`,
},
);
}
async function expectNoVisibleImageInstability(page: Page): Promise<void> {
const observation = await page.evaluate(() => {
const owner = window as unknown as {
__paseoAssistantImageObservation?: {
errorSeen: boolean;
missingSeen: boolean;
inspect(): void;
observer: MutationObserver | null;
};
};
const record = owner.__paseoAssistantImageObservation;
if (!record) throw new Error("Assistant image stability observation was not started");
record.inspect();
record.observer?.disconnect();
delete owner.__paseoAssistantImageObservation;
return { errorSeen: record.errorSeen, missingSeen: record.missingSeen };
});
expect(observation).toEqual({ errorSeen: false, missingSeen: false });
}
function encodeDeterministicPng(width: number, height: number): Buffer {
const stride = width * 4 + 1;
const scanlines = Buffer.allocUnsafe(stride * height);
let state = 0x9e3779b9;
for (let row = 0; row < height; row += 1) {
const rowOffset = row * stride;
scanlines[rowOffset] = 0;
for (let offset = 1; offset < stride; offset += 1) {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
scanlines[rowOffset + offset] = state & 0xff;
}
}
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(scanlines, { level: 0 })),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function pngChunk(type: string, data: Buffer): Buffer {
const typeBytes = Buffer.from(type, "ascii");
const chunk = Buffer.allocUnsafe(12 + data.byteLength);
chunk.writeUInt32BE(data.byteLength, 0);
typeBytes.copy(chunk, 4);
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.byteLength);
return chunk;
}
function crc32(bytes: Buffer): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}

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

@@ -1,51 +1,20 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import {
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
holdDaemonHydration,
holdAgentOlderTimelinePages,
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
interface LongTimelineAgentOptions {
turns: number;
}
interface LongTimelineAgent extends MockAgentWorkspace {
initialTailOldestPrompt: string;
oldestPrompt: string;
newestPrompt: string;
}
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
interface TimelineViewportSnapshot {
scrollHeight: number;
scrollTop: number;
}
interface TimelinePromptPositionSnapshot {
prompt: string;
top: number;
}
interface OlderHistoryLoadingOperation {
animationStartTime: number | null;
marker: string;
}
interface OlderHistoryPages {
expectRequestedPages(count: number): Promise<void>;
expectSettledWithRequestedPages(count: number): Promise<void>;
releasePage(pageNumber: number): void;
}
function promptForTurn(index: number): string {
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
@@ -67,41 +36,11 @@ export async function seedLongMockAgentTimeline(
return {
...agent,
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
oldestPrompt: promptForTurn(0),
newestPrompt: promptForTurn(options.turns - 1),
};
}
export async function rememberTimelinePromptPosition(
page: Page,
prompt: string,
): Promise<TimelinePromptPositionSnapshot> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(prompt, { exact: true });
await expect(item).toBeVisible();
const box = await item.boundingBox();
if (!box) {
throw new Error(`Expected a rendered timeline item for ${prompt}`);
}
return { prompt, top: box.y };
}
export async function expectTimelinePromptPositionPreserved(
page: Page,
before: TimelinePromptPositionSnapshot,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const item = timeline.getByText(before.prompt, { exact: true });
await expect(item).toBeVisible();
await expect
.poll(async () => {
const box = await item.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(2);
}
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId));
await page.waitForURL(
@@ -111,17 +50,15 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P
}
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
}
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0);
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1280, height: 20_000 });
await page.setViewportSize({ width: 1280, height: 8_000 });
}
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
@@ -138,33 +75,6 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<voi
.toBe(true);
}
export async function reloadAgentTimelineFromPersistedReplica(
page: Page,
agent: LongTimelineAgent,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((agentId) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -180,106 +90,6 @@ export async function holdNextOlderTimelinePage(
};
}
export async function holdOlderHistoryPages(
page: Page,
agent: LongTimelineAgent,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId);
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
expect(gate.getRequestCount()).toBe(count);
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count);
},
releasePage(pageNumber) {
gate.releasePage(pageNumber);
},
};
}
export async function rememberTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
return readTimelineViewport(page);
}
export async function expectTimelineViewportAnchoredAfterPrepend(
page: Page,
before: TimelineViewportSnapshot,
): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(before.scrollHeight);
await waitForTimelineGeometryToSettle(page);
const after = await readTimelineViewport(page);
const contentGrowth = after.scrollHeight - before.scrollHeight;
const scrollAdjustment = after.scrollTop - before.scrollTop;
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
export async function rememberOlderHistoryLoadingOperation(
page: Page,
): Promise<OlderHistoryLoadingOperation> {
const slot = page.getByTestId("load-older-history-spinner");
await expect(slot).toBeVisible();
const marker = `older-history-loading-${Date.now()}`;
await slot.evaluate((element, operationMarker) => {
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
const animated = candidates.find(
(candidate) => getComputedStyle(candidate).animationName !== "none",
);
if (!(animated instanceof HTMLElement)) {
throw new Error("Expected the older-history loader to contain an animated element");
}
animated.dataset.olderHistoryLoadingOperation = operationMarker;
}, marker);
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
await expect
.poll(async () => {
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
return typeof startTime === "number" ? startTime : null;
})
.not.toBeNull();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
return { animationStartTime, marker };
}
export async function expectSameOlderHistoryLoadingOperation(
page: Page,
operation: OlderHistoryLoadingOperation,
): Promise<void> {
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
await expect(animated).toBeVisible();
const animationStartTime = await animated.evaluate((element) => {
const startTime = element.getAnimations()[0]?.startTime;
return typeof startTime === "number" ? startTime : null;
});
expect(animationStartTime).toBe(operation.animationStartTime);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function holdBootstrapTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<BootstrapTimelineGate> {
return delayAgentBootstrapTailResponse(page, agent.agentId);
}
export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise<string> {
await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT);
await agent.client.waitForFinish(agent.agentId, 15_000);
return LIVE_BEFORE_HYDRATION_PROMPT;
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
@@ -299,95 +109,31 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
});
}
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();
for (let step = 0; step < 60; step += 1) {
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
break;
}
await page.mouse.wheel(0, -1_000);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
}),
);
}
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
}
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate((element) => {
const previousHeight = await scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
return element.scrollHeight;
});
}
export async function scrollTimelineUntilOlderHistoryIsReachable(
page: Page,
oldestPrompt: string,
): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const prompt = scroll.getByText(oldestPrompt, { exact: true });
for (let attempt = 0; attempt < 10; attempt += 1) {
if ((await prompt.count()) > 0) {
await expect(prompt).toBeVisible();
return;
}
const previousHeight = await readTimelineViewport(page);
await userScrollsTimelineToHistoryStart(page);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollHeight)
.toBeGreaterThan(previousHeight.scrollHeight);
await waitForTimelineGeometryToSettle(page);
}
await expect(prompt).toBeVisible();
}
async function readTimelineViewport(page: Page): Promise<TimelineViewportSnapshot> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop };
});
}
async function waitForTimelineGeometryToSettle(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.evaluate(
(element) =>
new Promise<void>((resolve, reject) => {
if (!(element instanceof HTMLElement)) {
reject(new Error("Agent chat scroll element is not an HTMLElement"));
return;
}
const startedAt = performance.now();
let stableFrames = 0;
let previous = `${element.scrollTop}:${element.scrollHeight}`;
const sample = () => {
const current = `${element.scrollTop}:${element.scrollHeight}`;
stableFrames = current === previous ? stableFrames + 1 : 0;
previous = current;
if (stableFrames >= 4) {
resolve();
return;
}
if (performance.now() - startedAt > 5_000) {
reject(new Error("Timeline geometry did not settle"));
return;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
await scrollTimelineToOldestLoadedEdge(page);
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight;
}),
)
.toBeGreaterThan(previousHeight);
await scrollTimelineToOldestLoadedEdge(page);
}

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

@@ -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

@@ -2,7 +2,6 @@ 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 { scrollChatAwayFromBottom } from "./helpers/agent-bottom-anchor";
import {
composerLocator,
expectComposerDraft,
@@ -192,10 +191,6 @@ test.describe("Rewind sheet", () => {
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await scrollChatAwayFromBottom(page, {
deltaY: -900,
minDistanceFromBottom: 300,
});
await userMessage(page, firstPrompt).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-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,15 +1,11 @@
import { describe, expect, it } from "vitest";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
const visibleHistoryStart: HistoryStartPaginationInput = {
const visibleHistoryStart = {
distanceFromHistoryStart: 0,
hasOlderHistory: true,
isLoadingOlderHistory: false,
@@ -18,140 +14,47 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
};
describe("history start pagination", () => {
it("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
it("loads once for each authoritative history cursor", () => {
const initial = createHistoryStartPaginationState();
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
const nextPage = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
true,
false,
false,
true,
]);
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
});
it("loads one page each time anchored geometry leaves and returns to history start", () => {
it("allows the same revision again after the user leaves the history edge", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
const away = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const settledAway = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
progressKey: "epoch-1:10",
});
const returned = evaluateHistoryStartPagination(settledAway.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
distanceFromHistoryStart: 200,
});
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
expect([
first.shouldLoad,
pageApplied.shouldLoad,
settledAway.shouldLoad,
returned.shouldLoad,
]).toEqual([true, false, false, true]);
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
});
it("continues the same loading operation when anchored geometry remains at history start", () => {
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const continued = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect(continued.shouldLoad).toBe(true);
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
});
it("latches a request that finishes without cursor progress", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
const away = evaluateHistoryStartPagination(duplicate.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
});
expect(finished.state).toEqual({ status: "latched" });
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
false,
false,
false,
]);
expect(away.state).toEqual({ status: "ready" });
});
it("allows another user attempt after a request finishes without progress", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(failed.state),
rearmHistoryStartPagination(first.state),
visibleHistoryStart,
);
expect(failed.state).toEqual({ status: "latched" });
expect(retried.shouldLoad).toBe(true);
});
it("latches an attempt that becomes invalid before entering flight", () => {
const requested = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
status: "latched",
});
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
expect(repeated.state).toBe(first.state);
expect(repeated.shouldLoad).toBe(false);
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
});
it("waits while history loading is unavailable or already active", () => {

View File

@@ -1,91 +1,33 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export type HistoryStartPaginationState =
| { status: "ready" }
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
| { status: "settling"; loadedProgressKey: string }
| { status: "latched" };
export interface HistoryStartPaginationInput {
distanceFromHistoryStart: number;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
}
export interface HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
export interface HistoryStartPaginationState {
requestedProgressKey: string | null;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "ready" };
}
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
return { requestedProgressKey: null };
}
export function rearmHistoryStartPagination(
state: HistoryStartPaginationState,
_state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return state.status === "latched" ? { status: "ready" } : state;
}
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
return createHistoryStartPaginationState();
}
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status === "loading") {
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
return {
state: { status: "settling", loadedProgressKey: input.progressKey },
shouldLoad: false,
};
}
if (input.isLoadingOlderHistory && !state.requestObserved) {
return { state: { ...state, requestObserved: true }, shouldLoad: false };
}
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
return { state: { status: "latched" }, shouldLoad: false };
}
if (
state.requestObserved &&
!input.isLoadingOlderHistory &&
input.progressKey === state.requestedProgressKey
) {
return { state: { status: "latched" }, shouldLoad: false };
}
return { state, shouldLoad: false };
}
if (state.status === "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (!isAtHistoryStart) {
return state.status === "ready"
? { state, shouldLoad: false }
: { state: { status: "ready" }, shouldLoad: false };
input: {
distanceFromHistoryStart: number;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
},
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
return { state: createHistoryStartPaginationState(), shouldLoad: false };
}
if (
state.status === "latched" ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
@@ -93,42 +35,11 @@ export function evaluateHistoryStartPagination(
) {
return { state, shouldLoad: false };
}
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
shouldLoad: true,
};
}
export function settleHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status !== "settling") {
if (state.requestedProgressKey === input.progressKey) {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (
!isAtHistoryStart ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return {
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
shouldLoad: false,
};
}
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
},
state: { requestedProgressKey: input.progressKey },
shouldLoad: true,
};
}

View File

@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createHistoryStartSettleScheduler } from "./history-start-settle-scheduler";
describe("history start settle scheduler", () => {
it("does not restart its countdown when geometry keeps changing", () => {
const frames = new Map<number, () => void>();
let nextFrameId = 1;
const onFrame = vi.fn();
const onSettle = vi.fn();
const scheduler = createHistoryStartSettleScheduler({
settleFrames: 2,
requestFrame(callback) {
const id = nextFrameId;
nextFrameId += 1;
frames.set(id, callback);
return id;
},
cancelFrame: (id) => frames.delete(id),
isSettling: () => true,
isLoading: () => false,
onFrame,
onSettle,
});
const runNextFrame = () => {
const next = frames.entries().next().value as [number, () => void] | undefined;
if (!next) {
throw new Error("Expected a scheduled settlement frame");
}
frames.delete(next[0]);
next[1]();
};
scheduler.schedule();
scheduler.schedule();
runNextFrame();
scheduler.schedule();
runNextFrame();
scheduler.schedule();
runNextFrame();
expect(onSettle).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenCalledTimes(3);
expect(frames.size).toBe(0);
});
});

View File

@@ -1,52 +0,0 @@
export interface HistoryStartSettleScheduler {
schedule(): void;
cancel(): void;
}
export function createHistoryStartSettleScheduler(input: {
settleFrames: number;
requestFrame: (callback: () => void) => number;
cancelFrame: (id: number) => void;
isSettling: () => boolean;
isLoading: () => boolean;
onFrame?: () => void;
onSettle: () => void;
}): HistoryStartSettleScheduler {
let frameId: number | null = null;
let remainingFrames = 0;
const tick = () => {
input.onFrame?.();
if (!input.isSettling()) {
frameId = null;
remainingFrames = 0;
return;
}
if (input.isLoading() || remainingFrames > 0) {
if (!input.isLoading()) {
remainingFrames -= 1;
}
frameId = input.requestFrame(tick);
return;
}
frameId = null;
input.onSettle();
};
return {
schedule() {
if (frameId !== null) {
return;
}
remainingFrames = input.settleFrames;
frameId = input.requestFrame(tick);
},
cancel() {
if (frameId !== null) {
input.cancelFrame(frameId);
}
frameId = null;
remainingFrames = 0;
},
};
}

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,
@@ -32,19 +31,10 @@ import {
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
@@ -62,7 +52,6 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
const HISTORY_START_SETTLE_FRAMES = 2;
interface HistoryRowDisplayVariants {
regular?: StreamItem;
@@ -121,17 +110,12 @@ 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);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -160,74 +144,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => {
const evaluateHistoryStart = useStableEvent(() => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
};
});
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (transition.shouldLoad) {
const requestedProgressKey = olderHistoryProgressKey;
if (requestedProgressKey === null) {
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
);
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
scheduler = createHistoryStartSettleScheduler({
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
scheduler.schedule();
});
const clearNativeViewportSettling = useCallback(() => {
@@ -328,9 +260,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -338,8 +268,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
@@ -407,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),
@@ -439,25 +365,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const handleScrollBeginDrag = useStableEvent(() => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
evaluateHistoryStart();
});
// Defer drag end so momentum can take ownership, but capture the terminal
// 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;
@@ -525,23 +448,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
});
useEffect(() => {
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
]);
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
@@ -582,21 +493,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
]);
const historyFooterContent = useMemo(() => {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!hasOlderHistory && !isLoadingOperation) {
if (!hasOlderHistory && !isLoadingOlderHistory) {
return null;
}
return (
<View
style={historyStartSlotStyle}
testID={isLoadingOperation ? "load-older-history-spinner" : undefined}
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
>
{isLoadingOperation ? (
{isLoadingOlderHistory ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
</View>
);
}, [hasOlderHistory, historyStartPaginationState]);
}, [hasOlderHistory, isLoadingOlderHistory]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.

View File

@@ -130,7 +130,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -174,7 +174,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -230,7 +230,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -285,6 +285,76 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
HTMLElement.prototype.scrollTo = vi.fn(function (
this: HTMLElement,
@@ -296,7 +366,7 @@ describe("createWebStreamStrategy", () => {
});
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn().mockReturnValue(true);
const onNearHistoryStart = vi.fn();
const renderInput = {
agentId: "agent",
boundary: {
@@ -403,7 +473,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -511,7 +581,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -608,7 +678,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -707,7 +777,7 @@ describe("createWebStreamStrategy", () => {
viewportRef,
routeBottomAnchorRequest: null,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,

View File

@@ -17,30 +17,15 @@ import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
} from "./history-start-settle-scheduler";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
}
interface HistoryStartPrependAnchor {
progressKey: string;
rowId: string;
viewportOffset: number;
}
type ScrollBehaviorLike = "auto" | "smooth";
const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
@@ -48,22 +33,12 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_SETTLE_FRAMES = 2;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null {
for (const element of contentNode.querySelectorAll<HTMLElement>("[data-history-row-id]")) {
if (element.dataset.historyRowId === rowId) {
return element;
}
}
return null;
}
const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
@@ -171,14 +146,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const [isHistoryStartSlotReserved, setIsHistoryStartSlotReserved] = useState(hasOlderHistory);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartPrependAnchorRef = useRef<HistoryStartPrependAnchor | null>(null);
const historyStartPrependAnchorActiveRef = useRef(false);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -208,9 +176,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
});
useEffect(() => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => {
if (historyStartPrependAnchorActiveRef.current) {
return false;
}
const viewportHeight = instance.scrollRect?.height ?? 0;
const scrollOffset = instance.scrollOffset ?? 0;
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
@@ -222,162 +187,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [rowVirtualizer]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
const evaluateHistoryStart = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return null;
return;
}
const bottomAnchorSettled =
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
return {
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
distanceFromHistoryStart: scrollContainer.scrollTop,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && bottomAnchorSettled,
progressKey: olderHistoryProgressKey,
};
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
});
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (!isHistoryStartLoadingOperation(transition.state)) {
historyStartPrependAnchorRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
}
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
return;
}
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1);
const anchorElement =
contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null;
if (scrollContainer && anchorRow && anchorElement) {
historyStartPrependAnchorRef.current = {
progressKey: olderHistoryProgressKey,
rowId: anchorRow.id,
viewportOffset:
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
};
} else {
historyStartPrependAnchorRef.current = null;
}
historyStartPrependAnchorActiveRef.current = false;
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
);
const evaluateHistoryStart = useStableEvent(() => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
});
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed === historyStartPaginationStateRef.current) {
return;
}
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
});
const applyHistoryStartPrependAnchor = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
const contentNode = contentRef.current;
const anchor = historyStartPrependAnchorRef.current;
if (
!scrollContainer ||
!contentNode ||
!anchor ||
!historyStartPrependAnchorActiveRef.current
) {
return;
}
const anchorElement = findHistoryRowElement(contentNode, anchor.rowId);
if (!anchorElement) {
return;
}
const viewportOffset =
anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset;
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
});
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
scheduler = createHistoryStartSettleScheduler({
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isLoading: () => {
const input = getHistoryStartPaginationInput();
return (
!input ||
input.isLoadingOlderHistory ||
pendingVirtualRowMeasureFramesRef.current.size > 0
);
},
onFrame: applyHistoryStartPrependAnchor,
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
historyStartPrependAnchorActiveRef.current = false;
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
historyStartPrependAnchorRef.current = null;
applyHistoryStartPaginationTransition(transition);
},
});
historyStartSettleSchedulerRef.current = scheduler;
}
scheduler.schedule();
});
useLayoutEffect(() => {
const anchor = historyStartPrependAnchorRef.current;
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
return;
}
historyStartPrependAnchorActiveRef.current = true;
evaluateHistoryStart();
applyHistoryStartPrependAnchor();
scheduleHistoryStartPrependSettle();
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
segments.historyMounted,
segments.historyVirtualized,
virtualTotalSize,
]);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -507,11 +335,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
useEffect(() => {
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
historyStartPrependAnchorRef.current = null;
historyStartPrependAnchorActiveRef.current = false;
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -519,8 +343,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart, props.agentId]);
@@ -579,15 +401,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
updateScrollMetrics();
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -605,12 +423,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
evaluateHistoryStart();
const observer = new ResizeObserver(() => {
if (historyStartPrependAnchorActiveRef.current) {
applyHistoryStartPrependAnchor();
}
if (historyStartPaginationStateRef.current.status === "settling") {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
evaluateHistoryStart();
if (!followOutputRef.current) {
@@ -625,13 +437,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect();
};
}, [
applyHistoryStartPrependAnchor,
evaluateHistoryStart,
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
]);
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -641,9 +447,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
rearmHistoryStartFromUserIntent();
evaluateHistoryStart();
}
};
const handlePointerDown = () => {
@@ -666,9 +477,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
rearmHistoryStartFromUserIntent();
evaluateHistoryStart();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -697,7 +513,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -764,9 +580,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<div key={item.id} data-history-row-id={item.id}>
<Fragment key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</div>
</Fragment>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
@@ -778,27 +594,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
useEffect(() => {
if (hasOlderHistory || isHistoryStartLoadingOperation(historyStartPaginationState)) {
setIsHistoryStartSlotReserved(true);
}
}, [hasOlderHistory, historyStartPaginationState]);
const historyStartSlot = useMemo(() => {
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
if (!isHistoryStartSlotReserved && !hasOlderHistory && !isLoadingOperation) {
if (!hasOlderHistory && !isLoadingOlderHistory) {
return null;
}
return (
<div
style={historyStartSlotStyle}
data-testid={isLoadingOperation ? "load-older-history-spinner" : undefined}
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
>
{isLoadingOperation ? (
{isLoadingOlderHistory ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
</div>
);
}, [hasOlderHistory, historyStartPaginationState, isHistoryStartSlotReserved]);
}, [hasOlderHistory, isLoadingOlderHistory]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -825,7 +635,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div
key={virtualRow.key}
data-index={virtualRow.index}
data-history-row-id={item.id}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>

View File

@@ -69,7 +69,7 @@ export interface StreamRenderInput {
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => boolean | Promise<boolean>;
onNearHistoryStart: () => void;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;

View File

@@ -77,7 +77,6 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./bottom-anchor-controller";
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
@@ -251,7 +250,7 @@ export interface AgentStreamViewProps {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => boolean | Promise<boolean>;
onLoadOlder: () => void;
};
}
@@ -695,7 +694,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
toast={toast}
>
<AssistantMessage
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
@@ -706,7 +704,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider>
);
},
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(

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

@@ -1,193 +0,0 @@
import { describe, expect, it } from "vitest";
import {
collectRetainedAttachmentIds,
retainAttachmentForGarbageCollection,
} from "@/attachments/gc-retention";
import {
createAssistantImageAcquisitionCache,
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
createAssistantImageOccurrenceKey,
} from "./acquisition-cache";
describe("assistant image acquisition cache", () => {
it("evicts a rejected acquisition so the next request can retry", async () => {
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
let attempts = 0;
await expect(
cache.acquire("image", async () => {
attempts += 1;
throw new Error("first attempt failed");
}),
).rejects.toThrow("first attempt failed");
const recovered = await cache.acquire("image", async () => {
attempts += 1;
return "recovered";
});
expect({ attempts, recovered, size: cache.size() }).toEqual({
attempts: 2,
recovered: "recovered",
size: 1,
});
});
it("bounds successful acquisitions and evicts the least recently used entry", async () => {
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
const located: string[] = [];
const locate = async (key: string) => {
located.push(key);
return key;
};
await cache.acquire("a", async () => await locate("a"));
await cache.acquire("b", async () => await locate("b"));
await cache.acquire("a", async () => await locate("a-again"));
await cache.acquire("c", async () => await locate("c"));
await cache.acquire("b", async () => await locate("b-again"));
expect({ located, size: cache.size() }).toEqual({
located: ["a", "b", "c", "b-again"],
size: 2,
});
});
it("reuses an acquired image when the current locator is unavailable", async () => {
const cache = createAssistantImageAcquisitionCache<string>({ capacity: 2 });
let unavailableCalls = 0;
await cache.acquire("message:image", async () => "persisted attachment");
const cached = await cache.acquire("message:image", async () => {
unavailableCalls += 1;
throw new Error("daemon disconnected");
});
expect({ cached, unavailableCalls }).toEqual({
cached: "persisted attachment",
unavailableCalls: 0,
});
expect(cache.peek("message:image")).toBe("persisted attachment");
});
it("scopes file acquisitions to the rendered message occurrence", () => {
const first = createAssistantImageFileAcquisitionKey({
serverId: "server",
occurrenceKey: "message-1:image-1",
cwd: "/workspace",
path: "screenshot.png",
});
const remount = createAssistantImageFileAcquisitionKey({
serverId: "server",
occurrenceKey: "message-1:image-1",
cwd: "/workspace",
path: "screenshot.png",
});
const laterMessage = createAssistantImageFileAcquisitionKey({
serverId: "server",
occurrenceKey: "message-2:image-1",
cwd: "/workspace",
path: "screenshot.png",
});
expect(remount).toBe(first);
expect(laterMessage).not.toBe(first);
});
it("scopes persisted file previews to the rendered message occurrence", () => {
const first = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
occurrenceKey: "message-1:image-1",
mimeType: "image/png",
path: "/workspace/screenshot.png",
size: 512,
modifiedAt: "2026-07-27T12:00:00.000Z",
contentLength: 512,
});
const second = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
occurrenceKey: "message-2:image-1",
mimeType: "image/png",
path: "/workspace/screenshot.png",
size: 512,
modifiedAt: "2026-07-27T12:00:00.000Z",
contentLength: 512,
});
expect(second).not.toBe(first);
});
it("scopes message occurrences to their agent", () => {
const first = createAssistantImageOccurrenceKey({ agentId: "agent-1", itemId: "message-1" });
const second = createAssistantImageOccurrenceKey({ agentId: "agent-2", itemId: "message-1" });
expect(second).not.toBe(first);
});
it("retains successful values until their cache entry is evicted", async () => {
const retained: string[] = [];
const released: string[] = [];
const cache = createAssistantImageAcquisitionCache<string>({
capacity: 1,
onRetain(value) {
retained.push(value);
return () => released.push(value);
},
});
await cache.acquire("first", async () => "attachment-1");
expect({ retained, released }).toEqual({ retained: ["attachment-1"], released: [] });
await cache.acquire("second", async () => "attachment-2");
expect({ retained, released }).toEqual({
retained: ["attachment-1", "attachment-2"],
released: ["attachment-1"],
});
});
it("does not evict a value while an active consumer retains it", async () => {
const released: string[] = [];
const cache = createAssistantImageAcquisitionCache<string>({
capacity: 1,
onRetain: (value) => () => released.push(value),
});
const first = cache.acquireRetained("first", async () => "attachment-1");
await first.promise;
const second = cache.acquireRetained("second", async () => "attachment-2");
await second.promise;
expect({ released, size: cache.size() }).toEqual({ released: [], size: 2 });
first.release();
expect({ released, size: cache.size() }).toEqual({
released: ["attachment-1"],
size: 1,
});
second.release();
});
it("protects every actively consumed attachment from garbage collection past capacity", async () => {
const cache = createAssistantImageAcquisitionCache<{ id: string }>({
capacity: 1,
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
});
const first = cache.acquireRetained("first", async () => ({ id: "mounted-image-1" }));
await first.promise;
const second = cache.acquireRetained("second", async () => ({ id: "mounted-image-2" }));
await second.promise;
expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-1", "mounted-image-2"]));
first.release();
expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-2"]));
second.release();
await expect(
cache.acquire("cleanup", async () => {
throw new Error("cleanup");
}),
).rejects.toThrow("cleanup");
expect(collectRetainedAttachmentIds()).toEqual(new Set());
});
});

View File

@@ -1,159 +0,0 @@
import { createPreviewAttachmentId } from "@/attachments/utils";
export interface AssistantImageAcquisitionCache<T> {
acquire(key: string, locate: () => Promise<T>): Promise<T>;
acquireRetained(
key: string,
locate: () => Promise<T>,
): { promise: Promise<T>; value?: T; release: () => void };
peek(key: string): T | undefined;
size(): number;
}
export function createAssistantImageOccurrenceKey(input: {
agentId: string;
itemId: string;
}): string {
return `${input.agentId}:${input.itemId}`;
}
export function createAssistantImageFilePreviewAttachmentId(input: {
serverId?: string;
occurrenceKey: string;
mimeType: string;
path: string;
size: number;
modifiedAt?: string | null;
contentLength: number;
}): string {
return createPreviewAttachmentId({
mimeType: input.mimeType,
path: input.path,
size: input.size,
modifiedAt: input.modifiedAt,
contentLength: input.contentLength,
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
});
}
export function createAssistantImageFileAcquisitionKey(input: {
serverId?: string;
occurrenceKey: string;
cwd: string;
path: string;
}): string {
return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
}
export function createAssistantImageAcquisitionCache<T>(input: {
capacity: number;
onRetain?: (value: T) => () => void;
}): AssistantImageAcquisitionCache<T> {
if (!Number.isInteger(input.capacity) || input.capacity < 1) {
throw new Error("Assistant image acquisition cache capacity must be a positive integer.");
}
interface CacheEntry {
pending: Promise<T>;
resolved: boolean;
value?: T;
release: (() => void) | null;
activeConsumers: number;
}
const entries = new Map<string, CacheEntry>();
const evict = (key: string, entry: CacheEntry) => {
if (entries.get(key) === entry) {
entries.delete(key);
}
entry.release?.();
entry.release = null;
};
const enforceCapacity = () => {
while (entries.size > input.capacity) {
let evicted = false;
for (const [key, entry] of entries) {
if (entry.activeConsumers > 0) {
continue;
}
evict(key, entry);
evicted = true;
break;
}
if (!evicted) {
return;
}
}
};
const acquireEntry = (key: string, locate: () => Promise<T>, retain: boolean): CacheEntry => {
const cached = entries.get(key);
if (cached) {
entries.delete(key);
entries.set(key, cached);
if (retain) {
cached.activeConsumers += 1;
}
return cached;
}
const pending = locate();
const entry: CacheEntry = {
pending,
resolved: false,
release: null,
activeConsumers: retain ? 1 : 0,
};
entries.set(key, entry);
enforceCapacity();
void (async () => {
try {
const value = await pending;
const release = input.onRetain?.(value) ?? null;
if (entries.get(key) === entry) {
entry.value = value;
entry.resolved = true;
entry.release = release;
} else {
release?.();
}
} catch {
evict(key, entry);
}
})();
return entry;
};
return {
acquire(key, locate) {
return acquireEntry(key, locate, false).pending;
},
acquireRetained(key, locate) {
const entry = acquireEntry(key, locate, true);
let released = false;
return {
promise: entry.pending,
...(entry.resolved ? { value: entry.value } : {}),
release() {
if (released) {
return;
}
released = true;
entry.activeConsumers = Math.max(0, entry.activeConsumers - 1);
enforceCapacity();
},
};
},
peek(key) {
const entry = entries.get(key);
if (!entry?.resolved) {
return undefined;
}
entries.delete(key);
entries.set(key, entry);
return entry.value;
},
size() {
return entries.size;
},
};
}

View File

@@ -1,53 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import {
createAssistantImageFileAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
readonly reads: Array<{ cwd: string; path: string }> = [];
async readFile(cwd: string, path: string) {
this.reads.push({ cwd, path });
return {
kind: "image" as const,
path,
mime: "image/png",
size: 4,
modifiedAt: "1",
bytes: new Uint8Array([1, 2, 3, 4]),
};
}
async persist(input: { id: string; mimeType: string; fileName: string | null }) {
return {
id: input.id,
mimeType: input.mimeType,
storageType: "web-indexeddb" as const,
storageKey: input.id,
fileName: input.fileName,
byteSize: 4,
createdAt: 1,
} satisfies AttachmentMetadata;
}
}
describe("assistant image file acquisition", () => {
it("recreates the same acquisition with a live port after reconnect", async () => {
const common = {
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
serverId: "server",
occurrenceKey: "agent:message:reconnect-image",
unavailableMessage: "Image unavailable",
};
const disconnected = createAssistantImageFileAcquisition({ ...common, port: null });
const connectedPort = new MemoryFileAcquisitionPort();
const connected = createAssistantImageFileAcquisition({ ...common, port: connectedPort });
expect(disconnected?.key).toBe(connected?.key);
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
});
});

View File

@@ -1,67 +0,0 @@
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { getFileNameFromPath } from "@/attachments/utils";
import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source";
import {
createAssistantImageFileAcquisitionKey,
createAssistantImageFilePreviewAttachmentId,
} from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string): Promise<FileReadResult>;
persist(input: {
id: string;
bytes: Uint8Array;
mimeType: string;
fileName: string | null;
}): Promise<AttachmentMetadata>;
}
export interface AssistantImageAcquisition {
key: string;
locate: () => Promise<AttachmentMetadata>;
}
export function createAssistantImageFileAcquisition(input: {
port: AssistantImageFileAcquisitionPort | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
occurrenceKey: string;
unavailableMessage: string;
}): AssistantImageAcquisition | null {
if (input.resolution?.kind !== "file_rpc") {
return null;
}
const { port, resolution } = input;
return {
key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd,
path: resolution.path,
}),
locate: async () => {
if (!port) {
throw new Error(input.unavailableMessage);
}
const file = await port.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(input.unavailableMessage);
}
return await port.persist({
id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId,
occurrenceKey: input.occurrenceKey,
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
};
}

View File

@@ -1,120 +0,0 @@
import { describe, expect, it } from "vitest";
import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle";
describe("assistant image lifecycle", () => {
it("keeps preview URL recreation in the public loading state", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:first",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:first",
aspectRatio: 1.5,
});
const recreating = transitionAssistantImageLifecycle(loaded, {
type: "preview_released",
});
expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null });
});
it("publishes loaded only after the recreated URL loads", () => {
const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:recreated",
aspectRatio: 1.5,
});
expect(recreating).toEqual({
status: "loading",
uri: "blob:recreated",
aspectRatio: 1.5,
});
const recreated = transitionAssistantImageLifecycle(recreating, {
type: "image_loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
expect(recreated).toEqual({
status: "loaded",
uri: "blob:recreated",
aspectRatio: 0.75,
});
});
it("does not reset a loaded image when the same preview is reported again", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const loaded = transitionAssistantImageLifecycle(loading, {
type: "image_loaded",
uri: "blob:current",
aspectRatio: 1.5,
});
const repeated = transitionAssistantImageLifecycle(loaded, {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.5,
});
expect(repeated).toBe(loaded);
});
it("publishes failed for a terminal image failure on the current URI", () => {
const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const failed = transitionAssistantImageLifecycle(loading, {
type: "failed",
uri: "blob:current",
message: "Unable to load image preview.",
});
expect(failed).toEqual({
status: "failed",
message: "Unable to load image preview.",
});
});
it("ignores a stale load callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: 1.25,
});
const afterStaleLoad = transitionAssistantImageLifecycle(current, {
type: "image_loaded",
uri: "blob:released",
aspectRatio: 2,
});
expect(afterStaleLoad).toEqual(current);
});
it("ignores a stale error callback from a replaced URI", () => {
const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), {
type: "preview_created",
uri: "blob:current",
aspectRatio: null,
});
const afterStaleError = transitionAssistantImageLifecycle(current, {
type: "failed",
uri: "blob:released",
message: "Image unavailable",
});
expect(afterStaleError).toEqual(current);
});
});

View File

@@ -1,46 +0,0 @@
export type AssistantImageLifecycle =
| { status: "loading"; uri: string | null; aspectRatio: number | null }
| { status: "loaded"; uri: string; aspectRatio: number }
| { status: "failed"; message: string };
export type AssistantImageLifecycleEvent =
| { type: "preview_created"; uri: string; aspectRatio: number | null }
| { type: "preview_released" }
| { type: "image_loaded"; uri: string; aspectRatio: number }
| { type: "failed"; uri: string; message: string };
export function createAssistantImageLifecycle(): AssistantImageLifecycle {
return { status: "loading", uri: null, aspectRatio: null };
}
export function transitionAssistantImageLifecycle(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
if (event.type === "image_loaded") {
if (state.status !== "loading" || state.uri !== event.uri) {
return state;
}
return {
status: "loaded",
uri: event.uri,
aspectRatio: event.aspectRatio,
};
}
if (event.type === "failed") {
if (state.status === "failed" || state.uri !== event.uri) {
return state;
}
return { status: "failed", message: event.message };
}
if (event.type === "preview_created") {
if (state.status === "loaded" && state.uri === event.uri) {
return state;
}
return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio };
}
if (event.type === "preview_released") {
return { status: "loading", uri: null, aspectRatio: null };
}
return state;
}

View File

@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader {
constructor(private readonly dimensions: { width: number; height: number } | null) {}
read(): { width: number; height: number } | null {
return this.dimensions;
}
}
describe("assistant image load dimensions", () => {
it("prefers native source dimensions", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 640, height: 320 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 640, height: 320 });
});
it("uses browser event dimensions when native dimensions are absent", () => {
const dimensions = resolveAssistantImageLoadDimensions({
source: { width: 0, height: 0 },
target: { naturalWidth: 800, naturalHeight: 400 },
renderedImage: null,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 800, height: 400 });
});
it("falls back to the rendered image adapter", () => {
const renderedImage = { id: "rendered-image" };
const dimensions = resolveAssistantImageLoadDimensions({
target: null,
renderedImage,
renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }),
});
expect(dimensions).toEqual({ width: 900, height: 600 });
});
});

View File

@@ -1,45 +0,0 @@
export interface AssistantImageDimensions {
width: number;
height: number;
}
interface AssistantImageDimensionCandidate {
width?: unknown;
height?: unknown;
}
export interface AssistantImageRenderedDimensionsReader {
read(renderedImage: unknown): AssistantImageDimensions | null;
}
function readPositiveDimensions(
candidate: AssistantImageDimensionCandidate | null | undefined,
): AssistantImageDimensions | null {
if (
typeof candidate?.width !== "number" ||
typeof candidate.height !== "number" ||
candidate.width <= 0 ||
candidate.height <= 0
) {
return null;
}
return { width: candidate.width, height: candidate.height };
}
export function resolveAssistantImageLoadDimensions(input: {
source?: AssistantImageDimensionCandidate | null;
target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null;
renderedImage: unknown;
renderedDimensions: AssistantImageRenderedDimensionsReader;
}): AssistantImageDimensions | null {
const source = readPositiveDimensions(input.source);
if (source) {
return source;
}
const target = readPositiveDimensions({
width: input.target?.naturalWidth,
height: input.target?.naturalHeight,
});
return target ?? input.renderedDimensions.read(input.renderedImage);
}

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { runAssistantImageOperationWithRetry } from "./retry";
describe("assistant image retry", () => {
it("recovers from transient failures while the image remains mounted", async () => {
const waits: number[] = [];
let attempts = 0;
const result = await runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
if (attempts < 3) {
throw new Error("transient");
}
return "loaded";
},
delaysMs: [10, 20, 30],
wait: async (delayMs) => {
waits.push(delayMs);
},
});
expect({ attempts, waits, result }).toEqual({
attempts: 3,
waits: [10, 20],
result: "loaded",
});
});
it("stops retrying after the image is released", async () => {
let stopped = false;
let attempts = 0;
await expect(
runAssistantImageOperationWithRetry({
operation: async () => {
attempts += 1;
throw new Error("transient");
},
delaysMs: [10, 20],
shouldStop: () => stopped,
wait: async () => {
stopped = true;
},
}),
).rejects.toThrow("transient");
expect(attempts).toBe(1);
});
});

View File

@@ -1,31 +0,0 @@
export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const;
export async function runAssistantImageOperationWithRetry<T>(input: {
operation: () => Promise<T>;
delaysMs?: readonly number[];
shouldStop?: () => boolean;
wait?: (delayMs: number) => Promise<void>;
}): Promise<T> {
const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS;
const shouldStop = input.shouldStop ?? (() => false);
const wait =
input.wait ??
(async (delayMs: number) => {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
});
for (let attempt = 0; ; attempt += 1) {
try {
return await input.operation();
} catch (error) {
const delay = delays[attempt];
if (delay === undefined || shouldStop()) {
throw error;
}
await wait(delay);
if (shouldStop()) {
throw error;
}
}
}
}

View File

@@ -1,544 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ImageLoadEvent } from "react-native";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AttachmentMetadata } from "@/attachments/types";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention";
import {
persistAttachmentFromBytes,
persistAttachmentFromDataUrl,
releaseAttachmentPreviewUrl,
resolveAttachmentPreviewUrl,
} from "@/attachments/service";
import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils";
import {
getAssistantImageMetadata,
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import { createAssistantImageAcquisitionCache } from "./acquisition-cache";
import {
createAssistantImageFileAcquisition,
type AssistantImageAcquisition,
type AssistantImageFileAcquisitionPort,
} from "./file-acquisition";
import {
createAssistantImageLifecycle,
transitionAssistantImageLifecycle,
type AssistantImageLifecycle,
type AssistantImageLifecycleEvent,
} from "./lifecycle";
import {
type AssistantImageRenderedDimensionsReader,
resolveAssistantImageLoadDimensions,
} from "./load-dimensions";
import { runAssistantImageOperationWithRetry } from "./retry";
interface AssistantImageRenderBinding {
uri: string;
onRef: (instance: unknown) => void;
onLoad: (event: ImageLoadEvent) => void;
onError: () => void;
}
const renderedDimensions: AssistantImageRenderedDimensionsReader = {
read(renderedImage) {
if (!isWeb || !(renderedImage instanceof HTMLElement)) {
return null;
}
const image = renderedImage.querySelector("img");
return image && image.naturalWidth > 0 && image.naturalHeight > 0
? { width: image.naturalWidth, height: image.naturalHeight }
: null;
},
};
export type AssistantImageResult =
| {
status: "loading";
binding: AssistantImageRenderBinding | null;
aspectRatio: number | null;
}
| {
status: "loaded";
binding: AssistantImageRenderBinding;
aspectRatio: number;
}
| { status: "failed"; message: string };
interface UseAssistantImageInput {
source: string;
occurrenceKey: string;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}
type PreviewUrlState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; uri: string }
| { status: "failed"; error: unknown };
type AttachmentAcquisitionState =
| { status: "waiting" }
| { status: "loading" }
| { status: "loaded"; attachment: AttachmentMetadata }
| { status: "failed"; error: unknown };
interface DataImage {
mimeType: string;
base64: string;
cacheKey: string;
}
const attachmentAcquisitionCache = createAssistantImageAcquisitionCache<AttachmentMetadata>({
capacity: 500,
onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id),
});
interface CachedPreviewUrl {
attachment: AttachmentMetadata;
uri: string;
}
const previewUrlCache = createAssistantImageAcquisitionCache<CachedPreviewUrl>({
capacity: 500,
onRetain:
({ attachment, uri }) =>
() => {
void releaseAttachmentPreviewUrl({ attachment, url: uri });
},
});
const LOADED_IMAGE_CACHE_CAPACITY = 500;
const loadedImageCache = new Map<string, number>();
function getLoadedImageAspectRatio(uri: string): number | null {
const aspectRatio = loadedImageCache.get(uri);
if (aspectRatio === undefined) {
return null;
}
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
return aspectRatio;
}
function rememberLoadedImage(uri: string, aspectRatio: number): void {
loadedImageCache.delete(uri);
loadedImageCache.set(uri, aspectRatio);
if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) {
return;
}
const leastRecentlyUsedUri = loadedImageCache.keys().next().value;
if (leastRecentlyUsedUri !== undefined) {
loadedImageCache.delete(leastRecentlyUsedUri);
}
}
function useAttachmentAcquisition(
acquisition: AssistantImageAcquisition | null,
): AttachmentAcquisitionState {
const acquisitionKey = acquisition?.key ?? null;
const [entry, setEntry] = useState<{
key: string | null;
state: AttachmentAcquisitionState;
}>(() => {
const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined;
return {
key: acquisitionKey,
state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" },
};
});
useEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
if (!acquisition || !acquisitionKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = attachmentAcquisitionCache.acquireRetained(
acquisitionKey,
acquisition.locate,
);
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: acquisitionKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const attachment = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: acquisitionKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [acquisition, acquisitionKey]);
if (!acquisitionKey) {
return { status: "waiting" };
}
const cached = attachmentAcquisitionCache.peek(acquisitionKey);
if (cached) {
return { status: "loaded", attachment: cached };
}
return entry.key === acquisitionKey ? entry.state : { status: "waiting" };
}
function createDataImageAcquisition(input: {
source: string;
dataImage: DataImage | null;
}): AssistantImageAcquisition | null {
if (!input.dataImage) {
return null;
}
const { dataImage, source } = input;
return {
key: dataImage.cacheKey,
locate: async () =>
await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
contentKey: dataImage.cacheKey,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
}),
};
}
function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState {
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
const previewKey =
id && storageType && storageKey && mimeType
? `${id}:${storageType}:${storageKey}:${mimeType}`
: null;
const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => {
return {
key: previewKey,
state: { status: "waiting" },
};
});
const getCurrentAttachment = useStableEvent(() => attachment ?? null);
useLayoutEffect(() => {
let disposed = false;
let releaseCurrent: (() => void) | null = null;
const current = getCurrentAttachment();
if (!current || !previewKey) {
setEntry({ key: null, state: { status: "waiting" } });
return;
}
const acquireCurrent = () => {
releaseCurrent?.();
const retained = previewUrlCache.acquireRetained(previewKey, async () => ({
attachment: current,
uri: await resolveAttachmentPreviewUrl(current),
}));
releaseCurrent = retained.release;
return retained;
};
const initial = acquireCurrent();
if (initial.value) {
setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } });
return () => {
disposed = true;
releaseCurrent?.();
};
}
setEntry({ key: previewKey, state: { status: "loading" } });
void (async () => {
let firstAttempt: ReturnType<typeof acquireCurrent> | null = initial;
try {
const preview = await runAssistantImageOperationWithRetry({
operation: async () => {
const retained = firstAttempt ?? acquireCurrent();
firstAttempt = null;
return await retained.promise;
},
shouldStop: () => disposed,
});
if (!disposed) {
setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } });
}
} catch (error) {
if (!disposed) {
setEntry({ key: previewKey, state: { status: "failed", error } });
}
}
})();
return () => {
disposed = true;
releaseCurrent?.();
};
}, [getCurrentAttachment, previewKey]);
if (!previewKey) {
return { status: "waiting" };
}
return entry.key === previewKey ? entry.state : { status: "waiting" };
}
function lifecycleReducer(
state: AssistantImageLifecycle,
event: AssistantImageLifecycleEvent,
): AssistantImageLifecycle {
return transitionAssistantImageLifecycle(state, event);
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function getAcquisitionFailure(input: {
hasResolution: boolean;
isFileSource: boolean;
isDataImage: boolean;
fileAttachment: AttachmentAcquisitionState;
dataImageAttachment: AttachmentAcquisitionState;
preview: PreviewUrlState;
hasDirectUri: boolean;
fallbackMessage: string;
}): AssistantImageResult | null {
if (!input.hasResolution) {
return { status: "failed", message: input.fallbackMessage };
}
if (input.isFileSource && input.fileAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.fileAttachment.error, input.fallbackMessage),
};
}
if (input.isDataImage && input.dataImageAttachment.status === "failed") {
return {
status: "failed",
message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage),
};
}
if (!input.hasDirectUri && input.preview.status === "failed") {
return {
status: "failed",
message: errorMessage(input.preview.error, input.fallbackMessage),
};
}
return null;
}
export function useAssistantImage({
source,
occurrenceKey,
client,
workspaceRoot,
serverId,
}: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path) => await client.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null;
return createAssistantImageFileAcquisition({
port,
resolution,
serverId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, resolution, serverId, t]);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
);
const fileAttachment = useAttachmentAcquisition(fileAcquisition);
const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition);
const filePreview = usePreviewUrl(
fileAttachment.status === "loaded" ? fileAttachment.attachment : null,
);
const dataImagePreview = usePreviewUrl(
dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null,
);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const preview = dataImage ? dataImagePreview : filePreview;
const previewUri = preview.status === "loaded" ? preview.uri : null;
const uri = directUri ?? previewUri;
const cachedMetadata = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
[serverId, source, workspaceRoot],
);
const [lifecycle, dispatchLifecycle] = useReducer(
lifecycleReducer,
uri,
(initialUri): AssistantImageLifecycle => {
if (initialUri) {
const aspectRatio = getLoadedImageAspectRatio(initialUri);
if (aspectRatio !== null) {
return { status: "loaded", uri: initialUri, aspectRatio };
}
}
return createAssistantImageLifecycle();
},
);
const dispatch = useCallback((event: AssistantImageLifecycleEvent) => {
if (event.type === "image_loaded") {
rememberLoadedImage(event.uri, event.aspectRatio);
} else if (event.type === "failed" && event.uri) {
loadedImageCache.delete(event.uri);
}
dispatchLifecycle(event);
}, []);
const renderedImageRef = useRef<unknown>(null);
const handleImageRef = useCallback((instance: unknown) => {
renderedImageRef.current = instance;
}, []);
useEffect(() => {
if (!uri) {
dispatch({ type: "preview_released" });
return;
}
dispatch({
type: "preview_created",
uri,
aspectRatio: cachedMetadata?.aspectRatio ?? null,
});
}, [cachedMetadata, dispatch, uri]);
const handleImageError = useCallback(() => {
if (uri) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
}
}, [dispatch, t, uri]);
const handleImageLoad = useCallback(
(event: ImageLoadEvent) => {
if (!uri) {
return;
}
const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & {
target?: { naturalWidth?: unknown; naturalHeight?: unknown };
};
const dimensions = resolveAssistantImageLoadDimensions({
source: nativeEvent.source,
target: nativeEvent.target,
renderedImage: renderedImageRef.current,
renderedDimensions,
});
const metadata = dimensions
? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions)
: null;
const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null;
if (!aspectRatio) {
dispatch({
type: "failed",
uri,
message: t("message.attachments.imageUnavailable"),
});
return;
}
dispatch({ type: "image_loaded", uri, aspectRatio });
},
[cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot],
);
const acquisitionFailure = getAcquisitionFailure({
hasResolution: resolution !== null,
isFileSource: resolution?.kind === "file_rpc",
isDataImage: dataImage !== null,
fileAttachment,
dataImageAttachment,
preview,
hasDirectUri: directUri !== null,
fallbackMessage: t("message.attachments.imagePreviewLoadFailed"),
});
if (acquisitionFailure) {
return acquisitionFailure;
}
const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri;
let binding: AssistantImageRenderBinding | null = null;
if (hasCurrentLifecycleUri && lifecycle.uri) {
binding = {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
};
}
if (lifecycle.status === "loaded" && lifecycle.uri === uri) {
return {
status: "loaded",
binding: {
uri: lifecycle.uri,
onRef: handleImageRef,
onLoad: handleImageLoad,
onError: handleImageError,
},
aspectRatio: lifecycle.aspectRatio,
};
}
if (lifecycle.status === "failed") {
return lifecycle;
}
return {
status: "loading",
binding,
aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null,
};
}

View File

@@ -1,15 +0,0 @@
import { describe, expect, it } from "vitest";
import { collectRetainedAttachmentIds, retainAttachmentForGarbageCollection } from "./gc-retention";
describe("attachment garbage-collection retention", () => {
it("keeps an attachment referenced until its final owner releases it", () => {
const releaseFirst = retainAttachmentForGarbageCollection("preview-1");
const releaseSecond = retainAttachmentForGarbageCollection("preview-1");
expect(collectRetainedAttachmentIds()).toContain("preview-1");
releaseFirst();
expect(collectRetainedAttachmentIds()).toContain("preview-1");
releaseSecond();
expect(collectRetainedAttachmentIds()).not.toContain("preview-1");
});
});

View File

@@ -1,22 +0,0 @@
const retentionCounts = new Map<string, number>();
export function retainAttachmentForGarbageCollection(attachmentId: string): () => void {
retentionCounts.set(attachmentId, (retentionCounts.get(attachmentId) ?? 0) + 1);
let released = false;
return () => {
if (released) {
return;
}
released = true;
const nextCount = (retentionCounts.get(attachmentId) ?? 1) - 1;
if (nextCount <= 0) {
retentionCounts.delete(attachmentId);
return;
}
retentionCounts.set(attachmentId, nextCount);
};
}
export function collectRetainedAttachmentIds(): ReadonlySet<string> {
return new Set(retentionCounts.keys());
}

View File

@@ -1,11 +1,7 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
import { __setAttachmentStoreForTests } from "./store";
import {
encodeAttachmentsForSend,
garbageCollectAttachments,
persistAttachmentFromBytes,
} from "./service";
import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service";
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
return {
@@ -98,46 +94,4 @@ describe("attachment service", () => {
{ data: "att_send:base64", mimeType: "image/jpeg" },
]);
});
it("does not collect an attachment persisted while garbage collection is starting", async () => {
let releaseSave: () => void = () => undefined;
let reportSaveStarted: () => void = () => undefined;
const saveStarted = new Promise<void>((resolve) => {
reportSaveStarted = resolve;
});
const saveGate = new Promise<void>((resolve) => {
releaseSave = resolve;
});
const garbageCollections: string[][] = [];
const store: AttachmentStore = {
...createRecordingStore(),
async save(input) {
reportSaveStarted();
await saveGate;
return createAttachment({ id: input.id });
},
async garbageCollect({ referencedIds }) {
garbageCollections.push([...referencedIds]);
},
};
__setAttachmentStoreForTests(store);
const persist = persistAttachmentFromBytes({
id: "assistant-preview",
bytes: new Uint8Array([1, 2, 3]),
mimeType: "image/png",
});
await saveStarted;
const collect = garbageCollectAttachments({ referencedIds: new Set() });
try {
await Promise.resolve();
expect(garbageCollections).toEqual([]);
} finally {
releaseSave();
await Promise.all([persist, collect]);
}
expect(garbageCollections).toEqual([["assistant-preview"]]);
});
});

View File

@@ -1,40 +1,5 @@
import { collectRetainedAttachmentIds } from "@/attachments/gc-retention";
import type { AttachmentMetadata } from "@/attachments/types";
import { getAttachmentStore } from "@/attachments/store";
import type { AttachmentMetadata, SaveAttachmentInput } from "@/attachments/types";
const activePersistence = new Set<Promise<AttachmentMetadata>>();
const persistedDuringGarbageCollection = new Set<string>();
let pendingGarbageCollections = 0;
let garbageCollectionTail: Promise<void> = Promise.resolve();
let persistenceBarrier: Promise<void> | null = null;
let releasePersistenceBarrier: (() => void) | null = null;
async function waitForPersistenceBarrier(): Promise<void> {
const barrier = persistenceBarrier;
if (!barrier) {
return;
}
await barrier;
await waitForPersistenceBarrier();
}
async function persistAttachment(input: SaveAttachmentInput): Promise<AttachmentMetadata> {
await waitForPersistenceBarrier();
const pending = (async () => {
const store = await getAttachmentStore();
const attachment = await store.save(input);
if (pendingGarbageCollections > 0) {
persistedDuringGarbageCollection.add(attachment.id);
}
return attachment;
})();
activePersistence.add(pending);
try {
return await pending;
} finally {
activePersistence.delete(pending);
}
}
export async function persistAttachmentFromBlob(input: {
blob: Blob;
@@ -42,7 +7,8 @@ export async function persistAttachmentFromBlob(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
return await persistAttachment({
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -56,7 +22,8 @@ export async function persistAttachmentFromDataUrl(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
return await persistAttachment({
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -70,7 +37,8 @@ export async function persistAttachmentFromBytes(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
return await persistAttachment({
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -84,7 +52,8 @@ export async function persistAttachmentFromFileUri(input: {
fileName?: string | null;
id?: string;
}): Promise<AttachmentMetadata> {
return await persistAttachment({
const store = await getAttachmentStore();
return await store.save({
id: input.id,
mimeType: input.mimeType,
fileName: input.fileName,
@@ -164,41 +133,6 @@ export async function deleteAttachments(
export async function garbageCollectAttachments(input: {
referencedIds: ReadonlySet<string>;
}): Promise<void> {
pendingGarbageCollections += 1;
if (!persistenceBarrier) {
persistenceBarrier = new Promise<void>((resolve) => {
releasePersistenceBarrier = resolve;
});
}
const previousGarbageCollection = garbageCollectionTail;
const currentGarbageCollection = (async () => {
await previousGarbageCollection;
while (activePersistence.size > 0) {
await Promise.allSettled(activePersistence);
}
const referencedIds = new Set(input.referencedIds);
for (const id of collectRetainedAttachmentIds()) {
referencedIds.add(id);
}
for (const id of persistedDuringGarbageCollection) {
referencedIds.add(id);
}
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds });
})();
garbageCollectionTail = currentGarbageCollection.catch(() => undefined);
try {
await currentGarbageCollection;
} finally {
pendingGarbageCollections -= 1;
if (pendingGarbageCollections === 0) {
persistedDuringGarbageCollection.clear();
const release = releasePersistenceBarrier;
releasePersistenceBarrier = null;
persistenceBarrier = null;
release?.();
}
}
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds: input.referencedIds });
}

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createImageSourceCacheKey,
createPreviewAttachmentId,
fileUriToPath,
localFileSourceToPath,
parseDataUrl,
@@ -87,29 +86,4 @@ describe("parseImageDataUrl", () => {
it("ignores SVG data URLs", () => {
expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull();
});
it("distinguishes image data that differs only in the middle", () => {
const prefix = "a".repeat(64);
const suffix = "z".repeat(64);
const first = `data:image/png;base64,${prefix}${"b".repeat(256)}${suffix}`;
const second = `data:image/png;base64,${prefix}${"c".repeat(256)}${suffix}`;
expect(createImageSourceCacheKey(first)).not.toBe(createImageSourceCacheKey(second));
});
it("gives equal-length preview content distinct attachment identities", () => {
expect(
createPreviewAttachmentId({
mimeType: "image/png",
contentLength: 512,
contentKey: "first-content",
}),
).not.toBe(
createPreviewAttachmentId({
mimeType: "image/png",
contentLength: 512,
contentKey: "second-content",
}),
);
});
});

View File

@@ -56,7 +56,7 @@ export function parseImageDataUrl(
if (!isRasterImageMimeType(parsed.mimeType)) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64}`;
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
return {
...parsed,
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
@@ -87,15 +87,12 @@ export function createPreviewAttachmentId(input: {
size?: number | null;
modifiedAt?: string | null;
contentLength?: number | null;
contentKey?: string | null;
}): string {
const path = input.path?.trim() ?? "";
const size = Number.isFinite(input.size) ? String(input.size) : "";
const modifiedAt = input.modifiedAt?.trim() ?? "";
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
const contentKey = input.contentKey?.trim() ?? "";
const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`;
const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity);
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
return `preview_${size || contentLength || "unknown"}_${hash}`;
}

View File

@@ -24,28 +24,16 @@ import {
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import type {
AgentFileExplorerState,
ExplorerDirectory,
ExplorerEntry,
} from "@/stores/session-store";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
flattenExplorerTree,
reconcileRestoredExpandedPaths,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
type ExplorerTreeRow,
} from "@/file-explorer/tree";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
@@ -95,7 +83,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
}
function treeRowKeyExtractor(row: ExplorerTreeRow) {
function treeRowKeyExtractor(row: TreeRow) {
return row.entry.path;
}
@@ -211,6 +199,11 @@ interface FileExplorerPaneProps {
onAddToChat?: (path: string) => void;
}
interface TreeRow {
entry: ExplorerEntry;
depth: number;
}
export function FileExplorerPane({
serverId,
workspaceId,
@@ -270,7 +263,7 @@ export function FileExplorerPane({
[isExplorerLoading, pendingRequest],
);
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
const treeListRef = useRef<FlatList<TreeRow>>(null);
const hasInitializedRef = useRef(false);
@@ -283,19 +276,9 @@ export function FileExplorerPane({
hasWorkspaceScope,
hasInitializedRef,
workspaceStateKey,
persistedExpandedPaths: expandedPaths,
showHiddenFiles,
requestDirectoryListing,
setExpandedPathsForWorkspace,
});
}, [
expandedPaths,
hasWorkspaceScope,
requestDirectoryListing,
setExpandedPathsForWorkspace,
showHiddenFiles,
workspaceStateKey,
]);
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) =>
@@ -368,42 +351,11 @@ export function FileExplorerPane({
const handleToggleHiddenFiles = useCallback(() => {
const willShow = !usePanelStore.getState().explorerShowHiddenFiles;
if (!willShow) {
toggleExplorerShowHiddenFiles();
return;
toggleExplorerShowHiddenFiles();
if (willShow) {
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
}
const rootDirectory = directories.get(".");
if (!rootDirectory || !workspaceStateKey) {
toggleExplorerShowHiddenFiles();
return;
}
void showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: expandedPaths,
showHiddenFiles: toggleExplorerShowHiddenFiles,
requestDirectoryListing: (path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
}).then((restoredPaths) => {
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths: expandedPaths,
currentExpandedPaths: new Set(currentPaths),
restoredExpandedPaths: restoredPaths,
}),
);
return null;
});
}, [
directories,
expandedPaths,
requestDirectoryListing,
setExpandedPathsForWorkspace,
toggleExplorerShowHiddenFiles,
workspaceStateKey,
]);
}, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]);
const refreshExplorer = useCallback(
() =>
@@ -435,7 +387,7 @@ export function FileExplorerPane({
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
const treeRows = useMemo(
() => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }),
() => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }),
[directories, expandedPaths, showHiddenFiles, sortOption],
);
@@ -448,7 +400,7 @@ export function FileExplorerPane({
const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]);
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<ExplorerTreeRow>) => (
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
@@ -528,11 +480,11 @@ interface FileExplorerPaneContentProps {
error: string | null;
showInitialLoading: boolean;
showBackFromError: boolean;
treeRows: ExplorerTreeRow[];
treeRows: TreeRow[];
currentSortLabel: string;
isRefreshFetching: boolean;
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
treeListRef: RefObject<FlatList<TreeRow> | null>;
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => ReactElement;
handleSortCycle: () => void;
handleToggleHiddenFiles: () => void;
handleRefresh: () => void;
@@ -685,6 +637,71 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
);
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
default:
return 0;
}
});
return sorted;
}
function buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path,
depth,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
path: string;
depth: number;
}): TreeRow[] {
const directory = directories.get(path);
if (!directory) {
return [];
}
const rows: TreeRow[] = [];
const entries = sortEntries(
filterVisibleExplorerEntries(directory.entries, showHiddenFiles),
sortOption,
);
for (const entry of entries) {
rows.push({ entry, depth });
if (entry.kind === "directory" && expandedPaths.has(entry.path)) {
rows.push(
...buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: entry.path,
depth: depth + 1,
}),
);
}
}
return rows;
}
function deriveExplorerFields(state: AgentFileExplorerState | undefined) {
return {
directories:
@@ -734,6 +751,30 @@ function resolveCurrentSortLabel(
return labels[sortOption] ?? labels.name;
}
function resolveTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}): TreeRow[] {
if (!directories.get(".")) {
return [];
}
return buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path: ".",
depth: 0,
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -745,25 +786,26 @@ function toggleDirectory({
entry: ExplorerEntry;
workspaceStateKey: string | null;
expandedPaths: Set<string>;
directories: Map<string, ExplorerDirectory>;
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
) => Promise<boolean>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void;
}): void {
if (!workspaceStateKey) {
return;
}
const isExpanded = expandedPaths.has(entry.path);
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
setExpandedDirectoryPath({
currentExpandedPaths: currentPaths,
directoryPath: entry.path,
expanded: !isExpanded,
}),
);
if (!isExpanded && !directories.has(entry.path)) {
if (isExpanded) {
setExpandedPathsForWorkspace(
workspaceStateKey,
Array.from(expandedPaths).filter((path) => path !== entry.path),
);
return;
}
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
if (!directories.has(entry.path)) {
void requestDirectoryListing(entry.path, {
recordHistory: false,
setCurrentPath: false,
@@ -785,7 +827,7 @@ function TreeRowDispatcher({
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<ExplorerTreeRow>;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
isDirectoryLoading: (path: string) => boolean;
@@ -823,59 +865,54 @@ async function initializeExplorer({
hasWorkspaceScope,
hasInitializedRef,
workspaceStateKey,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
setExpandedPathsForWorkspace,
}: {
hasWorkspaceScope: boolean;
hasInitializedRef: RefObject<boolean>;
workspaceStateKey: string | null;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
) => Promise<boolean>;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
return;
}
hasInitializedRef.current = true;
const rootDirectory = await requestDirectoryListing(".", {
const succeeded = await requestDirectoryListing(".", {
recordHistory: false,
setCurrentPath: false,
});
if (!rootDirectory) {
if (!succeeded) {
hasInitializedRef.current = false;
return;
}
if (!workspaceStateKey) {
requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing });
}
function requestPersistedExpandedPaths({
workspaceStateKey,
requestDirectoryListing,
}: {
workspaceStateKey: string | null;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
}): void {
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
if (!persistedPaths) {
return;
}
const restoredPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing: (path) =>
requestDirectoryListing(path, {
for (const path of persistedPaths) {
if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) {
void requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
});
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths: new Set(currentPaths),
restoredExpandedPaths: restoredPathsWithHidden,
}),
);
});
}
}
}
async function refreshExplorerDirectories({
@@ -888,7 +925,7 @@ async function refreshExplorerDirectories({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
) => Promise<boolean>;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;

View File

@@ -29,6 +29,7 @@ import {
} from "react";
import type { ComponentType, ReactNode } from "react";
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
import { useQuery } from "@tanstack/react-query";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Circle,
@@ -74,7 +75,19 @@ import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
setAssistantImageMetadata,
type AssistantImageLoadState,
} from "@/utils/assistant-image-metadata";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
@@ -89,7 +102,8 @@ import {
useAssistantLinkPress,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import {
AttachmentFrame,
AttachmentLabel,
@@ -723,7 +737,6 @@ export const LiveElapsed = memo(function LiveElapsed({
});
interface AssistantMessageProps {
occurrenceKey: string;
message: string;
timestamp: number;
workspaceRoot?: string;
@@ -751,21 +764,11 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
imageSurface: {
width: "100%",
overflow: "hidden",
position: "relative",
},
image: {
width: "100%",
height: "100%",
},
imageLoadingOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
},
imageState: {
alignItems: "center",
justifyContent: "center",
@@ -782,9 +785,125 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
uri,
alt,
containerStyle,
source,
workspaceRoot,
serverId,
}: {
uri: string;
alt?: string;
containerStyle?: StyleProp<ViewStyle>;
source: string;
workspaceRoot?: string;
serverId?: string;
}) {
const cachedMetadata = useMemo(
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
[serverId, source, workspaceRoot],
);
const [loadState, setLoadState] = useState<AssistantImageLoadState>(() =>
getAssistantImageLoadStateFromMetadata(cachedMetadata),
);
useEffect(() => {
if (cachedMetadata) {
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
return () => {};
}
setLoadState({ status: "loading" });
let cancelled = false;
Image.getSize(
uri,
(width, height) => {
if (cancelled) {
return;
}
if (width > 0 && height > 0) {
const metadata = setAssistantImageMetadata(
{ source, workspaceRoot, serverId },
{ width, height },
);
setLoadState({
status: "ready",
aspectRatio: metadata?.aspectRatio ?? width / height,
});
}
},
() => {
if (cancelled) {
return;
}
setLoadState({ status: "error" });
},
);
return () => {
cancelled = true;
};
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
const handleImageError = useCallback(() => {
setLoadState({ status: "error" });
}, []);
const { t } = useTranslation();
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [
assistantMessageStylesheet.imageSurface,
loadState.status === "ready"
? { aspectRatio: loadState.aspectRatio }
: { height: ASSISTANT_IMAGE_MIN_HEIGHT },
],
[loadState],
);
const frameStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageFrame, containerStyle],
[containerStyle],
);
const stateSurfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [surfaceStyle, assistantMessageStylesheet.imageState],
[surfaceStyle],
);
const imageSource = useMemo(() => ({ uri }), [uri]);
if (loadState.status !== "ready") {
return (
<View style={frameStyle}>
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
</Text>
) : null}
</View>
</View>
);
}
return (
<View style={frameStyle}>
<View style={surfaceStyle}>
<Image
source={imageSource}
style={assistantMessageStylesheet.image}
resizeMode="contain"
accessibilityLabel={alt}
onError={handleImageError}
/>
</View>
</View>
);
});
function AssistantMarkdownImage({
source,
occurrenceKey,
alt,
hasLeadingContent,
client,
@@ -792,13 +911,18 @@ function AssistantMarkdownImage({
serverId,
}: {
source: string;
occurrenceKey: string;
alt?: string;
hasLeadingContent: boolean;
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
}) {
const { t } = useTranslation();
const resolution = useMemo(
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? 16 : 0,
@@ -806,31 +930,64 @@ function AssistantMarkdownImage({
}),
[hasLeadingContent],
);
const image = useAssistantImage({
source,
occurrenceKey,
client,
workspaceRoot,
serverId,
const query = useQuery({
queryKey: [
"assistantMarkdownImage",
serverId ?? "unknown-server",
resolution?.kind === "file_rpc" ? resolution.cwd : null,
resolution?.kind === "file_rpc" ? resolution.path : null,
],
enabled: Boolean(client && resolution?.kind === "file_rpc"),
staleTime: 30_000,
queryFn: async () => {
if (!client || !resolution || resolution.kind !== "file_rpc") {
return null;
}
const file = await client.readFile(resolution.cwd, resolution.path);
if (file.kind !== "image") {
throw new Error(t("message.attachments.imagePreviewUnavailable"));
}
return await persistAttachmentFromBytes({
id: createPreviewAttachmentId({
mimeType: file.mime,
path: file.path || resolution.path,
size: file.size,
modifiedAt: file.modifiedAt,
contentLength: file.bytes.byteLength,
}),
bytes: file.bytes,
mimeType: file.mime,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
});
const binding = image.status === "failed" ? null : image.binding;
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
const imageUri = binding?.uri ?? "";
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
const frameStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageFrame, containerStyle],
[containerStyle],
);
const imageSizeStyle = useMemo<ViewStyle>(() => {
if (aspectRatio) {
return { aspectRatio };
}
return { height: ASSISTANT_IMAGE_MIN_HEIGHT };
}, [aspectRatio]);
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
() => [assistantMessageStylesheet.imageSurface, imageSizeStyle],
[imageSizeStyle],
);
const dataImageQuery = useQuery({
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
enabled: dataImage !== null,
staleTime: 30_000,
queryFn: async () => {
if (!dataImage) {
return null;
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId({
mimeType: dataImage.mimeType,
contentLength: dataImage.base64.length,
}),
dataUrl: source,
mimeType: dataImage.mimeType,
});
},
});
const fileAssetUri = useAttachmentPreviewUrl(query.data);
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
const stateFrameStyle = useMemo<StyleProp<ViewStyle>>(
() => [
@@ -842,15 +999,20 @@ function AssistantMarkdownImage({
[containerStyle],
);
if (image.status === "failed") {
if (resolvedUri) {
return (
<View style={stateFrameStyle}>
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
</View>
<AssistantMarkdownResolvedImage
uri={resolvedUri}
alt={alt}
containerStyle={containerStyle}
source={source}
workspaceRoot={workspaceRoot}
serverId={serverId}
/>
);
}
if (!binding) {
if (query.isLoading || dataImageQuery.isLoading) {
return (
<View style={stateFrameStyle}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
@@ -858,27 +1020,29 @@ function AssistantMarkdownImage({
);
}
const errorText = resolveAssistantImageErrorText(
query.error,
dataImageQuery.error,
t("message.attachments.imagePreviewLoadFailed"),
);
return (
<View style={frameStyle}>
<View style={surfaceStyle} accessibilityRole="image" accessibilityLabel={alt}>
<Image
ref={binding.onRef}
source={imageSource}
style={assistantMessageStylesheet.image}
resizeMode="contain"
onLoad={binding.onLoad}
onError={binding.onError}
/>
{image.status === "loading" ? (
<View pointerEvents="none" style={assistantMessageStylesheet.imageLoadingOverlay}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
<View style={stateFrameStyle}>
<Text style={assistantMessageStylesheet.imageErrorText}>{errorText}</Text>
</View>
);
}
function resolveAssistantImageErrorText(
fileError: unknown,
dataError: unknown,
fallbackText: string,
): string {
if (fileError instanceof Error) return fileError.message;
if (dataError instanceof Error) return dataError.message;
return fallbackText;
}
function getInlineCodeAutoLinkUrl(
markdownParser: ReturnType<typeof MarkdownIt>,
content: string,
@@ -1419,7 +1583,6 @@ function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProp
}
export const AssistantMessage = memo(function AssistantMessage({
occurrenceKey,
message,
timestamp: _timestamp,
workspaceRoot,
@@ -1754,7 +1917,6 @@ export const AssistantMessage = memo(function AssistantMessage({
<AssistantMarkdownImage
key={node.key}
source={String(node.attributes?.src ?? "")}
occurrenceKey={`${occurrenceKey}:${node.key}`}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
hasLeadingContent={hasLeadingContent}
client={client}
@@ -1764,7 +1926,7 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
}, [client, fileLinkActions, markdownParser, occurrenceKey, serverId, workspaceRoot]);
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(

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

@@ -1,4 +1,3 @@
import React from "react";
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps {

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

@@ -40,12 +40,7 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
selectAgentTimelineState,
useSessionStore,
type MessageEntry,
type SessionState,
} from "@/stores/session-store";
import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
@@ -197,6 +192,11 @@ type WorkspaceSetupProgressPayload = Extract<
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
type SetAgentStreamState = SessionStoreActions["setAgentStreamState"];
type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"];
type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"];
type SetAgentAuthoritativeHistoryApplied =
SessionStoreActions["setAgentAuthoritativeHistoryApplied"];
function clearAgentInitializingFlag(
setInitializingAgents: SetInitializingAgents,
@@ -229,6 +229,65 @@ function handleTimelineError(input: {
}
}
function applyTimelineStreamPatches(input: {
result: ProcessTimelineResponseOutput;
agentId: string;
serverId: string;
currentTail: StreamItem[];
currentHead: StreamItem[];
setAgentStreamState: SetAgentStreamState;
setAgentTimelineCursor: SetAgentTimelineCursor;
}): void {
const {
result,
agentId,
serverId,
currentTail,
currentHead,
setAgentStreamState,
setAgentTimelineCursor,
} = input;
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.cursorChanged) {
setAgentTimelineCursor(serverId, (prev) => {
const current = prev.get(agentId);
if (!result.cursor) {
if (!current) {
return prev;
}
const next = new Map(prev);
next.delete(agentId);
return next;
}
if (
current &&
current.epoch === result.cursor.epoch &&
current.startSeq === result.cursor.startSeq &&
current.endSeq === result.cursor.endSeq
) {
return prev;
}
const next = new Map(prev);
next.set(agentId, result.cursor);
return next;
});
}
}
function executeTimelineSideEffects(input: {
sideEffects: TimelineReducerSideEffect[];
agentId: string;
@@ -249,6 +308,8 @@ function finalizeTimelineApplication(input: {
serverId: string;
shouldMarkAuthoritativeHistoryApplied: boolean;
setInitializingAgents: SetInitializingAgents;
setAgentAuthoritativeHistoryApplied: SetAgentAuthoritativeHistoryApplied;
markAgentHistorySynchronized: MarkAgentHistorySynchronized;
}): void {
const {
result,
@@ -257,13 +318,17 @@ function finalizeTimelineApplication(input: {
serverId,
shouldMarkAuthoritativeHistoryApplied,
setInitializingAgents,
setAgentAuthoritativeHistoryApplied,
markAgentHistorySynchronized,
} = input;
if (result.clearInitializing) {
clearAgentInitializingFlag(setInitializingAgents, serverId, agentId);
}
if (shouldMarkAuthoritativeHistoryApplied) {
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
useCreateFlowStore.getState().clearByAgent({ serverId, agentId });
markAgentHistorySynchronized(serverId, agentId);
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
if (agent && agent.status !== "running") {
@@ -349,10 +414,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
const applyAgentTimelineResponseState = useSessionStore(
(state) => state.applyAgentTimelineResponseState,
const markAgentHistorySynchronized = useSessionStore(
(state) => state.markAgentHistorySynchronized,
);
const setAgentAuthoritativeHistoryApplied = useSessionStore(
(state) => state.setAgentAuthoritativeHistoryApplied,
);
const setAgents = useSessionStore((state) => state.setAgents);
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
@@ -575,15 +644,22 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const isInitializing = session?.initializingAgents.get(agentId) === true;
const activeInitDeferred = getInitDeferred(initKey);
const hasActiveInitDeferred = Boolean(activeInitDeferred);
const timeline = selectAgentTimelineState(session, agentId);
const currentCursor =
timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const currentTail = timeline.status === "cold" ? [] : timeline.items;
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) {
return prev;
}
const next = new Map(prev);
next.set(agentId, payload.hasOlder);
return next;
});
// Call pure reducer
const result = processTimelineResponse({
payload,
@@ -594,7 +670,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
hasActiveInitDeferred,
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds,
hasAuthoritativeBaseline: timeline.status === "synced",
});
if (result.error) {
@@ -608,13 +683,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return;
}
applyAgentTimelineResponseState(serverId, agentId, {
items: result.tail,
head: result.head,
range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null),
older: payload.hasOlder ? "available" : "none",
synchronized: shouldMarkAuthoritativeHistoryApplied,
acknowledgedClientMessageIds: result.acknowledgedClientMessageIds,
applyTimelineStreamPatches({
result,
agentId,
serverId,
currentTail,
currentHead,
setAgentStreamState,
setAgentTimelineCursor,
});
executeTimelineSideEffects({
@@ -630,9 +706,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
shouldMarkAuthoritativeHistoryApplied,
setInitializingAgents,
setAgentAuthoritativeHistoryApplied,
markAgentHistorySynchronized,
});
},
[applyAgentTimelineResponseState, recoverTimelineGap, serverId, setInitializingAgents],
[
markAgentHistorySynchronized,
recoverTimelineGap,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamState,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
],
);
useEffect(() => {
@@ -643,20 +730,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const sync = createViewedTimelineSync({
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
},
readCursor: (agentId) =>
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
hasAuthoritativeHistory: (agentId) =>
selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId).status ===
"synced",
useSessionStore
.getState()
.sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true,
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true;
if (shouldInitialize) {
if (!getInitDeferred(initKey)) {
const deferred = createInitDeferred(initKey, request.direction ?? "tail");

View File

@@ -1,197 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
} from "./tree";
function makeDirectoryEntry(name: string, path: string): ExplorerEntry {
return {
name,
path,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("file explorer tree", () => {
it("flattens a deeply expanded tree without consuming the call stack", () => {
const depth = 10_000;
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const expandedPaths = new Set<string>(["."]);
let parentPath = ".";
for (let index = 1; index <= depth; index += 1) {
const childPath = `directory-${index}`;
directories.set(parentPath, {
path: parentPath,
entries: [makeDirectoryEntry(childPath, childPath)],
});
expandedPaths.add(childPath);
parentPath = childPath;
}
directories.set(parentPath, { path: parentPath, entries: [] });
const rows = flattenExplorerTree({
directories,
expandedPaths,
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(depth);
expect(rows[0]).toEqual({
entry: makeDirectoryEntry("directory-1", "directory-1"),
depth: 0,
});
expect(rows.at(-1)).toEqual({
entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`),
depth: depth - 1,
});
});
it("flattens a large expanded directory without spreading its rows into the parent", () => {
const fileCount = 150_000;
const files = Array.from(
{ length: fileCount },
(_, index): ExplorerEntry => ({
name: `file-${index.toString().padStart(6, "0")}`,
path: `generated/file-${index}`,
kind: "file",
size: index,
modifiedAt: "2026-01-01T00:00:00.000Z",
}),
);
const child = makeDirectoryEntry("generated", "generated");
const directories = new Map([
[".", { path: ".", entries: [child] }],
["generated", { path: "generated", entries: files }],
]);
const rows = flattenExplorerTree({
directories,
expandedPaths: new Set([".", "generated"]),
sortOption: "name",
showHiddenFiles: true,
});
expect(rows).toHaveLength(fileCount + 1);
expect(rows[0]).toEqual({ entry: child, depth: 0 });
expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 });
});
it("restores five rendered directory levels rather than counting path segments", async () => {
const paths = [
"generated/cache/level-1",
"generated/cache/level-1/level-2",
"generated/cache/level-1/level-2/level-3",
"generated/cache/level-1/level-2/level-3/level-4",
"generated/cache/level-1/level-2/level-3/level-4/level-5",
"generated/cache/level-1/level-2/level-3/level-4/level-5/level-6",
];
const directories = new Map<string, { path: string; entries: ExplorerEntry[] }>();
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("level-1", paths[0])],
};
directories.set(".", rootDirectory);
for (let index = 0; index < paths.length - 1; index += 1) {
directories.set(paths[index], {
path: paths[index],
entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])],
});
}
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(paths),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return directories.get(path) ?? null;
},
});
expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5);
expect(requestedPaths).toEqual(paths.slice(0, 5));
expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]);
});
it("does not restore persisted descendants beneath a collapsed rendered directory", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry("parent", "parent")],
};
const requestedPaths: string[] = [];
const expandedPaths = await restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]),
showHiddenFiles: true,
requestDirectoryListing: async (path) => {
requestedPaths.push(path);
return null;
},
});
expect(requestedPaths).toEqual([]);
expect(expandedPaths).toEqual(["."]);
});
it("preserves expansion changes made while persisted directories are restoring", () => {
const paths = reconcileRestoredExpandedPaths({
persistedExpandedPaths: new Set([".", "parent", "parent/child"]),
currentExpandedPaths: new Set([".", "parent/child", "manual"]),
restoredExpandedPaths: [".", "parent"],
});
expect(paths).toEqual([".", "manual"]);
});
it("applies a directory click to the latest restored expansion paths", () => {
const expanded = setExpandedDirectoryPath({
currentExpandedPaths: [".", "restored"],
directoryPath: "manual",
expanded: true,
});
const collapsed = setExpandedDirectoryPath({
currentExpandedPaths: expanded,
directoryPath: "manual",
expanded: false,
});
expect(expanded).toEqual([".", "restored", "manual"]);
expect(collapsed).toEqual([".", "restored"]);
});
it("shows hidden files before waiting for expanded directories to restore", async () => {
const rootDirectory = {
path: ".",
entries: [makeDirectoryEntry(".hidden", ".hidden")],
};
let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void;
const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => {
resolveDirectory = resolve;
});
let hiddenFilesAreShown = false;
const restoration = showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths: new Set([".hidden"]),
showHiddenFiles: () => {
hiddenFilesAreShown = true;
},
requestDirectoryListing: () => directoryListing,
});
expect(hiddenFilesAreShown).toBe(true);
resolveDirectory({ path: ".hidden", entries: [] });
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});

View File

@@ -1,203 +0,0 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { filterVisibleExplorerEntries } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
export interface ExplorerTreeRow {
entry: ExplorerEntry;
depth: number;
}
interface FlattenExplorerTreeInput {
directories: ReadonlyMap<string, ExplorerDirectory>;
expandedPaths: ReadonlySet<string>;
sortOption: SortOption;
showHiddenFiles: boolean;
}
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
RestoreExpandedDirectoriesInput,
"showHiddenFiles"
> {
showHiddenFiles: () => void;
}
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: string[];
}
interface SetExpandedDirectoryPathInput {
currentExpandedPaths: readonly string[];
directoryPath: string;
expanded: boolean;
}
export function flattenExplorerTree({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
}: FlattenExplorerTreeInput): ExplorerTreeRow[] {
const root = directories.get(".");
if (!root) {
return [];
}
const rows: ExplorerTreeRow[] = [];
const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed();
while (pending.length > 0) {
const row = pending.pop();
if (!row) {
break;
}
rows.push(row);
const entry = row.entry;
if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) {
continue;
}
const childDirectory = directories.get(entry.path);
if (!childDirectory) {
continue;
}
const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles);
for (let index = childRows.length - 1; index >= 0; index -= 1) {
pending.push(childRows[index]);
}
}
return rows;
}
export async function restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) {
const pathsToRequest: string[] = [];
for (const directory of parentDirectories) {
const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
for (const entry of entries) {
const isPersistedExpandedDirectory =
entry.kind === "directory" && persistedExpandedPaths.has(entry.path);
if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) {
pathsToRequest.push(entry.path);
restoredPathSet.add(entry.path);
}
}
}
if (pathsToRequest.length === 0) {
break;
}
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory) {
continue;
}
restoredPaths.push(directory.path);
parentDirectories.push(directory);
}
}
return restoredPaths;
}
export function showHiddenFilesAndRestoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
persistedExpandedPaths,
showHiddenFiles: true,
requestDirectoryListing,
});
}
export function reconcileRestoredExpandedPaths({
persistedExpandedPaths,
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
if (!currentExpandedPaths.has(path)) {
reconciledPaths.delete(path);
}
}
for (const path of currentExpandedPaths) {
if (!persistedExpandedPaths.has(path)) {
reconciledPaths.add(path);
}
}
return Array.from(reconciledPaths);
}
export function setExpandedDirectoryPath({
currentExpandedPaths,
directoryPath,
expanded,
}: SetExpandedDirectoryPathInput): string[] {
const nextPaths = new Set(currentExpandedPaths);
if (expanded) {
nextPaths.add(directoryPath);
} else {
nextPaths.delete(directoryPath);
}
return Array.from(nextPaths);
}
function rowsForDirectory(
directory: ExplorerDirectory,
depth: number,
sortOption: SortOption,
showHiddenFiles: boolean,
): ExplorerTreeRow[] {
const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles);
const sortedEntries = sortExplorerEntries(visibleEntries, sortOption);
return sortedEntries.map((entry) => ({ entry, depth }));
}
function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === "directory" ? -1 : 1;
}
switch (sortOption) {
case "name":
return a.name.localeCompare(b.name);
case "modified":
return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
case "size":
return b.size - a.size;
}
});
return sorted;
}

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

@@ -111,47 +111,6 @@ describe("ensureAgentIsInitialized", () => {
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("requests a bounded projected tail after restoring painted replica items", () => {
const client = new FakeDaemonClient();
const runtime = new FakeTimelineRuntime();
useSessionStore.getState().restoreSessionReplica(serverId, {
agents: new Map(),
workspaces: new Map(),
emptyProjects: new Map(),
timeline: {
agentId,
items: [
{
kind: "assistant_message",
id: "painted-item",
text: "Painted before hydration",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
},
],
},
});
void ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: bindSetAgentInitializing(),
});
expect(runtime.requests).toEqual([
{
serverId,
agentId,
request: {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
},
},
]);
});
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = new FakeDaemonClient();

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
@@ -52,9 +52,8 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
}
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const hasAuthoritativeHistory = timeline.status === "synced";
const cursor = session?.agentTimelineCursor.get(agentId);
const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
const deferred = createInitDeferred(key, timelineRequest.direction);

View File

@@ -1,10 +1,6 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
function createExplorerState(): AgentFileExplorerState {
@@ -92,9 +88,9 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<ExplorerDirectory | null> => {
): Promise<boolean> => {
if (!workspaceStateKey) {
return null;
return false;
}
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
@@ -123,7 +119,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return null;
return false;
}
if (!client) {
@@ -133,7 +129,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return null;
return false;
}
try {
@@ -154,7 +150,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return nextState;
});
return directory;
return true;
} catch (error) {
updateExplorerState((state) => ({
...state,
@@ -165,7 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return null;
return false;
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],

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

@@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => {
const client = createClient();
const inFlight = createInFlight();
const started = await loadOlderAgentHistory(agentId, {
await loadOlderAgentHistory(agentId, {
client,
cursor: undefined,
hasOlder: true,
@@ -94,14 +94,13 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([false]);
expect(started).toBe(false);
});
it("no-ops when the daemon says no older history exists", async () => {
const client = createClient();
const inFlight = createInFlight();
const started = await loadOlderAgentHistory(agentId, {
await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: false,
@@ -111,14 +110,13 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([false]);
expect(started).toBe(false);
});
it("no-ops when a request is already in flight", async () => {
const client = createClient();
const inFlight = createInFlight(true);
const started = await loadOlderAgentHistory(agentId, {
await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -128,14 +126,13 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]);
expect(started).toBe(true);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
const client = createClient();
const inFlight = createInFlight();
const started = await loadOlderAgentHistory(agentId, {
await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -155,7 +152,6 @@ describe("loadOlderAgentHistory", () => {
},
]);
expect(inFlight.values).toEqual([false, true, false]);
expect(started).toBe(true);
});
it("shows a panel toast, warns, and clears in-flight on failure", async () => {

View File

@@ -2,11 +2,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import {
selectAgentTimelineState,
useSessionStore,
type AgentTimelineCursorState,
} from "@/stores/session-store";
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -40,14 +36,11 @@ export interface LoadOlderAgentHistoryDeps {
export async function loadOlderAgentHistory(
agentId: string,
deps: LoadOlderAgentHistoryDeps,
): Promise<boolean> {
): Promise<void> {
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (isLoadingOlder) {
return true;
}
if (!client || !cursor || !hasOlder) {
return false;
if (!client || !cursor || !hasOlder || isLoadingOlder) {
return;
}
setInFlight(true);
@@ -65,7 +58,6 @@ export async function loadOlderAgentHistory(
} finally {
setInFlight(false);
}
return true;
}
export function useLoadOlderAgentHistory({
@@ -78,17 +70,15 @@ export function useLoadOlderAgentHistory({
toast?: ToastApi | null;
}) {
const { t } = useTranslation();
const hasOlder = useSessionStore((state) => {
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
return timeline.status === "synced" && timeline.older === "available";
});
const hasOlder =
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
true;
const isLoadingOlder =
useSessionStore((state) =>
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
) === true;
const progressKey = useSessionStore((state) => {
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
const cursor = timeline.status === "synced" ? timeline.range : null;
const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId);
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
});
const setOlderFetchInFlight = useSessionStore(
@@ -109,18 +99,17 @@ export function useLoadOlderAgentHistory({
[agentId, serverId, setOlderFetchInFlight],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
const loadOlder = useCallback(() => {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
return await loadOlderAgentHistory(agentId, {
void loadOlderAgentHistory(agentId, {
client: session?.client
? {
fetchAgentTimeline: (timelineAgentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
}
: null,
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
hasOlder: timeline.status === "synced" && timeline.older === "available",
cursor: session?.agentTimelineCursor.get(agentId),
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
setInFlight,
toast,

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

@@ -72,7 +72,7 @@ import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { selectAgentTimelineState, type Agent, useSessionStore } from "@/stores/session-store";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
@@ -792,12 +792,11 @@ function ChatAgentContent({
const historySyncGeneration = useSessionStore(
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0,
);
const replicaTimelineStatus = useSessionStore((state) =>
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
agentId
? selectAgentTimelineState(state.sessions[serverId], agentId).status
: ("cold" as const),
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true
: false,
);
const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced";
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
);
@@ -833,8 +832,7 @@ function ChatAgentContent({
kind: "idle",
});
const hasHydratedHistoryBefore =
hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted";
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
const attentionController = useAgentAttentionClear({
agentId,

View File

@@ -104,12 +104,10 @@ function ProviderSubagentPanel() {
.catch(() => undefined);
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback((): boolean => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false;
}
const loadOlder = useCallback(() => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return;
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
if (firstSeq === null) return false;
if (firstSeq === null) return;
setIsLoadingOlder(true);
void client
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
@@ -123,7 +121,6 @@ function ProviderSubagentPanel() {
})
.catch(() => undefined)
.finally(() => setIsLoadingOlder(false));
return true;
}, [
client,
isLoadingOlder,

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

@@ -4,7 +4,6 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
selectAgentTimelineState,
useSessionStore,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
@@ -167,14 +166,13 @@ describe("ReplicaCache", () => {
expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
expect(session?.agentAuthoritativeHistoryApplied).toEqual(new Map());
expect(session?.agentTimelineCursor).toEqual(new Map());
expect(session?.agentTimelineHasOlder).toEqual(new Map());
expect(session?.agentHistorySyncGeneration).toEqual(new Map());
expect(selectAgentTimelineState(session, "agent-1")).toEqual({
status: "painted",
items: [message("message-1", "Cached")],
expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true);
expect(session?.agentTimelineCursor.get("agent-1")).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 12,
});
expect(session?.agentTimelineHasOlder.get("agent-1")).toBe(true);
});
it("persists only the focused agent view with a short timeline tail", async () => {
@@ -218,13 +216,6 @@ describe("ReplicaCache", () => {
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]);
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as {
version: number;
hosts: Array<{ timeline: Record<string, unknown> | null }>;
};
expect(persisted.version).toBe(2);
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]);
});
it("evicts the least recently written host when the cache exceeds its byte budget", async () => {
@@ -252,38 +243,14 @@ describe("ReplicaCache", () => {
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
});
it("rejects version 1 cache data and overwrites it on flush", async () => {
it("drops malformed or unknown cache versions", async () => {
const storage = new MemoryStorage();
storage.values.set(
"@paseo:replica-cache",
JSON.stringify({
version: 1,
hosts: [
{
serverId: SERVER_ID,
agents: [],
workspaces: [],
emptyProjects: [],
timeline: {
agentId: "agent-1",
items: [],
cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 },
hasOlder: false,
},
},
],
}),
);
storage.values.set("@paseo:replica-cache", JSON.stringify({ version: 999, hosts: [] }));
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
await cache.restore();
await cache.flush();
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
version: 2,
hosts: [],
});
});
});

View File

@@ -11,7 +11,6 @@ import {
import {
normalizeEmptyProjectDescriptor,
normalizeWorkspaceDescriptor,
selectAgentTimelineState,
useSessionStore,
type Agent,
type SessionReplica,
@@ -38,6 +37,14 @@ const StoredAgentSchema = z.object({
const StoredTimelineSchema = z.object({
agentId: z.string(),
items: z.unknown(),
cursor: z
.object({
epoch: z.string(),
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
})
.nullable(),
hasOlder: z.boolean(),
});
const StoredHostSchema = z.object({
@@ -134,6 +141,8 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti
return {
agentId: stored.agentId,
items: decoded,
cursor: stored.cursor,
hasOlder: stored.hasOlder,
};
}
@@ -184,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,
@@ -367,24 +377,24 @@ export class ReplicaCache {
focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined,
),
);
const timelineState = focusedAgentId
? selectAgentTimelineState(session, focusedAgentId)
: { status: "cold" as const };
const items =
timelineState.status === "cold"
? undefined
: timelineState.items.filter(
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
? {
agentId: focusedAgent.id,
items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)),
cursor: session.agentTimelineCursor.get(focusedAgent.id) ?? null,
hasOlder: session.agentTimelineHasOlder.get(focusedAgent.id) ?? false,
}
: null;
const stored: StoredHost = {

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