Merge remote-tracking branch 'upstream/main' into fix/import-mode

# Conflicts:
#	packages/server/src/server/agent/agent-manager.ts
This commit is contained in:
Keith Kowal (paseo-dev)
2026-09-14 17:27:15 -07:00
250 changed files with 12357 additions and 3462 deletions
+4 -1
View File
@@ -251,7 +251,10 @@ jobs:
- name: Build Linux desktop artifacts
if: ${{ runner.os == 'Linux' }}
run: npm run build:desktop -- --publish never --linux --x64
# Smoke package contents and installation without spending minutes on xz compression.
run: |
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
env:
EP_GH_IGNORE_TIME: true
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
+1
View File
@@ -125,6 +125,7 @@ and updating `next`, integrating it after a release, and releasing a hotfix from
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
- Never re-run a test suite that another agent already ran and reported green — trust the result.
- For full suite verification, push to CI and check GitHub Actions instead.
- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones.
- **Always run typecheck and lint after every change.**
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
- `npm run build:client` — rebuild protocol and client declarations.
+22 -10
View File
@@ -109,16 +109,28 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
History navigation preserves the selected agent as an explicit recovery target. If both that agent
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
the selected agent as one user action. Other archived agents in the restored workspace remain
recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
resume repairs that drift through the provider's native unarchive hook; history resume does not.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
Once the daemon reports the agent active, its tab follows normal archive propagation again.
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
not own selection or release the explicit open.
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
releases the process before returning. It never loads, resumes, or unarchives a native thread,
including legacy records whose native archive failed. The retained history session contains only
the read results. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
+5 -1
View File
@@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally)
→ recordTimeline: one canonical row per flushed item
→ agent_stream ws message
→ reducer queue (app, one commit per frame) → session store
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
→ source-item plugin transforms → native Markdown blocks / tool grouping
→ paced reveal (app, per displayed item) → paint
```
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
@@ -28,6 +29,9 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native
blocks and parses only the growing last block during append. Splitting in the reducer discards
paragraph separators and exposes fragments to plugin callbacks, which need the whole source text.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
+30
View File
@@ -319,6 +319,36 @@ Example: adding a new enum value
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
agent receipts are imported at this boundary; message delivery receipts remain separate.
It keeps cumulative milestones and permanent IDs under an operation kind and idempotency
key. Reconnect subscribes to that key; retries
join active work or reuse committed stages. A persisted resource alone cannot prove
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
sequence after the client process disappears. Explicitly requested IDs or receipts
that an old host cannot honor produce an unsupported error. Keep these adapters inside
the client package, as an exception to the default no-fallback feature policy.
Creation executes through the existing Session capabilities. Connection-owned delivery
controls observation only: detaching a socket or cleaning up its Session does not cancel
accepted creation. Updates require an explicit subscription and go only to that socket;
reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their
existing response contract.
## Agent lifecycle
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
+1 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
+1 -1
View File
@@ -45,7 +45,7 @@ can be reordered, but the dock cannot be split. Selecting an Explorer tab does n
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
+15 -1
View File
@@ -58,7 +58,10 @@ Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
hit-testing starts fresh in that window. If its children use gesture-handler,
the Modal owner must wrap its content in a full-height `GestureHandlerRootView`:
the app's root does not span Android windows. React context alone does not
establish a native gesture root. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
@@ -154,6 +157,17 @@ layout. Preserve the translated dock's history scroll range with a far-end
content inset on the inverted stream list. Update that inset only when keyboard
motion settles; never drive it per frame.
The translated dock's height is not the composer's available height. Bound the
composer against the stationary space below the header through
`composer/viewport`, including its controls and attachments. Reserve the
keyboard destination at move start and release space at move end on the UI
thread. Waiting for a settled JS update lets a long draft disappear behind the
header during opening. This is a boundary-time layout change, not an animated
layout prop. Keep the input's native intrinsic sizing and internal scrolling.
New workspace also needs its setup fields to scroll when the form exceeds this
space. The Android regression must check both the header and IME boundaries;
checking only the bottom controls allowed this regression through previously.
Move the stream and composer together through `KeyboardDock`. Do not translate
them independently.
+4
View File
@@ -49,6 +49,10 @@ Provider controls remain provider-native; see [providers.md](providers.md).
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.
`workspace.create.request.idempotencyKey` provides the same creation guarantee for directory and
worktree workspaces; check `server_info.features.workspaceRequestReceipts` before using it.
Workspace creation keys and agent creation keys have separate namespaces. The app keeps these keys
for the lifetime of a draft and reuses the first message ID after failures.
The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
+7
View File
@@ -47,6 +47,13 @@ focused input identity and IME hide/show events. It saves screenshots and logs u
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
to test an installed production build. It never submits a chat message.
`npm run test:e2e:composer-keyboard:android` covers composer growth in chat and
New workspace, plus keyboard and control interactions. Configure its daemon,
Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
the headless input helper cannot verify the
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
## Maestro compatibility
+12 -4
View File
@@ -356,8 +356,10 @@ query, and toast providers inside the surface content, including overflow pages
plugins. Providers only around the trigger do not reach those bodies.
Request observation with `client.paseo.agents.list({ subscribe: {} })` and consume the returned
`subscription` handle. Plain `list()` and local `.subscribe(handler)` listeners create no daemon
demand. Each list-and-subscribe call has its own server ID, even for the same query. Handle snapshots
`subscription` handle. Plain `list()` and agent/workspace directory `.subscribe(handler)` listeners create no daemon
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
also run after reconnect; replace your view before applying its subsequent updates. The installation
owns every observation created through its API and releases them on unload, including setup failure.
Mounted surfaces and command invocations have shorter API lifetimes.
@@ -369,8 +371,10 @@ work inside it. See the maintained [composer pill example](../plugin-examples/lo
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
built-in projection stay unchanged. The app transforms each source item while building the render
model, for both fetched history and live events. The input includes `phase: "streaming" | "complete"`.
Paseo memoizes by source-item reference and derives every replacement ID from the source identity, so
model, for both fetched history and live events, before native Markdown splitting and Overview
tool grouping. Assistant callbacks receive the accumulated source text, never display fragments.
Live assistant messages use `phase: "streaming"`; committing to history makes them `"complete"`.
Paseo memoizes by source-item reference and phase and derives replacement IDs from source identity, so
streaming updates preserve mounted component identity.
`query.itemType` selects one public `AgentTimelineItem.type`. The callback owns any detailed
@@ -475,6 +479,10 @@ Its writer lives with the plugin subprocess, while its directory lives outside m
so updates and reloads retain values. Settings-change notifications must not enter the catalog
reload path: that path disposes the plugin and would destroy open drafts after every save.
`server.registerSettings(definition)` returns a server-side handle. Use `read()` for the current
`ready` or `invalid` state and `subscribe()` for successful saves, resets, and migrations. The
subscription cleanup belongs in the plugin's contribution cleanup when it outlives the entry.
## Contribute a theme
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
+15 -4
View File
@@ -46,15 +46,26 @@ The app, CLI, and plugins inherit those defaults; they supply only host resource
automation, or explicit overrides. A schema accepting a message does not establish support for
its delivery semantics.
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
On capable daemons, connecting creates no timeline or event demand. Client subscriptions own their network membership,
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
without requesting streams. Application caches and which agents are visible remain caller-owned.
## Owned observations
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the entire source-owned
contract described in [architecture](architecture.md#websocket-protocol). The client gates observation
once and tells users to update an older host. It does not emulate independent handles over legacy slots.
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
contract described in [architecture](architecture.md#websocket-protocol). The client selects delivery
behavior once at its connection boundary. App workflows use the same observation interface on both.
With an older daemon, the client uses the existing connection and legacy RPCs. Directory subscriptions
remain shared and last-query-wins. Local handle IDs identify listeners; they do not promise independent
server filters. Timeline and event membership retain their existing shared behavior. Releasing a handle
detaches its listener and uses the old unsubscribe operation where one exists. Broadcast-only hosts keep
broadcasting; readiness there means local attachment, not a daemon acknowledgement. No extra sockets
or multiplexing emulation are introduced.
Preserve established workflows when changing delivery internals. Independent filters and quiet
connections require a capable daemon; opening the app, reading history and using terminals do not.
Pre-registry workspace grouping and legacy event normalization belong inside the client boundary.
Old clients keep their existing wire shapes and slot behavior at the daemon's source boundary.
The adapter keys legacy slots by physical socket, so an old connection cannot replace a modern
+7
View File
@@ -49,6 +49,13 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
ACP permission options are rendered as ordered actions and Paseo returns the selected option's exact `optionId`. Agents can therefore encode a single-choice question as multiple options of the same allow kind. Auto-accept does not resolve those chooser requests; they always wait for the user.
ACP shims can own model discovery through `catalogModelResolver`; the shared client owns the probe
process and refresh deadline. Keep vendor RPCs in the shim. Cursor uses
`cursor/list_available_models` because switching models during discovery writes its saved CLI
preferences and selection history. Cursor versions without that extension must be updated. Kimi
still probes model selections in its own shim. The initial session supplies modes and the current
model; it does not override the model list returned by a resolver.
### Direct
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
+32 -3
View File
@@ -5,8 +5,10 @@ Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
The daemon keeps canonical rows only for its runtime. Provider history is the durable transcript
authority and repopulates those rows when an agent resumes.
The daemon retains projected items in memory. Each source event advances the stream sequence,
then replaces the previous tool state or merges into the current text item. Intermediate payloads
are never retained for history or catch-up. Provider history is the durable transcript authority
and rebuilds the projection when an agent resumes.
The invariants are:
@@ -67,7 +69,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
## Resume behavior
@@ -88,6 +93,25 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
history start; a response requested from a pre-replacement range is stale and is discarded.
A plan approval keeps the original proposal's tool-call identity through resolution and provider
history replay. The pending approval UI can hide that tool from presentation, but the client model
must retain its position. Creating a new history card on rejection places it after the prompt that
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
## Provider child history
Child transcripts use the same projected-page reconciliation as the main conversation. The client
retains rendered items and sequence cursors, never a second cache of source events. Pagination
uses the projected display anchor; live updates advance the source cursor without moving tools.
Clients advertising `projected_subagent_timeline` receive child streams and projected fetches.
Updated clients require `features.projectedSubagentTimeline` for child history; older hosts show
an update-host notice for that pane.
Older clients retain child names/status and receive an upgrade message when opening a child
conversation. This gate affects only the child transcript; it does not gate the connection,
main conversation, or current-context forks. A legacy `canonical` root request still receives
projected items.
## Client replica lifetime
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
@@ -95,6 +119,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application,
behind one interface. React supplies transport and projection operations without selecting a cache
path or issuing a separate persistence notification.
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
history response can arrive before the active-list response that omits it. Clearing history there
would discard accepted rows while the viewed owner still considers them synchronized. Entity
deletion clears the transcript; list membership changes do not.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
@@ -9,7 +9,7 @@ import {
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
@@ -22,53 +22,6 @@ const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
const MODELESS_PROVIDER = "modeless-profile-e2e";
const MODELESS_MODEL = "pi-profile-model";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
featureValues?: unknown;
};
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
@@ -143,7 +96,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
],
});
await seedPoisonedModelessPreference(page);
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
@@ -206,7 +159,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
},
],
});
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: true });
try {
await gotoAppShell(page);
+134 -12
View File
@@ -876,6 +876,32 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
).toContainText("Scroll long lines");
});
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
const explorer = await openCompactChanges(page, workspace);
await openChangedFilesOverview(page);
await test.step("reopening the overview expands folders again", async () => {
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
});
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
});
});
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
const committed = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, committed, 90_000);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
const explorer = await openCompactChanges(page, committed);
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
await selectEmptyUncommittedComparison(page, explorer);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -1159,20 +1185,25 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
test("canvas diff preserves selection through a repaint and copies selections and source lines", async ({
page,
}) => {
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
await test.step("copy the selection before resizing", async () => {
await copyDiffSelectionWithKeyboard(page);
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the preserved selection after resizing", async () => {
await resizeDiffViewportHeight(page, 960);
await copyFromChangedLineMenu(page, "Copy");
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the complete source line", async () => {
await copyFromChangedLineMenu(page, "Copy line");
await expectClipboardText(page, "ABCDEFGHIJ");
});
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
@@ -1667,6 +1698,62 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await expectExpandedMountedTabDiff(page);
}
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
await page.setViewportSize({ width: 390, height: 844 });
// Reload at the compact size: panel selection starts at the center on a cold
// mount, independently of any desktop sidebar used earlier in the test.
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
return explorer;
}
async function openChangedFilesOverview(page: Page): Promise<void> {
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
await expect(jumpToFile).toBeVisible();
await jumpToFile.click();
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
}
async function expectOverviewReopensExpanded(
page: Page,
folderName: string,
childFileName: string,
): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
const tree = changesTree(page);
const folder = tree
.getByRole("button")
.filter({ has: page.getByText(folderName, { exact: true }) });
const child = tree.getByText(childFileName, { exact: true });
await expect(folder).toBeVisible();
await expect(child).toBeVisible();
await folder.click();
await expect(child).toHaveCount(0);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await openChangedFilesOverview(page);
await expect(child).toBeVisible();
}
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
await expect(sheet).toHaveCount(0);
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
}
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
}
async function openWorkspaceChangesSurface(
page: Page,
workspace: DirtyWorkspace,
@@ -1820,6 +1907,41 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
}
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await page.keyboard.press("ControlOrMeta+C");
}
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await rightClickFirstChangedLine(page);
const item = page.getByRole("menuitem", { name: action, exact: true });
await expect(item).toBeEnabled();
await item.click();
}
async function expectClipboardText(page: Page, text: string): Promise<void> {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
}
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
const previousViewport = page.viewportSize()!;
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
await page.setViewportSize({ width: previousViewport.width, height });
await expect(canvas).toHaveCSS(
"height",
`${previousHeight + height - previousViewport.height}px`,
);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
@@ -20,6 +20,8 @@ import {
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
@@ -41,6 +43,31 @@ const WIDE_VIEWPORT = { width: 1440, height: 900 };
const LOADED_TURNS = 16;
test.describe("desktop chat outline", () => {
test("keeps the prompt marked while reading split Markdown blocks and after completion", async ({
page,
}) => {
test.setTimeout(120_000);
const prompt = "Explain in several paragraphs.";
await withStreamingMarkdownOutline(async (agent) => {
await agent.client.sendAgentMessage(agent.agentId, "Earlier prompt.");
await agent.client.waitForFinish(agent.agentId, 30_000);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await agent.client.sendAgentMessage(agent.agentId, prompt);
await expectChatOutlinePrompts(page, 2);
await expectReadingStreamedMarkdown(page, prompt);
await expectActiveChatOutlinePrompt(page, 2);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectActiveChatOutlinePrompt(page, 2);
await page.reload({ waitUntil: "domcontentloaded" });
await expectActiveChatOutlinePrompt(page, 2);
await clickChatOutlineRowEdge(page, 2);
await expectTimelinePromptLandedBelowTop(page, prompt);
});
});
test("indexes unloaded prompts and jumps with one bounded merged page", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
@@ -9,6 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
const TEST_COMMANDS = [
{
@@ -671,7 +672,7 @@ test.describe("Composer autocomplete", () => {
});
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
await expectMobileAgentSidebarVisible(page);
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
@@ -1,4 +1,4 @@
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -30,10 +30,13 @@ async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
title: "Composer diff stat",
repo: { withRemote: true },
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
},
});
try {
await rm(path.join(workspace.cwd, "remote.git"), { recursive: true });
await writeFile(
path.join(workspace.cwd, "README.md"),
"# Temp Repo\nexport const one = 1;\nexport const two = 2;\n",
@@ -0,0 +1,130 @@
import type { createCreationScenario } from "../support/helpers/creation";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
for (const isolation of ["local", "worktree"] as const) {
test(`repeated Create clicks before a render create only one ${isolation} workspace`, async ({
creation,
}) => {
await creation.openWorkspaceForm(isolation);
await creation.submitRepeatedly("Create");
await creation.expectOneCreatedWorkspace();
});
}
test("repeated Send clicks before a render create only one agent", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitRepeatedly("Send message", "Start exactly one agent for this prompt.");
await creation.expectPromptVisible();
await creation.expectAgentCount(1);
});
test("retrying the app's agent creation returns the same agent for every attempt", async ({
creation,
agentRetries,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start exactly one agent for this prompt.");
expect(new Set(await agentRetries.completedAgentIds()).size).toBe(1);
await creation.expectAgentCount(1);
});
test("the first prompt still names an agent created with a receipt", async ({ creation }) => {
await creation.openAgentDraft();
await creation.submitPrompt("Name this new agent from its first prompt.");
await creation.expectPromptVisible();
await creation.expectAgentTitle("Name this new agent from its first prompt.");
});
test("reconnecting after a lost creation response recovers the agent and initial message", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("a remounted draft reconciles its original creation after reconnect", async ({
creation,
promptRetry,
}) => {
await creation.openAgentDraft();
promptRetry.holdAcknowledgement();
await creation.submitPrompt("Deliver this initial prompt once.");
await promptRetry.waitForDeliveredPrompt();
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Deliver this initial prompt once.");
await promptRetry.disconnectAndReconnect();
await promptRetry.expectSameAgentAndMessage();
await creation.expectAgentCount(1);
});
test("repeated new-workspace prompt submissions create one workspace and one agent", async ({
creation,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", "Start one agent in one new workspace.");
await creation.expectPromptVisible();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("separate drafts can intentionally create two agents in the same workspace", async ({
creation,
delayedCreation,
}) => {
await creation.openAgentDraft();
await creation.submitPrompt("Start the first agent.");
await creation.expectPromptVisible("Start the first agent.");
await delayedCreation.waitForDelayedCreatedStatus();
await creation.startAnotherDraft();
await creation.submitPrompt("Start the second agent.");
await creation.expectPromptVisible("Start the second agent.");
await delayedCreation.waitForDelayedCreatedStatus();
delayedCreation.release();
await creation.expectAgentCount(2);
});
test("new workspace navigation and optimistic prompt precede agent initialization", async ({
creation,
startup,
delayedCreation,
}) => {
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Show this prompt while the agent is starting.", "Create");
await creation.expectAgentStillStarting();
delayedCreation.expectSingleWorkspaceIntent();
delayedCreation.release();
await startup.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
}
@@ -1,4 +1,7 @@
import { test } from "../support/fixtures";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { test, expect, type Page } from "../support/fixtures";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import {
closeModelPicker,
expectModelSearchEmptyState,
@@ -15,7 +18,7 @@ import {
expectSearchResultsVirtualized,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
const MOCK_PROVIDER_LABEL = "Mock Load Test";
@@ -158,3 +161,110 @@ test.describe("Cross-provider model search", () => {
}
});
});
async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) {
await client.patchDaemonConfig({
providers: {
gemini: {
extends: "acp",
label: "Catalog provider",
enabled: true,
command: [
process.execPath,
path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"),
String(count),
],
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd })).entries.find(
(entry) => entry.provider === "gemini",
)?.status,
{ timeout: 30_000 },
)
.toBe("ready");
}
async function reloadSavedDraft(page: Page) {
await page.evaluate(() =>
localStorage.setItem(
"@paseo:e2e-disable-default-seed-once",
localStorage.getItem("@paseo:e2e-seed-nonce")!,
),
);
await page.reload();
await expectComposerVisible(page);
}
async function expectOneCatalogChoice(page: Page, label: string) {
await openModelPicker(page);
await page.getByRole("button", { name: "Back", exact: true }).click();
await searchAllModels(page, label);
await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1);
await closeModelPicker(page);
}
async function configureModelOverride(client: DaemonClient, cwd: string) {
await client.patchDaemonConfig({
providers: {
gemini: {
additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }],
},
},
});
await expect
.poll(
async () =>
(await client.getProvidersSnapshot({ cwd: cwd })).entries.find(
(entry) => entry.provider === "gemini",
)?.models,
{ timeout: 30_000 },
)
.toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]);
}
const catalogTest = test.extend<{ catalogClient: DaemonClient }>({
catalogClient: async ({ e2eWorker }, provide) => {
void e2eWorker;
const client = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "catalog-models" });
try {
await provide(client);
} finally {
try {
await client.patchDaemonConfig({ removeProviders: ["gemini"] });
} finally {
await client.close();
}
}
},
});
catalogTest(
"New Agent and saved drafts stay usable with repeated runtime model rows",
async ({ page, withWorkspace, catalogClient: client }, testInfo) => {
const workspace = await withWorkspace({ prefix: "catalog-models-" });
await test.step("open a draft with a one-row runtime catalog", async () => {
await setRuntimeCatalog(client, 1, workspace.repoPath);
await gotoWorkspace(page, workspace.workspaceId);
await expectComposerVisible(page);
});
await test.step("open New Agent after the provider publishes repeated model rows", async () => {
await clickNewTerminal(page);
await setRuntimeCatalog(client, 2, workspace.repoPath);
await clickNewChat(page);
await expectComposerVisible(page);
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") });
});
await test.step("reopen the saved draft with the same catalog", async () => {
await reloadSavedDraft(page);
await expectOneCatalogChoice(page, "Gemini 3.5 Flash");
});
await test.step("retain configured model overrides when runtime IDs repeat", async () => {
await configureModelOverride(client, workspace.repoPath);
await reloadSavedDraft(page);
await expectOneCatalogChoice(page, "Configured model");
});
},
);
@@ -1,9 +1,10 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, type Page } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { captureWorkspaceAgentRequest } from "../support/helpers/creation";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
openGlobalNewWorkspaceComposer,
@@ -17,40 +18,6 @@ import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
};
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(rawMessage);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page, cwd: string): Promise<string> {
const client = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "codex-mode-preferences",
@@ -142,44 +109,15 @@ async function expectThinkingOptionsFit(page: Page): Promise<void> {
await expect(page.getByTestId("combobox-desktop-container")).toHaveCount(0, { timeout: 5_000 });
}
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => {
ws.send(message);
});
});
return {
waitForCreateAgentRequest: () => createAgentSeen,
};
}
test.describe("New workspace Codex mode preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
startup,
}) => {
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
const createAgentRecorder = await captureWorkspaceAgentRequest(page, { block: false });
try {
await seedCodexDefaultPermissionPreferences(page, seeded.repoPath);
await gotoAppShell(page);
@@ -200,8 +138,12 @@ test.describe("New workspace Codex mode preferences", () => {
).toBeVisible();
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
const createAgentRequest = await createAgentRecorder.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByText("Keep Codex full access selected globally.", { exact: true }).first(),
).toBeVisible();
expect(createAgentRequest.config).toMatchObject({
provider: "codex",
modeId: "full-access",
@@ -209,6 +151,8 @@ test.describe("New workspace Codex mode preferences", () => {
await expect
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
.toBe("full-access");
await startup.fail();
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
} finally {
await seeded.cleanup();
}
@@ -1,14 +1,42 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectTimelinePromptVisible,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
} from "../support/helpers/timeline-pagination";
test("requires a host update before observing a published 0.2.5 daemon", async ({ page }) => {
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (
frame.type === "session" &&
frame.message.type === "fetch_agents_request" &&
frame.message.subscribe
)
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
@@ -21,16 +49,32 @@ test("requires a host update before observing a published 0.2.5 daemon", async (
modeId: "load-test",
model: "ten-second-stream",
});
const requests: unknown[] = [];
page.on("websocket", (socket) =>
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string") return;
const frame = JSON.parse(payload);
if (frame.type === "session" && frame.message.type !== "ping") requests.push(frame.message);
}),
);
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
try {
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
@@ -45,20 +89,52 @@ test("requires a host update before observing a published 0.2.5 daemon", async (
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
await page.goto(buildAgentRoute(workspace.workspaceId, createdAgent.id, serverId));
const updateHost = page.getByText("Update the host to use this version of Paseo.", {
exact: true,
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
await expect(updateHost).toHaveCount(1);
await expect(updateHost).toBeVisible();
await expect(page.getByRole("button", { name: "Manage host", exact: true })).toBeVisible();
expect(requests).toEqual([]);
await page.reload();
await expect(updateHost).toHaveCount(1);
await expect(updateHost).toBeVisible();
expect(requests).toEqual([]);
} finally {
await workspace.cleanup();
await agent.cleanup();
await daemon.close();
}
});
@@ -1,4 +1,5 @@
import { mkdtempSync, realpathSync } from "node:fs";
import type { Page, TestInfo } from "@playwright/test";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
@@ -18,7 +19,7 @@ const scenarios = [
},
{
provider: "codex" as const,
providerConfig: { featureValues: { plan_mode: true } },
providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } },
planPrompt:
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
planText: "Inspect permission steering",
@@ -27,57 +28,151 @@ const scenarios = [
},
];
test.describe("composer steer supersedes plan approval", () => {
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
async function readConversationOrder(page: Page, prompt: string, reply: string) {
const plan = page.getByTestId("timeline-plan-card");
const question = page.getByTestId("user-message").filter({ hasText: prompt });
const answer = page.getByTestId("assistant-message").filter({ hasText: reply });
const boxes = await Promise.all([
plan.boundingBox(),
question.boundingBox(),
answer.boundingBox(),
]);
return boxes
.map((box, index) => {
if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered");
return { role: ["plan", "question", "answer"][index], y: box.y };
})
.sort((a, b) => a.y - b.y)
.map((item) => item.role);
}
async function togglePlan(
page: Page,
testId: string,
name: string,
expanded: boolean,
): Promise<void> {
const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", String(expanded));
}
async function expectRejectedPlanCollapsed(page: Page): Promise<void> {
await expect(
page
.getByTestId("timeline-plan-card")
.getByRole("button", { name: "Rejected plan", exact: true }),
).toHaveAttribute("aria-expanded", "false");
}
async function reopenConversation(page: Page, reply: string): Promise<void> {
await page.reload();
await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible();
}
type PlanScenario = (typeof scenarios)[number];
interface PlanSteer {
page: Page;
scenario: PlanScenario;
testInfo: TestInfo;
}
async function withPlanSteer(context: PlanSteer, journey: () => Promise<void>): Promise<void> {
const { page, scenario } = context;
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
}, testInfo) => {
test.setTimeout(420_000);
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
await submitMessage(page, scenario.steerPrompt);
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expect(rejectedPlan).toContainText(scenario.planText);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await journey();
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise<void> {
await submitMessage(page, scenario.planPrompt);
await waitForPermissionPrompt(page, 180_000);
const pendingPlan = page.getByTestId("permission-plan-card");
await expect(pendingPlan).toContainText(scenario.planText);
await togglePlan(page, "permission-plan-card", "Plan", false);
await expect(page.getByTestId("permission-request-deny")).toBeVisible();
await togglePlan(page, "permission-plan-card", "Plan", true);
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
await page.screenshot({ path: pendingScreenshot });
await testInfo.attach(`${scenario.provider} pending plan`, {
path: pendingScreenshot,
contentType: "image/png",
});
}
async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise<void> {
await submitMessage(page, scenario.steerPrompt);
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 });
const rejectedPlan = page.getByTestId("timeline-plan-card");
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
await expectRejectedPlanCollapsed(page);
await expect(
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
).toBeVisible({
timeout: 180_000,
});
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
await page.screenshot({ path: rejectedScreenshot });
await testInfo.attach(`${scenario.provider} rejected plan`, {
path: rejectedScreenshot,
contentType: "image/png",
});
await expect(
page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }),
).toBeVisible();
expect
.soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply))
.toEqual(["plan", "question", "answer"]);
}
async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise<void> {
const rejectedPlan = page.getByTestId("timeline-plan-card");
await togglePlan(page, "timeline-plan-card", "Rejected plan", true);
await expect(rejectedPlan).toContainText(scenario.planText);
await rejectedPlan.screenshot({
path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`),
});
await togglePlan(page, "timeline-plan-card", "Rejected plan", false);
}
async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise<void> {
await reopenConversation(page, scenario.reply);
await expectRejectedPlanCollapsed(page);
expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([
"plan",
"question",
"answer",
]);
}
for (const scenario of scenarios) {
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
const context = { page, scenario, testInfo };
await withPlanSteer(context, async () => {
await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context));
await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context));
await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context));
await test.step("Keep conversation order after reload", () =>
restoreConversationOrder(context));
});
});
}
@@ -0,0 +1,164 @@
import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { submitMessage } from "../support/helpers/composer";
import {
allowPermission,
denyPermission,
waitForPermissionPrompt,
} from "../support/helpers/permissions";
import {
assertComposerIdle,
cleanupRewindFlow,
launchAgent,
type AgentHandle,
} from "../support/helpers/rewind-flow";
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
const PLAN_PROMPT =
"Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED.";
async function requestPlan(page: Page, prompt: string): Promise<void> {
await submitMessage(page, prompt);
await waitForPermissionPrompt(page, 180_000);
await expect(page.getByTestId("permission-plan-card")).toContainText("README.md");
}
function planWithOutcome(page: Page, label: string) {
return page
.getByTestId("timeline-plan-card")
.filter({ has: page.getByRole("button", { name: label, exact: true }) });
}
async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise<void> {
const card = planWithOutcome(page, label);
await expect(card).toHaveCount(1);
await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute(
"aria-expanded",
String(expanded),
);
}
async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise<void> {
await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click();
await expectPlanOutcome(page, label, expanded);
}
async function expectClaudeReply(page: Page, marker: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({
timeout: 180_000,
});
await assertComposerIdle({ page });
}
async function restartIsolatedDaemon(): Promise<void> {
const port = Number(getE2EDaemonPort());
expect([6767, 6768]).not.toContain(port);
const client = await connectDaemonClient<{
connect(): Promise<void>;
close(): Promise<void>;
getDaemonStatus(): Promise<{ pid: number }>;
restartServer(reason: string): Promise<unknown>;
}>({ port, clientIdPrefix: "plan-history-restart" });
try {
const before = (await client.getDaemonStatus()).pid;
await client.restartServer("Verify plan history replay in the isolated Playwright daemon");
await expect
.poll(
async () => {
try {
return (await client.getDaemonStatus()).pid;
} catch {
return before;
}
},
{ timeout: 60_000 },
)
.not.toBe(before);
} finally {
await client.close();
}
}
async function expectRecordedPlans(page: Page): Promise<void> {
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 });
await expectPlanOutcome(page, "Rejected plan", false);
await expectPlanOutcome(page, "Approved plan", true);
}
interface PlanReview {
page: Page;
cwd: string;
testInfo: TestInfo;
}
async function withPlanReview(
page: Page,
testInfo: TestInfo,
review: (context: PlanReview) => Promise<void>,
): Promise<void> {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "haiku", modeId: "plan" },
});
await review({ page, cwd, testInfo });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
}
async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise<void> {
await requestPlan(page, PLAN_PROMPT);
await denyPermission(page);
await expectClaudeReply(page, "PLAN_REJECTED");
await expectPlanOutcome(page, "Rejected plan", false);
await toggleRecordedPlan(page, "Rejected plan", true);
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") });
await toggleRecordedPlan(page, "Rejected plan", false);
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello");
}
async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise<void> {
await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`);
await allowPermission(page);
await expectClaudeReply(page, "PLAN_IMPLEMENTED");
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello");
await expectRecordedPlans(page);
await toggleRecordedPlan(page, "Approved plan", false);
await toggleRecordedPlan(page, "Approved plan", true);
await page.screenshot({ path: testInfo.outputPath("approved-plan.png") });
}
async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise<void> {
await restartIsolatedDaemon();
await page.reload();
await expectRecordedPlans(page);
await toggleRecordedPlan(page, "Rejected plan", true);
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
await toggleRecordedPlan(page, "Rejected plan", false);
await page.screenshot({ path: testInfo.outputPath("restored-plans.png") });
}
test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({
page,
}, testInfo) => {
test.setTimeout(420_000);
await withPlanReview(page, testInfo, async (review) => {
await test.step("Reject a plan without implementing it", () =>
rejectPlanWithoutImplementing(review));
await test.step("Approve and implement a fresh plan", () =>
approveFreshPlanAndImplement(review));
await test.step("Restore readable plans after daemon restart", () =>
restoreReadablePlanHistory(review));
});
});
@@ -0,0 +1,35 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
expectBothConsecutiveTools,
} from "../support/helpers/plugin-timeline";
for (const width of [1100, 390]) {
test(`assistant plugin receives the whole streaming message at width ${width}`, async ({
page,
}, info) => {
await page.setViewportSize({ width, height: 900 });
await withTimelinePlugin(page, info, "assistant", async (agent) => {
await requestPluginTimeline(agent);
await interactWithStreamingCard(page);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectWholeCompletedCard(page);
// Local state is checked during growth; the viewport remounts on the history handoff.
await page.reload({ waitUntil: "domcontentloaded" });
await expectWholeCompletedCard(page);
});
});
}
test("Overview preserves both consecutive tool plugin cards", async ({ page }, info) => {
await withTimelinePlugin(page, info, "tools", async (agent) => {
await requestPluginTimeline(agent);
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectBothConsecutiveTools(page);
await page.reload({ waitUntil: "domcontentloaded" });
await expectBothConsecutiveTools(page);
});
});
@@ -4,9 +4,15 @@ import { expect, test, type Page, type TestInfo } from "@playwright/test";
let server: Server;
let appUrl: string;
test.beforeAll(async () => {
// Compile this separate fixture entrypoint in setup, like the main app warmup.
// Cold Metro compilation must not consume the recovery interaction's timeout.
test.setTimeout(120_000);
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required");
const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`;
const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) });
if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`);
await compiled.arrayBuffer();
server = createServer((_request, response) => {
response.setHeader("Content-Type", "text/html");
response.end(
@@ -1,6 +1,6 @@
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
@@ -104,12 +104,12 @@ test.describe("Settings toggle tab regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
try {
const firstAgent = await createIdleAgent(workspace.client, {
const firstAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
const secondAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-toggle-b-${Date.now()}`,
@@ -165,12 +165,12 @@ test.describe("Settings toggle tab regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "settings-composer-height-" });
try {
const firstAgent = await createIdleAgent(workspace.client, {
const firstAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-composer-height-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
const secondAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `settings-composer-height-b-${Date.now()}`,
@@ -225,12 +225,12 @@ test.describe("Settings toggle tab regression", () => {
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
try {
const firstAgent = await createIdleAgent(workspace.client, {
const firstAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(workspace.client, {
const secondAgent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: `agent-route-refresh-b-${Date.now()}`,
@@ -7,7 +7,6 @@ import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
// Model B sidebar shape: every project — git or non-git, single- or
// multi-workspace — renders as the same expandable parent, the deepest sidebar
@@ -96,8 +95,9 @@ test.describe("Model B sidebar shape", () => {
try {
// Open the workspace and materialize both an agent tab and a terminal tab.
await gotoWorkspace(page, mock.workspaceId);
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
await expect(
page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }),
).toBeVisible();
await clickNewTerminal(page);
await expect(
@@ -0,0 +1,21 @@
import { test } from "../support/fixtures";
import {
expectFinishedMarkdown,
expectReloadedMarkdown,
expectUnfinishedBold,
expectUnfinishedLink,
requestStreamingMarkdown,
withStreamingMarkdown,
} from "../support/helpers/streaming-markdown";
test("formats unfinished Markdown while streaming and preserves the completed rendering", async ({
page,
}, testInfo) => {
await withStreamingMarkdown(page, testInfo, async (agent) => {
await requestStreamingMarkdown(agent);
await expectUnfinishedBold(page);
await expectUnfinishedLink(page, agent, testInfo);
await expectFinishedMarkdown(page, agent, testInfo);
await expectReloadedMarkdown(page);
});
});
@@ -0,0 +1,63 @@
import { test } from "../support/helpers/codex-workspace-restore";
test.use({
e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } },
trace: "on",
video: "on",
});
test.describe.configure({ timeout: 600_000 });
test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({
codexRestore,
}) => {
await test.step("Create, finish, and archive Codex before this browser connects", async () => {
await codexRestore.createWorktreeWithCodex();
await codexRestore.archiveBeforeBrowserConnects();
});
await test.step("Connect the fresh browser, open History, and restore the workspace", async () => {
await codexRestore.connectFreshBrowser();
await codexRestore.openArchivedWorkspaceFromHistory();
await codexRestore.restoreWorkspace();
await codexRestore.expectArchivedAgentSelectedWithHistory();
});
await test.step("Reload without cached agent data and keep the selected archived agent", async () => {
await codexRestore.reloadWithoutAgentCache();
await codexRestore.waitForWorkspaceHydration();
await codexRestore.expectArchivedAgentSelectedWithHistory();
});
await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => {
await codexRestore.unarchiveAgent();
await codexRestore.reloadWithoutAgentCache();
await codexRestore.expectIdleAgentWithVisibleComposer();
});
});
test("restore an archived worktree, then unarchive its completed Codex agent", async ({
codexRestore,
}) => {
await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => {
await codexRestore.createWorktreeWithCodex();
});
await test.step("2. Wait for Codex to finish and show its reply", async () => {
await codexRestore.waitForCompletedReply();
});
await test.step("3. Archive the workspace from its sidebar menu", async () => {
await codexRestore.archiveWorkspaceFromSidebar();
});
await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => {
await codexRestore.expectWorkspaceAndAgentArchived();
});
await test.step("5. Open the archived workspace from History", async () => {
await codexRestore.openArchivedWorkspaceFromHistory();
});
await test.step("6. Restore the workspace", async () => {
await codexRestore.restoreWorkspace();
await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected();
});
await test.step("7. Click Unarchive on the agent", async () => {
await codexRestore.unarchiveAgent();
});
await test.step("8. Expect an idle agent with a visible editable composer", async () => {
await codexRestore.expectIdleAgentWithVisibleComposer(60_000);
});
});
@@ -265,7 +265,7 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
test("restores the workspace before explicitly unarchiving each selected agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
@@ -292,9 +292,16 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true");
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -0,0 +1,96 @@
// Run against a native dev app loaded from this checkout:
// node packages/app/e2e/mobile/clipboard-image.mjs <metro-port> <app-id>
// Exercises real native attachment persistence; Node's fetch(data:) hides this regression.
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { WebSocket } from "ws";
const [, , metroPort, appId] = process.argv;
assert(metroPort && appId, "Pass the Metro port and native app ID");
const targets = await fetch(`http://127.0.0.1:${Number(metroPort)}/json/list`).then((r) =>
r.json(),
);
const target = targets.find((candidate) => candidate.appId === appId);
assert(target, `Missing Hermes target for ${appId}`);
const png = await readFile(new URL("../../assets/images/icon.png", import.meta.url));
const base64 = png.toString("base64");
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
let nextId = 0;
function evaluate(expression) {
return new Promise((resolve, reject) => {
const id = ++nextId;
const timeout = setTimeout(() => {
socket.off("message", onMessage);
reject(new Error("Hermes evaluation timed out"));
}, 10000);
function onMessage(message) {
const response = JSON.parse(message.toString());
if (response.id !== id) return;
clearTimeout(timeout);
socket.off("message", onMessage);
if (response.error || response.result.exceptionDetails) {
reject(new Error(JSON.stringify(response.error ?? response.result.exceptionDetails)));
} else {
resolve(response.result.result.value);
}
}
socket.on("message", onMessage);
socket.send(
JSON.stringify({
id,
method: "Runtime.evaluate",
params: { expression, returnByValue: true },
}),
);
});
}
async function waitForNativeClipboardImage() {
const deadline = Date.now() + 15000;
do {
const result = await evaluate("globalThis.__clipboardImageTest");
if (result) return result;
await delay(100);
} while (Date.now() < deadline);
assert.fail("Native image persistence timed out");
}
try {
// Metro's debugger module table locates the same public service used by the composer.
// Hermes does not await promises through Runtime.evaluate, so collect the async result.
await evaluate(`(() => {
const entry = Array.from(__r.getModules().entries()).find(
([, module]) => module.verboseName === "src/attachments/service.ts"
);
if (!entry) throw new Error("Open the composer before running this test");
const service = __r(entry[0]);
globalThis.__clipboardImageTest = null;
service.persistAttachmentFromDataUrl({
dataUrl: ${JSON.stringify(`data:image/png;base64,${base64}`)},
fileName: "clipboard.png"
}).then(attachment => {
return Promise.all([
service.encodeAttachmentsForSend([attachment]),
service.resolveAttachmentPreviewUrl(attachment)
]).then(([encoded, preview]) => ({ attachment, encoded, preview }))
.finally(() => service.deleteAttachments([attachment]));
}).then(result => { globalThis.__clipboardImageTest = result; })
.catch(error => { globalThis.__clipboardImageTest = { error: String(error) }; });
})()`);
const result = await waitForNativeClipboardImage();
assert.equal(result.error, undefined, result.error);
assert.equal(result.attachment.storageType, "native-file");
assert.equal(result.attachment.byteSize, png.length);
assert.equal(result.attachment.fileName, "clipboard.png");
assert.match(result.preview, /^file:\/\//);
assert.deepEqual(result.encoded, [{ data: base64, mimeType: "image/png" }]);
console.log(`PASS: native clipboard PNG persisted, previewed, and encoded (${png.length} bytes)`);
} finally {
await evaluate("delete globalThis.__clipboardImageTest").finally(() => socket.close());
}
@@ -275,6 +275,11 @@ node "${ASSERT}" xml-above-y \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"Add attachment" \
"$(read_ime_top)"
display_density="$(adb shell wm density | awk '/density:/ { density = $NF } END { print density }')"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${MESSAGE}" --settle
open_gboard "${input_x}" "${input_y}"
@@ -337,4 +342,24 @@ node "${ASSERT}" same-region \
1400 \
430
ad press 'id="menu-button"' --settle
ad press 'id="sidebar-global-new-workspace"' --settle
ad wait 'id="workspace-create-submit"' 10000
adb shell ime set "${HELPER_IME}" >/dev/null
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
read -r new_input_x new_input_y new_input_height < <(
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-long-draft.json" "editable"
)
# The editor shrinks when the keyboard opens. Its bottom stays anchored, while
# its old center can move into the setup fields after the height constraint.
new_input_y="$((new_input_y + new_input_height / 2 - 8))"
open_gboard "${new_input_x}" "${new_input_y}"
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml"
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.png"
node "${ASSERT}" xml-composer-contained \
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
"$(read_ime_top)" \
"${display_density}"
echo "Composer keyboard invariants passed"
@@ -4,11 +4,44 @@ import sharp from "sharp";
const [, , command, ...args] = process.argv;
if (command === "rect") {
if (command === "xml-composer-contained") {
const [snapshotPath, imeTopArgument, densityArgument] = args;
const snapshot = await fs.readFile(snapshotPath, "utf8");
const boundsFor = (id) => {
const node = snapshot.match(new RegExp(`<node[^>]*resource-id="${id}"[^>]*>`));
if (!node) throw new Error(`Missing node: ${id}`);
const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/);
if (!bounds) throw new Error(`Missing bounds: ${id}`);
return { top: Number(bounds[2]), bottom: Number(bounds[4]) };
};
const viewport = boundsFor("composer-viewport");
const content = boundsFor("composer-viewport-content");
const composer = boundsFor("message-input-root");
const controls = boundsFor("message-input-attach-button");
const clearance = (5 * Number(densityArgument)) / 160;
const minimumTop = viewport.top + clearance;
// Android rounds layout units to physical pixels; allow one pixel of rounding.
if (content.top < minimumTop - 1 || composer.top < minimumTop - 1) {
throw new Error(
`Composer overlaps header: content=${content.top}, editor=${composer.top}, minimum=${minimumTop}`,
);
}
if (composer.bottom > Number(imeTopArgument) || controls.bottom > Number(imeTopArgument)) {
throw new Error(
`Composer extends behind keyboard: editor=${composer.bottom}, controls=${controls.bottom}, keyboard=${imeTopArgument}`,
);
}
process.stdout.write(
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
);
} else if (command === "rect") {
const [snapshotPath, identifier] = args;
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
const node = snapshot.data.nodes.find(
(candidate) => candidate.identifier === identifier || candidate.label === identifier,
(candidate) =>
candidate.identifier === identifier ||
candidate.label === identifier ||
(identifier === "editable" && candidate.type.endsWith("EditText")),
);
if (!node) throw new Error(`Missing node: ${identifier}`);
const { x, y, width, height } = node.rect;
+47 -2
View File
@@ -1,4 +1,6 @@
# Native sheet gestures
# Native modal and sheet gestures
## Compact sheet gestures
Use an Android emulator at 1080×1920, density 420, with the checkout's native app bundle loaded.
Connect the app to an isolated daemon, enable plugins, and install `plugin-examples/modal-ui` there.
@@ -22,7 +24,7 @@ its parent without selecting a model or submitting a prompt. It does not assert
These scripts live outside the default mobile suite because they require the installed example and
an explicit connected host. See [mobile testing](../../../../../docs/mobile-testing.md) for device setup.
## Recorded verification
### Recorded verification
Android API 35, 1080×1920 at density 420, development binary 0.7.2 with this checkout's JavaScript.
Plugin tests used an isolated daemon. Recordings show the same body drag
@@ -45,3 +47,46 @@ Root typecheck, lint and format: passed
The model form's catalog stayed on “Loading…” in the debug app even though the isolated daemon's
provider API returned models. Its sheet dismissal/reopen was exercised; populated model selection
was not. iOS and Electron were not exercised.
## Tablet model selection
`model-tablet.android.ad` exercises the New workspace popover. `model-tablet-agent.android.ad`
exercises the modal used by an existing agent's narrow composer on a wide device. Both drill into
a populated provider, select a real model, assert the updated trigger, reopen, and dismiss with
Android Back. New workspace also pans beyond the short list viewport and flings back without
selecting a row or dismissing the popover. They do not submit a prompt.
Use an English-language Android tablet at 1600×2560, density 320 (800dp), with this checkout's
JavaScript and a connected isolated daemon. On a Pixel Tablet AVD, rotate to portrait explicitly;
its natural landscape orientation letterboxes this portrait-locked app into the compact layout:
```sh
adb -s "$ANDROID_SERIAL" shell settings put system accelerometer_rotation 0
adb -s "$ANDROID_SERIAL" shell settings put system user_rotation 1
adb -s "$ANDROID_SERIAL" shell wm density 320
```
Prepare an idle agent through the daemon's public `createAgent` API with a real provider/model and
no initial prompt. Set `MODEL_QA_AGENT_ID` and `MODEL_QA_AGENT_TITLE` to that agent. In New workspace,
remember a model first (the phone layout works on the broken baseline), then close the picker.
Choose a model visible on the provider's first page and set its catalog ID and displayed label.
Set `MODEL_QA_LAST_MODEL_ID` to its initially offscreen last model; use a short overflowing catalog
(six rows during this verification) for the coordinate-based pan/fling regression:
```sh
agent-device test packages/app/e2e/mobile/modal-sheet/model-tablet.android.ad \
packages/app/e2e/mobile/modal-sheet/model-tablet-agent.android.ad \
--serial "$ANDROID_SERIAL" --metro-port "$MODEL_QA_METRO_PORT" \
--env FORM_URL="paseo://new?serverId=${SHEET_QA_SERVER_ID}" \
--env AGENT_URL="paseo://h/${SHEET_QA_SERVER_ID}/agent/${MODEL_QA_AGENT_ID}" \
--env AGENT_TITLE="$MODEL_QA_AGENT_TITLE" \
--env PROVIDER_ID=codex --env MODEL_ID="$MODEL_QA_MODEL_ID" \
--env MODEL_LABEL="$MODEL_QA_MODEL_LABEL" --env LAST_MODEL_ID="$MODEL_QA_LAST_MODEL_ID" \
--artifacts-dir /tmp/paseo-model-tablet-qa
```
The existing-agent journey needs the sidebar pinned so its composer stays narrow. Start each run with no modal open;
a failed provider tap leaves its modal open, so dismiss it before the next run. These host-dependent
journeys remain outside the default mobile suite, alongside the compact sheet journeys above.
Keep generated screenshots, recordings, and run logs outside the repository.
@@ -0,0 +1,23 @@
# Idle existing agent in a narrow composer on a wide Android device (AdaptiveModalSheet).
# AGENT_URL=paseo://h/<serverId>/agent/<agentId>; AGENT_TITLE identifies the loaded route.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${AGENT_URL}"
wait "label=\"${AGENT_TITLE}\"" 20000
wait "id=\"combined-model-selector\"" 10000
press "id=\"combined-model-selector\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\""
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
press "id=\"combined-model-selector\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -0,0 +1,30 @@
# Connected host with a selected model, 1600x2560 at density 320. See README.md.
# FORM_URL=paseo://new?serverId=<serverId>; MODEL_ID and MODEL_LABEL name a real catalog model.
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
env PROVIDER_ID=codex
open "${APP_ID}" "${FORM_URL}"
wait "label=\"Workspace project\"" 20000
press "id=\"combined-model-selector\""
wait "id=\"combobox-desktop-container\"" 10000
# A remembered model opens its provider's list. Return to the provider page first.
press "label=\"Back\""
wait "id=\"model-provider-${PROVIDER_ID}\"" 10000
press "id=\"model-provider-${PROVIDER_ID}\""
wait "id=\"compact-model-list\"" 3000
press "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\""
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
# A drag that leaves the short viewport must scroll without selecting or dismissing.
press "id=\"combined-model-selector\""
wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000
gesture pan 1000 1850 0 -700 600
wait "id=\"compact-model-list\"" 3000
wait "id=\"model-row-${PROVIDER_ID}-${LAST_MODEL_ID}\"" 3000
gesture fling down 1000 1750 500
wait "id=\"model-row-${PROVIDER_ID}-${MODEL_ID}\"" 3000
# Android Back dismisses the whole popover without changing the remembered selection.
back
wait "id=\"combined-model-selector\" label=\"Select model (${MODEL_LABEL})\"" 3000
close
@@ -0,0 +1,92 @@
import { writeFile, rm } from "node:fs/promises";
import path from "node:path";
import { copyPluginExample } from "./helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { test as base } from "./fixtures";
import {
createCreationScenario,
createPromptRetryScenario,
retryNextAgentCreation,
} from "./helpers/creation";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const previous = await client.getDaemonConfig();
try {
await writeFile(gate, "hold");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
if (request.reason !== "create") return request;
const gate = ${JSON.stringify(gate)};
let command = await readFile(gate, "utf8").catch(() => "release");
while (command === "hold") {
await new Promise(resolve => setTimeout(resolve, 20));
command = await readFile(gate, "utf8").catch(() => "release");
}
if (command === "fail") {
await rm(gate, { force: true });
throw new Error("Creation startup failed for test");
}
return request;
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide({
release: () => rm(gate, { force: true }),
fail: () => writeFile(gate, "fail"),
});
} finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
await provide(creation);
} finally {
await creation.cleanup();
}
},
promptRetry: async ({ page }, provide) => {
const gate = await installDaemonWebSocketGate(page);
try {
await provide(createPromptRetryScenario(page, gate));
} finally {
gate.restore();
}
},
agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page));
},
delayedCreation: async ({ page }, provide) => {
const delayed = await delayBrowserAgentCreatedStatus(page);
try {
await provide(delayed);
} finally {
delayed.release();
}
},
});
@@ -0,0 +1,18 @@
const readline = require("node:readline");
const model = { modelId: "gemini-3.5-flash", name: "Gemini 3.5 Flash" };
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
let result = {};
if (request.method === "initialize")
result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] };
if (request.method === "session/new")
result = {
sessionId: "catalog-diagnostic",
models: {
currentModelId: model.modelId,
availableModels: Array.from({ length: Number(process.argv[2]) }, () => model),
},
};
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n");
});
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { loadSessionMessageReaders } from "./new-workspace";
import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
type WebSocketMessage = string | Buffer;
@@ -292,11 +293,14 @@ export async function holdRewindCompletion(
}
if (
!released &&
sessionMessage?.type === "agent.rewind.response" &&
(sessionMessage?.type === "agent.rewind.response" ||
sessionMessage?.type === "agent.timeline.replacement") &&
payload?.agentId === agentId
) {
// The replacement also signals completion and removes the rewound row/menu.
// Hold it with the response so the pending-state assertion observes a pending rewind.
delayedForwards.push(() => ws.send(message));
resolveDelayedResponse?.();
if (sessionMessage.type === "agent.rewind.response") resolveDelayedResponse?.();
return;
}
ws.send(message);
@@ -321,6 +325,7 @@ export async function holdRewindCompletion(
export async function delayCreatedAgentInitialTailResponse(
page: Page,
): Promise<CreatedAgentTimelineGate> {
const frames = await loadSessionMessageReaders();
let createdAgentId: string | null = null;
let releaseRequested = false;
let delayedResponseSeen = false;
@@ -350,19 +355,24 @@ export async function delayCreatedAgentInitialTailResponse(
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type === "status" && payload?.status === "agent_created") {
const agentId = payload.agentId;
if (typeof agentId === "string") {
createdAgentId = agentId;
resolveCreatedAgent?.(agentId);
}
const sessionMessage = frames.server(message);
let createdId: unknown;
if (sessionMessage?.type === "agent.create.response") {
createdId = sessionMessage.payload.agent?.id;
} else if (
sessionMessage?.type === "status" &&
sessionMessage.payload.status === "agent_created"
) {
createdId = sessionMessage.payload.agentId;
}
if (typeof createdId === "string") {
createdAgentId = createdId;
resolveCreatedAgent?.(createdId);
}
if (sessionMessage?.type === "fetch_agent_timeline_response") {
const agentId = payload?.agentId;
const direction = payload?.direction;
const agentId = sessionMessage.payload.agentId;
const direction = sessionMessage.payload.direction;
if (
!delayedResponseSeen &&
typeof agentId === "string" &&
@@ -599,3 +609,71 @@ async function delayAgentTimelineResponse(
waitForDelayedResponse: () => delayedResponse,
};
}
/** Holds real incoming frames so intermediate rendering assertions do not race the producer. */
export async function holdAssistantStream(page: Page, agentId: string) {
const pending: Array<{ forward(): void; text: string }> = [];
let holding = false;
let released = false;
let forwardedText = "";
let notifyFrame: (() => void) | undefined;
let notifyInitialTimeline!: () => void;
const initialTimeline = new Promise<void>((resolve) => {
notifyInitialTimeline = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const session = getSessionMessage(message);
const payload = session ? getPayload(session) : null;
if (session?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId) {
notifyInitialTimeline();
}
const event = payload?.event as
| { type?: string; item?: { type?: string; text?: string } }
| undefined;
const text =
session?.type === "agent_stream" &&
payload?.agentId === agentId &&
event?.type === "timeline" &&
event.item?.type === "assistant_message"
? (event.item.text ?? "")
: "";
if (text) holding = true;
if (holding && !released) {
pending.push({ forward: () => ws.send(message), text });
notifyFrame?.();
} else {
ws.send(message);
}
});
});
return {
waitForInitialTimeline: () => initialTimeline,
async showThrough(prefix: string): Promise<void> {
while (forwardedText.length < prefix.length) {
const frame = pending.shift();
if (!frame) {
await new Promise<void>((resolve) => {
notifyFrame = resolve;
});
continue;
}
forwardedText += frame.text;
frame.forward();
}
if (forwardedText !== prefix) {
throw new Error(
`Stream did not stop at ${JSON.stringify(prefix)}: ${JSON.stringify(forwardedText)}`,
);
}
},
release() {
released = true;
for (const frame of pending.splice(0)) frame.forward();
},
};
}
@@ -2,6 +2,41 @@ import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-outline-markdown-",
title: "Streaming Markdown outline",
featureValues: {
mockStreamingAssistantResponse: Array.from(
{ length: 60 },
(_, index) => `Paragraph ${index + 1}.`,
).join("\n\n"),
mockStreamingAssistantIntervalMs: 80,
},
});
try {
await run(agent);
} finally {
await agent.cleanup();
}
}
export async function expectReadingStreamedMarkdown(page: Page, prompt: string): Promise<void> {
await expect(page.getByText("Paragraph 30.", { exact: true }).last()).toBeVisible();
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.getByTestId("user-message").filter({ hasText: prompt });
await expect
.poll(async () => {
const [viewport, row] = await Promise.all([timeline.boundingBox(), promptRow.boundingBox()]);
return viewport && row ? row.y + row.height - viewport.y : Number.POSITIVE_INFINITY;
})
.toBeLessThan(0);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
}
export function chatOutlineRail(page: Page): Locator {
return page.getByTestId("chat-outline-rail");
@@ -0,0 +1,277 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import type { TestInfo } from "@playwright/test";
import { test as base, expect, type Page } from "../fixtures";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { openSessions } from "./archive-tab";
import { getE2EDaemonPort } from "./daemon-port";
import { assertComposerIdle } from "./rewind-flow";
import { getServerId } from "./server-id";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
import type { SeedDaemonClient } from "./seed-client";
const REPLY = "CODEX_WORKTREE_RESTORE_READY";
const workspaceSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
isolation: z.literal("worktree"),
});
async function callPaseoTool(
mcp: Client,
name: string,
args: Record<string, unknown>,
): Promise<unknown> {
const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 });
expect(result).not.toMatchObject({ isError: true });
return result.structuredContent;
}
async function defaultCodexModel(mcp: Client): Promise<string> {
const result = z
.object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) })
.parse(await callPaseoTool(mcp, "list_models", { provider: "codex" }));
const model = result.models.find((entry) => entry.isDefault);
if (!model) throw new Error("Codex did not advertise a default model");
return model.id;
}
function recordTimelineWire(page: Page): string[] {
const wire: string[] = [];
page.on("websocket", (socket) => {
const recordFrame = (direction: string, message: string) => {
if (/agent.*timeline|fetch_agents|agent_update/.test(message)) {
wire.push(JSON.stringify({ at: Date.now(), direction, message }));
}
};
socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString()));
socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString()));
});
return wire;
}
async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) {
const repo = await createTempGitRepo("codex-worktree-restore-");
const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" });
const wire = recordTimelineWire(page);
let workspace: z.infer<typeof workspaceSchema> | undefined;
let agentId: string | undefined;
function created() {
if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it");
return { ...workspace, agentId };
}
async function expectSelected() {
await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
return {
async createWorktreeWithCodex() {
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`),
),
);
workspace = workspaceSchema.parse(
await callPaseoTool(mcp, "create_workspace", {
isolation: "worktree",
path: repo.path,
baseBranch: "main",
title: "Codex worktree restore reproduction",
}),
);
const agent = z.object({ agentId: z.string() }).parse(
await callPaseoTool(mcp, "create_agent", {
workspaceId: workspace.workspaceId,
provider: `codex/${await defaultCodexModel(mcp)}`,
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
background: true,
}),
);
agentId = agent.agentId;
},
async waitForCompletedReply() {
const target = created();
await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId));
await waitForSidebarHydration(page);
await expect(page.getByTestId("assistant-message")).toContainText(REPLY, {
timeout: 240_000,
});
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { status: "idle", archivedAt: null },
});
await assertComposerIdle({ page });
},
async archiveBeforeBrowserConnects() {
const target = created();
await client.waitForFinish(target.agentId, 240_000);
expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull();
await expect.poll(() => existsSync(target.cwd)).toBe(false);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
},
async connectFreshBrowser() {
await page.goto("/");
},
async archiveWorkspaceFromSidebar() {
await archiveWorkspaceFromSidebar(page, created().workspaceId);
},
async expectWorkspaceAndAgentArchived() {
const target = created();
await expectWorkspaceAbsentFromSidebar(page, target.workspaceId);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false);
await expect
.poll(() => client.fetchWorkspaces())
.not.toMatchObject({
entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]),
});
},
async openArchivedWorkspaceFromHistory() {
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`);
await expect(row).toContainText("Archived");
await row.click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
},
async restoreWorkspace() {
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectWorktreeRestoredWithArchivedAgentSelected() {
await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true);
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await info.attach("after-workspace-restore", {
body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2),
contentType: "application/json",
});
},
async expectArchivedAgentSelectedWithHistory() {
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toContainText(REPLY);
},
async reloadWithoutAgentCache() {
const url = page.url();
const cdp = await page.context().newCDPSession(page);
// Unmount first so an open connection cannot repopulate the cache while it is cleared.
await page.goto("about:blank");
await cdp.send("Storage.clearDataForOrigin", {
origin: new URL(url).origin,
storageTypes: "indexeddb,cache_storage",
});
await cdp.send("Network.clearBrowserCache");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await page.goto(url);
await cdp.detach();
},
async waitForWorkspaceHydration() {
await waitForSidebarHydration(page);
},
async unarchiveAgent() {
await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectIdleAgentWithVisibleComposer(statusTimeout?: number) {
await expect
.poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout })
.toMatchObject({ agent: { status: "idle", archivedAt: null } });
await expectSelected();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await assertComposerIdle({ page });
await info.attach("restored-agent-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("restored-agent-accessibility", {
body: await page.locator("body").ariaSnapshot(),
contentType: "text/plain",
});
},
async dispose() {
const testFailed = info.status !== info.expectedStatus;
const errors: unknown[] = [];
try {
await info.attach("final-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" });
await info.attach("final-agent", {
body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2),
contentType: "application/json",
});
await info.attach("daemon-log", {
body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")),
contentType: "text/plain",
});
} catch (error) {
errors.push(error);
}
const cleanup: Array<() => Promise<unknown>> = [() => mcp.close()];
if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") {
const projectId = workspace?.projectId;
if (projectId) cleanup.push(() => client.removeProject(projectId));
cleanup.push(() => repo.cleanup());
}
for (const release of cleanup) {
try {
await release();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
const error = new AggregateError(errors, "Codex restore fixture teardown failed");
if (!testFailed) throw error;
// Keep the journey failure primary while reporting teardown failures in the test log.
console.error(error);
}
},
};
}
export const test = base.extend<{
codexRestore: Awaited<ReturnType<typeof createCodexRestoreJourney>>;
}>({
// This fixture owns cleanup so a failed reproduction can retain its daemon state.
projectOwnership: async ({ e2eWorkerClient }, provide) => {
void e2eWorkerClient;
await provide();
},
codexRestore: async ({ page, e2eWorkerClient }, provide, info) => {
const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info);
try {
await provide(journey);
} finally {
await journey.dispose();
}
},
});
@@ -28,7 +28,7 @@ export async function openWorkspaceFromCommandCenter(
): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await page.keyboard.press("Enter");
await action(panel, title).click();
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
timeout: 30_000,
});
@@ -0,0 +1,278 @@
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import {
waitForSidebarHydration,
switchWorkspaceViaSidebar,
workspaceDeckEntryLocator,
} from "./workspace-ui";
import { getServerId } from "./server-id";
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
import type { installDaemonWebSocketGate } from "./daemon-websocket-gate";
/** Capture the submitted agent options; optionally stop provisioning for wire-only assertions. */
export async function captureWorkspaceAgentRequest(page: Page, options: { block: boolean }) {
const frames = await loadSessionMessageReaders();
type AgentIntent = NonNullable<WorkspaceCreateRequest["agent"]>;
let resolveRequest!: (agent: AgentIntent) => void;
const request = new Promise<AgentIntent>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((frame) => {
const message = frames.client(frame);
if (message?.type === "workspace.create.request" && message.agent) {
resolveRequest(message.agent);
if (options.block) return;
}
server.send(frame);
});
server.onMessage((frame) => ws.send(frame));
});
return { waitForRequest: () => request };
}
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
const create = page.getByRole("button", { name, exact: true });
await expect(create).toBeEnabled();
// Dispatch the queued clicks in one JS task, before pending state can paint.
// Exercise DOM events rather than calling the app's submit handler directly.
await create.evaluate((button) => {
for (let click = 0; click < 3; click++) {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
});
}
export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const pending = new Set<string>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (
request?.type === "workspace.create.request" ||
request?.type === "agent.create.request"
) {
pending.add(request.requestId);
}
});
socket.on("framereceived", ({ payload }) => {
const response = frames.server(payload);
if (
response?.type === "workspace.create.response" ||
response?.type === "agent.create.response" ||
(response?.type === "status" &&
(response.payload.status === "agent_created" ||
response.payload.status === "agent_create_failed"))
) {
const requestId = response.payload.requestId;
if (typeof requestId === "string") pending.delete(requestId);
}
});
});
return {
async settled() {
await expect.poll(() => pending.size).toBe(0);
},
};
}
export async function retryNextAgentCreation(page: Page) {
const frames = await loadSessionMessageReaders();
const retryIds = new Set<string>();
const results: Array<{ status: string; agentId?: string }> = [];
let repeated = false;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const request = frames.client(frame);
if (!repeated && request?.type === "agent.create.request") {
repeated = true;
for (let attempt = 1; attempt <= 3; attempt++) {
const requestId = attempt === 1 ? request.requestId : `${request.requestId}-${attempt}`;
retryIds.add(requestId);
// Keep the app's operation key and payload; only RPC correlation changes.
server.send(JSON.stringify({ type: "session", message: { ...request, requestId } }));
}
return;
}
server.send(frame);
});
server.onMessage((frame) => {
const response = frames.server(frame);
if (
response?.type === "agent.create.response" &&
retryIds.delete(response.payload.requestId)
) {
results.push({
status: response.payload.error ? "agent_create_failed" : "agent_created",
agentId: response.payload.agent?.id,
});
}
browser.send(frame);
});
});
return {
async completedAgentIds() {
await expect.poll(() => results.length).toBe(3);
expect(results.map((result) => result.status)).toEqual([
"agent_created",
"agent_created",
"agent_created",
]);
return results.map((result) => result.agentId);
},
};
}
export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
return {
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
},
async openAgentDraft() {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
},
async startAnotherDraft() {
await createAgentTabFromMenu(page);
},
async submitPrompt(prompt: string, button = "Send message") {
await fillComposerDraft(page, prompt);
await page.getByRole("button", { name: button, exact: true }).click();
},
async submitRepeatedly(button: string, prompt?: string) {
if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button);
},
async expectPromptVisible(prompt?: string) {
// Inactive agent tabs retain their timeline DOM beside the visible draft.
const rows = page.getByTestId("user-message").filter({ visible: true });
await expect(prompt ? rows.filter({ hasText: prompt }) : rows.first()).toBeVisible();
},
async expectWorkspaceReadyBeforeAgentCompletion() {
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
},
async expectAgentStillStarting() {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
},
async expectAgentCount(count: number) {
await requests.settled();
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === workspaceId,
).length,
)
.toBe(count);
},
async expectAgentTitle(title: string) {
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)?.agent.title,
)
.toBe(title);
},
async evictAndReturnToDraft() {
const draft = page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true })
.first();
const draftTestId = await draft.getAttribute("data-testid");
expect(draftTestId).not.toBeNull();
for (let index = 0; index < WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES; index++) {
const result = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: result.workspace.id,
});
}
await expect(workspaceDeckEntryLocator(page, getServerId(), workspaceId)).toHaveCount(0);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId });
await page
.getByTestId(draftTestId!)
.filter({ visible: true })
.click({ position: { x: 12, y: 13 } });
},
};
}
export function createPromptRetryScenario(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
) {
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return {
holdAcknowledgement() {
gate.holdNextServerMessage("agent.create.response");
},
async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage("agent.create.response");
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
},
async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request");
await gate.drop();
gate.restore();
await expect
.poll(() => gate.getClientRequestCount("fetch_agents_request"))
.toBeGreaterThan(fetches);
},
async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
},
};
}
@@ -64,3 +64,10 @@ function loadAppVersion(): string {
}
return packageJson.version;
}
export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/protocol/messages")> {
const moduleUrl = pathToFileURL(
path.resolve(__dirname, "../../../../../packages/protocol/dist/messages.js"),
).href;
return import(moduleUrl);
}
@@ -21,6 +21,7 @@ interface ClientRequest {
mode?: unknown;
path?: unknown;
agentId?: unknown;
messageId?: unknown;
text?: unknown;
activeTurnBehavior?: unknown;
}
@@ -23,8 +23,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
const button = this.page.getByTestId("sidebar-import-session");
await expect(button).toHaveAccessibleName("Import session");
await button.hover();
await expect(this.page.getByText("Import session", { exact: true })).toBeVisible();
await expect(button).toBeInViewport();
}
async openGlobally() {
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
@@ -49,6 +48,9 @@ export class ImportSessionFlow {
before?: [string, string];
folders?: Array<[string, string]>;
}) {
for (const id of input.first ?? input.before ?? []) {
await expect(this.page.getByTestId(rowTestId(id))).toHaveCount(1);
}
const ids = await this.rowIds();
if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId));
if (input.before) {
@@ -134,15 +134,20 @@ export async function verifyDelayedWorkspaceCreation(
);
if (!upload) throw new Error("The create request did not contain the submitted file");
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
const agents = await client.fetchAgents();
expect(
agents.entries.find((entry) => entry.agent.workspaceId === created.id)?.agent,
).toMatchObject({
cwd: created.workspaceDirectory,
});
expect(delay.agentRequests[0]).toMatchObject({
workspaceId: created.id,
initialPrompt: PROMPT,
config: {
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
thinkingOptionId: "low",
cwd: created.workspaceDirectory,
cwd: repo.path,
},
attachments: [
expect.objectContaining({
@@ -1,8 +1,8 @@
import { expect, type BrowserContext, type Page } from "@playwright/test";
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
import type { CreateAgentRequestMessage, SessionInboundMessage } from "@getpaseo/protocol/messages";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { connectDaemonClient } from "./daemon-client-loader";
import { connectDaemonClient, loadProtocolSchemas } from "./daemon-client-loader";
import { daemonWsRoutePattern } from "./daemon-port";
import { projectEquivalenceViewKey } from "./project-view-key";
import { expectWorkspaceHeader } from "./workspace-ui";
@@ -535,37 +535,35 @@ function parseWebSocketJson(message: WebSocketMessage): unknown {
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseWebSocketJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function getStringField(input: Record<string, unknown>, key: string): string | null {
const value = input[key];
return typeof value === "string" ? value : null;
export async function loadSessionMessageReaders() {
// Use the same ESM module instance as the dynamically loaded daemon client.
const { WSInboundMessageSchema, WSOutboundMessageSchema } = await loadProtocolSchemas();
return {
client(message: WebSocketMessage) {
const parsed = WSInboundMessageSchema.safeParse(parseWebSocketJson(message));
return parsed.success && parsed.data.type === "session" ? parsed.data.message : null;
},
server(message: WebSocketMessage) {
const parsed = WSOutboundMessageSchema.safeParse(parseWebSocketJson(message));
return parsed.success && parsed.data.type === "session" ? parsed.data.message : null;
},
};
}
export interface AgentCreatedDelayControl {
release(): void;
waitForCreateRequest(): Promise<void>;
waitForDelayedCreatedStatus(): Promise<void>;
expectSingleWorkspaceIntent(): void;
}
export async function delayBrowserAgentCreatedStatus(
page: Page,
): Promise<AgentCreatedDelayControl> {
const frames = await loadSessionMessageReaders();
const daemonPortPattern = daemonWsRoutePattern();
const createRequestIds = new Set<string>();
const creationRequests: SessionInboundMessage[] = [];
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
let resolveCreateRequest: (() => void) | null = null;
@@ -581,26 +579,32 @@ export async function delayBrowserAgentCreatedStatus(
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
const sessionMessage = frames.client(message);
if (sessionMessage?.type === "send_agent_message_request")
creationRequests.push(sessionMessage);
if (
sessionMessage?.type === "create_agent_request" ||
sessionMessage?.type === "agent.create.request" ||
sessionMessage?.type === "workspace.create.request"
) {
creationRequests.push(sessionMessage);
createRequestIds.add(sessionMessage.requestId);
resolveCreateRequest?.();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
const sessionMessage = frames.server(message);
const isCreated =
(sessionMessage?.type === "status" && sessionMessage.payload.status === "agent_created") ||
sessionMessage?.type === "agent.create.response" ||
sessionMessage?.type === "workspace.create.response";
if (
isCreated &&
typeof sessionMessage.payload.requestId === "string" &&
createRequestIds.has(sessionMessage.payload.requestId)
) {
resolveDelayedCreatedStatus?.();
if (releaseRequested) {
ws.send(message);
@@ -621,26 +625,41 @@ export async function delayBrowserAgentCreatedStatus(
forward();
}
},
expectSingleWorkspaceIntent() {
expect(creationRequests).toHaveLength(1);
expect(creationRequests[0]).toMatchObject({
type: "workspace.create.request",
agent: { initialPrompt: expect.any(String) },
});
},
waitForCreateRequest: () => createRequestSeen,
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
type AgentCreationIntent = Pick<
CreateAgentRequestMessage,
"config" | "initialPrompt" | "attachments"
>;
export interface WorkspaceCreatedDelayControl {
agentRequests: readonly CreateAgentRequestMessage[];
agentRequests: readonly AgentCreationIntent[];
release(): void;
waitForCreateRequest(): Promise<void>;
}
/**
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
* Holds workspace readiness events and responses so a test can act while the daemon is still
* running `git worktree add` — most importantly, navigate somewhere else.
*/
export async function delayBrowserWorkspaceCreatedResponse(
page: Page,
): Promise<WorkspaceCreatedDelayControl> {
const frames = await loadSessionMessageReaders();
const daemonPortPattern = daemonWsRoutePattern();
const agentRequests: CreateAgentRequestMessage[] = [];
const agentRequests: AgentCreationIntent[] = [];
const creationKeys = new Set<string>();
const createRequestIds = new Set<string>();
const delayedForwards: Array<() => void> = [];
let releaseRequested = false;
@@ -653,29 +672,25 @@ export async function delayBrowserWorkspaceCreatedResponse(
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request")
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
const sessionMessage = frames.client(message);
if (sessionMessage?.type === "create_agent_request") agentRequests.push(sessionMessage);
if (sessionMessage?.type === "workspace.create.request") {
const requestId = getStringField(sessionMessage, "requestId");
if (requestId) {
createRequestIds.add(requestId);
resolveCreateRequest?.();
}
createRequestIds.add(sessionMessage.requestId);
if (sessionMessage.idempotencyKey) creationKeys.add(sessionMessage.idempotencyKey);
if (sessionMessage.agent) agentRequests.push(sessionMessage.agent);
resolveCreateRequest?.();
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload =
sessionMessage?.type === "workspace.create.response" &&
typeof sessionMessage.payload === "object"
? (sessionMessage.payload as Record<string, unknown>)
: null;
const requestId = payload ? getStringField(payload, "requestId") : null;
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
const sessionMessage = frames.server(message);
const isCreation =
(sessionMessage?.type === "workspace.create.response" &&
createRequestIds.has(sessionMessage.payload.requestId)) ||
(sessionMessage?.type === "workspace.create.update" &&
creationKeys.has(sessionMessage.payload.idempotencyKey));
if (isCreation && !releaseRequested) {
delayedForwards.push(() => ws.send(message));
return;
}
@@ -0,0 +1,144 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
const PLUGIN_ID = "timeline-transform-e2e";
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
const CLIENT_SOURCE = `import React, { useState } from "react";
import { Text, View, Pressable } from "react-native";
import { z } from "zod";
function Card({ item }) {
const [count, setCount] = useState(0);
return <View>
<Text accessibilityRole="header">{item.data.label + " " + item.data.phase}</Text>
<Text>{item.data.text}</Text>
<Pressable accessibilityRole="button" onPress={() => setCount(count + 1)}>
<Text>{"Card clicks " + count}</Text>
</Pressable>
</View>;
}
export default function contribute(client) {
for (const itemType of ["assistant_message", "tool_call"]) {
client.addTimelineTransformer({
id: itemType.replaceAll("_", "-"),
query: { itemType },
transform({ item, phase }) {
return { items: [{ type: "plugin", kind: "card", version: 1, data: {
label: item.type === "tool_call" ? "Tool " + item.name : "Assistant",
text: item.type === "tool_call" ? item.callId : item.text,
phase,
} }] };
},
});
}
client.addTimelineRenderer({
kind: "card", version: 1,
schema: z.object({ label: z.string(), text: z.string(), phase: z.enum(["streaming", "complete"]) }),
Component: Card,
});
return () => {};
}`;
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
mode: "assistant" | "tools",
run: (agent: MockAgentWorkspace) => Promise<void>,
): Promise<void> {
info.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
model: "ten-second-stream",
featureValues:
mode === "assistant"
? {
mockStreamingAssistantResponse: ASSISTANT_TEXT,
mockStreamingAssistantIntervalMs: 300,
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
} finally {
await pluginClient.removePlugin(PLUGIN_ID);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await agent.cleanup();
await rm(directory, { recursive: true, force: true });
}
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
export async function interactWithStreamingCard(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await page.getByRole("button", { name: "Card clicks 0", exact: true }).click();
await expect(page.getByText(/Skill header\s+# Instructions/)).toBeVisible();
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await expect(page.getByRole("button", { name: "Card clicks 1", exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectWholeCompletedCard(page: Page): Promise<void> {
await expect(page.getByRole("heading", { name: "Assistant complete", exact: true })).toHaveCount(
1,
);
await expect(page.getByText(ASSISTANT_TEXT, { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectBothConsecutiveTools(page: Page): Promise<void> {
// read and grep are consecutive source calls in the fixture. Overview used to
// send only the later call to the plugin, so the read card never existed.
const read = page.getByRole("heading", { name: "Tool read complete", exact: true }).first();
await read.scrollIntoViewIfNeeded();
await expect(read).toBeVisible();
const grep = page.getByRole("heading", { name: "Tool grep complete", exact: true }).first();
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
@@ -0,0 +1,98 @@
import { holdAssistantStream } from "./agent-timeline-gate";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
interface StreamingMarkdownAgent extends MockAgentWorkspace {
stream: Awaited<ReturnType<typeof holdAssistantStream>>;
}
export async function withStreamingMarkdown(
page: Page,
testInfo: TestInfo,
run: (agent: StreamingMarkdownAgent) => Promise<void>,
): Promise<void> {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "streaming-markdown-",
title: "Streaming Markdown",
featureValues: {
mockStreamingAssistantResponse:
"**Bold text stays bold** and [Paseo docs](https://example.com/documentation). Done.",
mockStreamingAssistantIntervalMs: 400,
},
});
const stream = await holdAssistantStream(page, agent.agentId);
try {
await openAgentRoute(page, agent);
await stream.waitForInitialTimeline();
await run({ ...agent, stream });
} finally {
stream.release();
await agent.cleanup();
}
}
export async function requestStreamingMarkdown(agent: StreamingMarkdownAgent): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Show the formatted streaming response.");
// Exercise late assertions: the producer finishes before the browser consumes its frames.
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.stream.showThrough("**Bold");
}
export async function expectUnfinishedBold(page: Page): Promise<void> {
const message = page.getByTestId("assistant-message").last();
const bold = message.locator('[data-paseo-markdown-tag="strong"]');
await expect(bold).toContainText("Bold");
await expect(message).not.toContainText("stays bold");
await expect(bold).toHaveCSS("font-weight", "500");
await expect(message).not.toContainText("*");
}
export async function expectUnfinishedLink(
page: Page,
agent: StreamingMarkdownAgent,
testInfo: TestInfo,
): Promise<void> {
const message = page.getByTestId("assistant-message").last();
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
await expect(message).toContainText("Paseo docs");
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
await expect(message).not.toContainText("[");
await expect(message).not.toContainText("https:");
await captureMarkdown(page, testInfo, "unfinished-link");
}
export async function expectFinishedMarkdown(
page: Page,
agent: StreamingMarkdownAgent,
testInfo: TestInfo,
): Promise<void> {
agent.stream.release();
await agent.client.waitForFinish(agent.agentId, 30_000);
await expectCompletedMarkdown(page);
await captureMarkdown(page, testInfo, "completed-markdown");
}
export async function expectReloadedMarkdown(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
await expectCompletedMarkdown(page);
}
async function captureMarkdown(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await testInfo.attach(name, {
body: await page.screenshot({ path: testInfo.outputPath(`${name}.png`) }),
contentType: "image/png",
});
}
async function expectCompletedMarkdown(page: Page): Promise<void> {
const message = page.getByTestId("assistant-message").last();
await expect(message).toHaveText("Bold text stays bold and Paseo docs. Done.");
await expect(
message.getByRole("link", { name: "Paseo docs" }).and(message.locator("a")),
).toHaveAttribute("href", "https://example.com/documentation");
await expect(message.locator('[data-paseo-markdown-tag="strong"]')).toHaveCSS(
"font-weight",
"500",
);
}
@@ -402,8 +402,15 @@ export async function expectStableHistoryStartGutter(page: Page): Promise<void>
export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const row = timeline.getByTestId("user-message").filter({ hasText: prompt });
await row.scrollIntoViewIfNeeded();
await expect(row).toBeVisible();
const [viewport, target] = await Promise.all([timeline.boundingBox(), row.boundingBox()]);
if (!viewport || !target) {
throw new Error(`Expected a rendered timeline prompt for ${prompt}`);
}
// Programmatic scrolling does not express the user's intent to stop following output.
await timeline.hover();
await page.mouse.wheel(0, target.y - viewport.y - (viewport.height - target.height) / 2);
await expect(row).toBeInViewport({ ratio: 1 });
await waitForTimelineGeometryToSettle(page);
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
.toBeGreaterThan(HISTORY_START_THRESHOLD_PX);
@@ -18,12 +18,11 @@ export interface TempDirectory {
}
/**
* The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp →
* /private/tmp) so fixture paths match the daemon's resolved paths; on Windows
* `/tmp` doesn't exist, so fall back to the OS temp dir.
* Honor the OS temp directory (including TMPDIR), resolving symlinks so fixture
* paths match the daemon's resolved paths.
*/
export async function resolveTempRoot(): Promise<string> {
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
return await realpath(tmpdir());
}
async function configureRemote(input: {
@@ -41,7 +41,7 @@ export interface ChatOutline {
prompts: ChatOutlinePrompt[];
activePrompt: ActivePromptSource;
jumpToPrompt: (seq: number) => void;
reportReadingPosition: (rowId: string | null) => void;
reportReadingPosition: (seq: number | null) => void;
}
export function useChatOutline({
@@ -59,7 +59,7 @@ export function useChatOutline({
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
const [activePrompt] = useState(createActivePromptPublisher);
const readingRowIdRef = useRef<string | null>(null);
const readingSeqRef = useRef<number | null>(null);
const nextJumpRequestIdRef = useRef(0);
const nextIndexRequestIdRef = useRef(0);
const loadedItems = useMemo(() => [...tail, ...(head ?? NO_STREAM_ITEMS)], [head, tail]);
@@ -107,26 +107,21 @@ export function useChatOutline({
};
}, [agentId, enabled, serverId, timelineEpoch, latestPromptSeq]);
// The transcript names the row it is showing; the outline turns that into a prompt using the
// complete index, so unloaded rows never have to exist in the DOM to be marked.
// The transcript resolves display rows (including Markdown blocks and plugin cards) to
// timeline positions. The outline uses the complete index, including unloaded prompts.
const publishActivePrompt = useStableEvent(() => {
const rowId = readingRowIdRef.current;
const anchorSeq =
rowId === null
? null
: (loadedItems.find((item) => item.id === rowId)?.timelineCursor?.seq ?? null);
activePrompt.publish(resolveActivePromptSeq(prompts, anchorSeq));
activePrompt.publish(resolveActivePromptSeq(prompts, readingSeqRef.current));
});
const reportReadingPosition = useStableEvent((rowId: string | null) => {
readingRowIdRef.current = rowId;
const reportReadingPosition = useStableEvent((seq: number | null) => {
readingSeqRef.current = seq;
publishActivePrompt();
});
useEffect(() => {
nextJumpRequestIdRef.current += 1;
setPendingJump(null);
readingRowIdRef.current = null;
readingSeqRef.current = null;
activePrompt.publish(null);
}, [activePrompt, agentId, timelineEpoch]);
@@ -134,7 +129,7 @@ export function useChatOutline({
// who never scrolls would otherwise sit on an unmarked rail.
useEffect(() => {
publishActivePrompt();
}, [loadedItems, prompts, publishActivePrompt]);
}, [prompts, publishActivePrompt]);
useEffect(() => {
if (pendingJump === null) return;
@@ -0,0 +1,351 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import { runPluginClientBundle, type PluginClientRuntime } from "@/plugins/evaluate";
import type { InstalledPlugin } from "@/plugins/types";
import {
applyStreamEvent,
hydrateStreamState,
type StreamItem,
type ToolCallItem,
} from "@/types/stream";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import { createStreamPresentation } from "./presentation";
const runtime = {
paseo: {},
async rpc() {},
openSettings() {},
openSurface() {},
openPanel() {},
addHeaderButton() {
return { update() {}, remove() {} };
},
addComposerPill() {
return { update() {}, remove() {} };
},
} as unknown as PluginClientRuntime;
function installProbe(
itemType: "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
return { default: function(client) {
client.addTimelineTransformer({
id: "probe",
query: { itemType: ${JSON.stringify(itemType)} },
transform: function({ item, phase }) {
if (!(${condition})) return undefined;
return {
items: [{
type: "plugin",
kind: "probe",
version: 1,
data: item.type === "tool_call"
? { callId: item.callId, name: item.name, status: item.status,
detail: item.detail, ...(item.metadata ? { metadata: item.metadata } : {}), error: item.error, phase: phase }
: { text: item.text, phase: phase }
}]
};
}
});
return function() {};
} };
})`;
const evaluated = runPluginClientBundle("probe-plugin", clientBundle, runtime);
return {
...evaluated,
serverId: "host-1",
clientBundle,
lifetime: new AbortController(),
queryClient: new QueryClient(),
};
}
function installedTransform(plugin: InstalledPlugin): TimelineItemTransform {
return (input) => transformTimelineItem({ ...input, plugins: [plugin] });
}
function toolCall(callId: string, name: string): ToolCallItem {
return {
kind: "tool_call",
id: `tool-${callId}`,
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId,
name,
status: "completed",
error: null,
detail: { type: "unknown", input: { callId }, output: { callId } },
},
},
};
}
function pluginData(items: StreamItem[]): unknown[] {
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
}
const presentationOptions = { level: "overview" as const, isTurnActive: true };
function streamHarness(transform?: TimelineItemTransform) {
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
const present = createStreamPresentation();
return {
send(event: AgentStreamEventPayload) {
state = applyStreamEvent({ ...state, event, timestamp: new Date(1000) });
return this.render();
},
render(nextTransform = transform) {
return present({ ...presentationOptions, ...state, transform: nextTransform });
},
source: () => state,
};
}
function assistant(text: string, messageId = "message-1"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: { type: "assistant_message", messageId, text },
};
}
function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[] {
return [...result.tail, ...result.head];
}
describe("stream presentation through installed plugins", () => {
it("offers every source tool call to an installed transformer in Overview mode", () => {
const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")];
const rendered = createStreamPresentation()({
...presentationOptions,
tail: calls,
head: [],
transform: installedTransform(installProbe("tool_call")),
});
expect(pluginData([...rendered.tail, ...rendered.head])).toMatchObject([
{ callId: "call-1", name: "bash", phase: "complete" },
{ callId: "call-2", name: "read", phase: "complete" },
]);
});
it.each([
{ type: "turn_completed", provider: "claude" },
{ type: "turn_canceled", provider: "claude", reason: "test cancellation" },
{ type: "turn_failed", provider: "claude", error: "test failure" },
] satisfies AgentStreamEventPayload[])(
"streams accumulated assistant text with one identity through $type",
(completion) => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
const first = rows(harness.send(assistant("Header")));
const fullText = "Header\n\n\n# Body\n\n```ts\nconst x = 1;\n\nconst y = 2;\n```\n";
const live = rows(harness.send(assistant(fullText.slice("Header".length))));
const completed = rows(harness.send(completion));
expect(pluginData(first)).toEqual([{ text: "Header", phase: "streaming" }]);
expect(pluginData(live)).toEqual([{ text: fullText, phase: "streaming" }]);
expect(pluginData(completed)).toEqual([{ text: fullText, phase: "complete" }]);
expect(live.map((item) => item.id)).toEqual(first.map((item) => item.id));
expect(completed.map((item) => item.id)).toEqual(first.map((item) => item.id));
expect(rows(harness.source())).toMatchObject([{ kind: "assistant_message", text: fullText }]);
},
);
it("finishes one message before streaming a different message ID", () => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
harness.send(assistant("First\n\nBody", "first"));
const result = harness.send(assistant("Second", "second"));
expect(pluginData(rows(result))).toEqual([
{ text: "First\n\nBody", phase: "complete" },
{ text: "Second", phase: "streaming" },
]);
expect(new Set(rows(result).map((item) => item.id)).size).toBe(2);
});
it("lets a plugin claim the accumulated message after earlier text used native Markdown", () => {
const harness = streamHarness();
harness.send(assistant("Header\n\nBody"));
const transform = installedTransform(
installProbe("assistant_message", 'item.text.includes("Body")'),
);
expect(pluginData(rows(harness.render(transform)))).toEqual([
{ text: "Header\n\nBody", phase: "streaming" },
]);
expect(rows(harness.render(transform))).toHaveLength(1);
expect(rows(harness.render()).map((item) => item.kind)).toEqual([
"assistant_message",
"assistant_message",
]);
});
it("keeps completed native blocks stable while the last block grows and finishes", () => {
const harness = streamHarness();
const first = harness.send(assistant("Intro\n\n![Image](image.png)"));
const growing = harness.send(assistant("\n\nAfter\n"));
const last = harness.send(assistant("- item"));
const completed = harness.send({ type: "turn_completed", provider: "claude" });
expect(first.tail).toMatchObject([{ text: "Intro", blockIndex: 0 }]);
expect(first.head).toMatchObject([{ text: "![Image](image.png)", blockIndex: 1 }]);
expect(growing.tail[0]).toBe(first.tail[0]);
expect(growing.tail[1]).toBe(first.head[0]);
expect(last.tail).toBe(growing.tail);
expect(last.head).toMatchObject([{ text: "After\n- item", blockIndex: 2 }]);
expect(completed.tail).toEqual(rows(last));
expect(completed.tail[2]).toBe(last.head[0]);
expect(completed.head).toEqual([]);
});
it("keeps blank lines inside an open native code fence", () => {
const harness = streamHarness();
harness.send(assistant("Intro\n\n```ts\nconst a = 1;"));
const result = harness.send(assistant("\n\nconst b = 2;"));
expect(result.tail).toMatchObject([{ text: "Intro" }]);
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
it("leaves fetched native Markdown intact, including cross-paragraph references", () => {
const source = hydrateStreamState([
{
event: assistant("[Link][docs]\n\n[docs]: https://example.com"),
timestamp: new Date(1000),
},
]);
const result = createStreamPresentation()({
...presentationOptions,
tail: source,
head: [],
transform: undefined,
});
expect(result.tail).toEqual(source);
expect(result.tail[0]).toBe(source[0]);
});
it("continues to stream inline reasoning with a stable plugin row", () => {
const harness = streamHarness(installedTransform(installProbe("reasoning")));
const first = harness.send({
type: "timeline",
provider: "claude",
item: { type: "reasoning", text: "First" },
});
const second = harness.send({
type: "timeline",
provider: "claude",
item: { type: "reasoning", text: "\n\nSecond" },
});
const done = harness.send({ type: "turn_completed", provider: "claude" });
expect(pluginData(rows(second))).toEqual([{ text: "First\n\nSecond", phase: "streaming" }]);
expect(pluginData(rows(done))).toEqual([{ text: "First\n\nSecond", phase: "complete" }]);
expect(rows(first)[0]?.id).toBe(rows(done)[0]?.id);
});
it("groups only unclaimed tools and passes unchanged tool details to the callback", () => {
const calls = [
toolCall("call-1", "read"),
toolCall("call-2", "read"),
toolCall("call-3", "bash"),
];
const result = createStreamPresentation()({
...presentationOptions,
isTurnActive: false,
tail: calls,
head: [],
transform: installedTransform(installProbe("tool_call", 'item.name === "bash"')),
});
expect(result.groupsByHostId.get(result.tail[0]!.id)?.run.calls).toEqual(calls.slice(0, 2));
expect(pluginData(rows(result))).toEqual([
{
callId: "call-3",
name: "bash",
status: "completed",
error: null,
detail: { type: "unknown", input: { callId: "call-3" }, output: { callId: "call-3" } },
phase: "complete",
},
]);
});
it.each([
{ status: "completed", error: null },
{ status: "failed", error: { message: "Command failed" } },
{ status: "canceled", error: null },
] as const)("keeps a tool card's identity and full payload through $status", (completion) => {
const harness = streamHarness(installedTransform(installProbe("tool_call")));
const source = {
type: "tool_call" as const,
callId: "call-1",
name: "bash",
detail: { type: "shell" as const, command: "pwd", cwd: "/repo" },
metadata: { sequence: 1 },
};
const running = rows(
harness.send({
type: "timeline",
provider: "claude",
item: {
...source,
status: "running",
error: null,
},
}),
);
const terminalItem = { ...source, ...completion };
const terminal = rows(
harness.send({
type: "timeline",
provider: "claude",
item: {
...terminalItem,
},
}),
);
expect(pluginData(running)).toMatchObject([
{ callId: "call-1", status: "running", phase: "streaming" },
]);
expect(pluginData(terminal)).toEqual([
{
callId: source.callId,
name: source.name,
detail: source.detail,
metadata: source.metadata,
phase: "complete",
status: completion.status,
error: terminalItem.error,
},
]);
expect(terminal.map((item) => item.id)).toEqual(running.map((item) => item.id));
});
it("offers one complete live source assistant message to an installed transformer", () => {
const sourceText =
'[IMPORTANT: User invoked the "diagnose" skill; follow its instructions. Full skill below.]\n\n# Diagnose\n\nFind the root cause.';
const event = {
type: "timeline" as const,
provider: "opencode" as const,
item: { type: "assistant_message" as const, messageId: "message-1", text: sourceText },
};
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const transform = installedTransform(installProbe("assistant_message"));
const present = createStreamPresentation();
const fetched = hydrateStreamState([{ event, timestamp }]);
const history = present({ ...presentationOptions, tail: fetched, head: [], transform });
expect(pluginData(history.tail)).toEqual([{ text: sourceText, phase: "complete" }]);
const stream = applyStreamEvent({
tail: [],
head: [],
event,
timestamp,
});
const rendered = present({ ...presentationOptions, ...stream, transform });
expect(pluginData([...rendered.tail, ...rendered.head])).toEqual([
{ text: sourceText, phase: "streaming" },
]);
});
});
@@ -0,0 +1,138 @@
import type { AssistantMessageItem, StreamItem } from "@/types/stream";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
interface PresentationInput {
tail: StreamItem[];
head: StreamItem[];
transform: TimelineItemTransform | undefined;
level: ToolCallDetailLevel;
isTurnActive: boolean;
}
function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
return previous.length === next.length && previous.every((item, index) => item === next[index])
? previous
: next;
}
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
export function createStreamPresentation() {
const blocksBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
let liveSources = new Map<string, AssistantMessageItem>();
let historySource: StreamItem[] | undefined;
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
let displayTail: StreamItem[] = [];
let displayHistory: StreamItem[] | undefined;
let promotedRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
function nativeBlocks(item: StreamItem): StreamItem[] {
if (item.kind !== "assistant_message") return [item];
const cached = blocksBySource.get(item);
if (cached) return cached;
const previousSource = liveSources.get(item.id);
const previous = previousSource && blocksBySource.get(previousSource);
// Parse only the growing last block on append, as the old live reducer did.
// Canonical text replacements are parsed afresh instead of joining fragments.
const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
let prefix: AssistantMessageItem[] = [];
let growingText = item.text;
if (isAppend) {
prefix = previous.slice(0, -1);
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
const textBlocks = splitMarkdownBlocks(growingText);
if (prefix.length + textBlocks.length < 2) {
blocksBySource.set(item, [item]);
return [item];
}
const blocks = [...prefix];
for (const [offset, text] of textBlocks.entries()) {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
// Completed display blocks keep their first cursor and object identity while
// the source message grows. The source itself always retains the latest cursor.
if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) {
blocks.push(existing);
continue;
}
blocks.push({
...item,
id,
blockGroupId: item.id,
blockIndex: index,
text: blockText,
});
}
blocksBySource.set(item, blocks);
return blocks;
}
return (input: PresentationInput) => {
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
(item) => {
// Preserve live block identities at completion; fetched native Markdown
// stays whole so links and other cross-block constructs keep their context.
if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item];
return [item];
},
);
historySource = input.tail;
historyTransform = input.transform;
}
const head: StreamItem[] = [];
const promoted: StreamItem[] = [];
const nextLiveSources = new Map<string, AssistantMessageItem>();
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
const blocks = nativeBlocks(item);
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
if (item.kind === "assistant_message" && blocks.length > 1) {
promoted.push(...blocks.slice(0, -1));
head.push(blocks[blocks.length - 1]!);
} else {
head.push(...blocks);
}
}
liveSources = nextLiveSources;
const nextPromoted = retainItems(promotedRows, promoted);
if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
displayTail = historyRows;
if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
displayHistory = historyRows;
promotedRows = nextPromoted;
}
if (preparedTail !== displayTail || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, displayTail);
preparedTail = displayTail;
preparedLevel = input.level;
}
return projectToolCallDetailLevel({
level: input.level,
tail: displayTail,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
});
};
}
+28 -33
View File
@@ -61,10 +61,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { QuestionFormCard } from "@/components/question-form-card";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -108,7 +105,6 @@ import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useStreamHistoryWindow } from "./use-stream-history-window";
import { PluginTimelineItemView, useInstalledTimelineTransform } from "@/plugins/timeline";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -537,35 +533,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
const isTurnActive = effectiveTurnPresentation.isActive;
// Keep retained history outside the 48ms live-head flush path.
const preparedToolCallHistory = useMemo(
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
[effectiveStreamItems, toolCallDetailLevel],
);
const projectedToolCalls = useMemo(
const presentStream = useMemo(() => createStreamPresentation(), []);
const presentation = useMemo(
() =>
projectToolCallDetailLevel({
level: toolCallDetailLevel,
presentStream({
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
preparedHistory: preparedToolCallHistory,
transform: transformTimelineItem,
level: toolCallDetailLevel,
isTurnActive,
}),
[
effectiveStreamHead,
presentStream,
effectiveStreamItems,
isTurnActive,
preparedToolCallHistory,
effectiveStreamHead,
transformTimelineItem,
toolCallDetailLevel,
isTurnActive,
],
);
const projectedPlugins = useMemo(
() => ({
tail: projectPluginTimelineItems(projectedToolCalls.tail, transformTimelineItem),
head: projectPluginTimelineItems(projectedToolCalls.head, transformTimelineItem),
}),
[projectedToolCalls.head, projectedToolCalls.tail, transformTimelineItem],
);
const {
start: historyWindowStart,
hasLocalHistory,
@@ -573,7 +559,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: projectedPlugins.tail,
items: presentation.tail,
loadRemoteOlder,
});
const isLoadingOlder = remoteIsLoadingOlder;
@@ -584,8 +570,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return buildAgentStreamRenderModel({
isTurnActive,
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
tail: projectedPlugins.tail,
head: projectedPlugins.head,
tail: presentation.tail,
head: presentation.head,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
historyStart: historyWindowStart,
@@ -593,8 +579,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [
isMobile,
isTurnActive,
projectedPlugins.head,
projectedPlugins.tail,
presentation.head,
presentation.tail,
effectiveTurnPresentation.startedAt,
historyWindowStart,
]);
@@ -823,7 +809,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
// Read through a stable event so live group updates do not change the renderer identity
// every tick; history hosts whose group changed are revised through `historyRowRevision`.
const getToolCallGroup = useStableEvent((hostId: string) =>
projectedToolCalls.groupsByHostId.get(hostId),
presentation.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
@@ -1017,6 +1003,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return itemById;
}, [streamLayout.liveHead]);
const handleReadingPositionChange = useStableEvent((rowId: string | null) => {
const row =
rowId === null
? undefined
: (layoutHistoryItemById.get(rowId) ?? layoutLiveHeadItemById.get(rowId));
chatOutline.reportReadingPosition(row?.item.timelineCursor?.seq ?? null);
});
const renderHistoryRow = useCallback(
(item: StreamItem) =>
renderHistoryStreamItem({
@@ -1080,11 +1074,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
expandedInlineToolCallIds.size === 0;
const historyRowRevision = useMemo(
() => ({
contentById: projectedToolCalls.historyGroupUpdatesByHostId,
contentById: presentation.historyGroupUpdatesByHostId,
displayStateById: expandedToolCallGroupIds,
globalDisplayState: isMobile,
}),
[expandedToolCallGroupIds, isMobile, projectedToolCalls.historyGroupUpdatesByHostId],
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
);
return (
@@ -1103,7 +1097,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onReadingPositionChange: chatOutline.reportReadingPosition,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
@@ -1565,6 +1559,7 @@ function PermissionRequestCard({
title={title}
description={description}
text={planMarkdown}
outcome="pending"
footer={footer}
testID="permission-plan-card"
disableOuterSpacing
@@ -115,8 +115,7 @@ function HostWorkspaceRouteContent() {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -191,16 +190,10 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
}
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -272,7 +265,6 @@ function WorkspaceDeck({
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -286,13 +278,11 @@ function WorkspaceDeckEntry({
selection,
active,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -323,7 +313,6 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
recoveryAgentId={active ? recoveryAgentId : null}
/>
</RetainedPanel>
);
@@ -2,16 +2,75 @@ import { describe, expect, it } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
function createStore() {
const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
return { fileSystem, store };
}
describe("local file attachment store", () => {
it("writes raw byte sources directly to the managed file path", async () => {
const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
it.each([
"data:image/png;base64,AAECAwQF+/8=",
"data:image/png;charset=utf-8;base64,AAEC\nAwQF+/8=",
])("preserves binary image bytes from %s", async (dataUrl) => {
const { fileSystem, store } = createStore();
const attachment = await store.save({
id: "clipboard",
fileName: "clipboard.png",
source: { kind: "data_url", dataUrl },
});
expect(attachment).toMatchObject({
mimeType: "image/png",
fileName: "clipboard.png",
byteSize: 8,
storageType: "native-file",
storageKey: "/cache/preview-assets/clipboard.png",
});
expect(fileSystem.files.get("file:///cache/preview-assets/clipboard.png")).toEqual(
new Uint8Array([0, 1, 2, 3, 4, 5, 251, 255]),
);
await expect(store.encodeBase64({ attachment })).resolves.toBe("AAECAwQF+/8=");
await expect(store.resolvePreviewUrl({ attachment })).resolves.toBe(
"file:///cache/preview-assets/clipboard.png",
);
});
it.each([
"not-a-data-url",
"data:image/png,hello",
"data:image/png;base64,",
"data:image/png;base64,%%%%",
"data:image/png;base64,A",
])("rejects malformed image data without writing a file: %s", async (dataUrl) => {
const { fileSystem, store } = createStore();
await expect(store.save({ source: { kind: "data_url", dataUrl } })).rejects.toThrow();
expect(fileSystem.files.size).toBe(0);
});
it("copies file URI images without altering the source", async () => {
const { fileSystem, store } = createStore();
const bytes = new Uint8Array([0, 1, 254, 255]);
fileSystem.setFile("file:///keyboard/image.png", bytes);
const attachment = await store.save({
id: "keyboard",
mimeType: "image/png",
source: { kind: "file_uri", uri: "file:///keyboard/image.png" },
});
expect(fileSystem.files.get("file:///cache/preview-assets/keyboard.png")).toEqual(bytes);
expect(fileSystem.files.get("file:///keyboard/image.png")).toEqual(bytes);
expect(attachment.byteSize).toBe(bytes.length);
});
it("writes raw byte sources directly to the managed file path", async () => {
const { fileSystem, store } = createStore();
const attachment = await store.save({
id: "preview_8_test",
mimeType: "image/png",
@@ -44,9 +44,10 @@ async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): P
await fileSystem.makeDirectory(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
const response = await fetch(dataUrl);
return new Uint8Array(await response.arrayBuffer());
function dataUrlToBytes(dataUrl: string): Uint8Array {
// React Native's fetch cannot read data URLs. Decode the embedded bytes locally.
const { base64 } = parseDataUrl(dataUrl);
return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
}
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
@@ -70,7 +71,7 @@ async function writeFromSource(input: {
let bytes: Uint8Array;
if (input.source.kind === "data_url") {
bytes = await dataUrlToBytes(input.source.dataUrl);
bytes = dataUrlToBytes(input.source.dataUrl);
} else if (input.source.kind === "blob") {
bytes = await blobToBytes(input.source.blob);
} else {
@@ -19,6 +19,7 @@ import {
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
@@ -42,6 +43,11 @@ export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/ada
// match this padding.
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
// The header's close button grows outward from its glyph, so the glyph's
// trailing rail is the content inset plus this padding. Rows whose trailing
// glyph should line up with the X must reach the same rail.
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
export interface SheetHeaderSearch {
onChange: (value: string) => void;
onFocus?: () => void;
@@ -71,6 +77,9 @@ const SCROLL_CONTENT_GROW = { flexGrow: 1 };
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
const styles = StyleSheet.create((theme) => ({
nativeModalRoot: {
flex: 1,
},
desktopOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.55)",
@@ -124,7 +133,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
},
searchRow: {
@@ -724,7 +733,10 @@ export function AdaptiveModalSheet({
onDismiss={notifyNativeModalDismiss}
hardwareAccelerated
>
{desktopContent}
{/* Android Modal opens a separate window outside the app's gesture root. */}
<GestureHandlerRootView style={styles.nativeModalRoot}>
{desktopContent}
</GestureHandlerRootView>
</Modal>
);
}
@@ -4,7 +4,7 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
@@ -20,6 +20,7 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
useIsCompactFormFactor,
} from "@/constants/layout";
import { ChangesSurface } from "@/git/diff-pane";
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
@@ -38,9 +39,17 @@ import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import {
EXPLORER_TAB_RAIL_INSET,
explorerSidebarCloseButtonLayout,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
const ThemedX = withUnistyles(X);
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
@@ -279,8 +288,12 @@ function ExplorerTabButton({
testID,
children,
}: ExplorerTabButtonProps) {
const isCompact = useIsCompactFormFactor();
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
const tabStyle = useMemo(() => [styles.tab, active && styles.tabActive], [active]);
const tabStyle = useMemo(
() => [styles.tab(isCompact), active && styles.tabActive],
[active, isCompact],
);
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
return (
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
@@ -315,6 +328,17 @@ function ExplorerSidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact);
const closeButtonStyle = useMemo(
() => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }),
[closeButtonLayout.size],
);
// The close glyph shares the trailing rail with the toolbar rows below it.
const headerRightSectionStyle = useMemo(
() => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }],
[closeButtonLayout.trailingPadding],
);
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
serverId,
cwd: workspaceRoot,
@@ -341,14 +365,9 @@ function ExplorerSidebarContent({
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<WindowChromeSafeArea
placement="inline"
horizontalPadding={theme.spacing[2]}
style={styles.header}
testID="explorer-header"
>
<WindowChromeSafeArea placement="inline" style={styles.header} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
<View style={styles.tabsContainer(isCompact)}>
{isGit && (
<ExplorerTabButton
tab="changes"
@@ -383,24 +402,18 @@ function ExplorerSidebarContent({
</ExplorerTabButton>
)}
</View>
<View style={styles.headerRightSection}>
<Pressable
<View style={headerRightSectionStyle}>
<ToolbarButton
compact={isCompact}
style={closeButtonStyle}
hitSlop={closeButtonLayout.hitSlop}
label={t("workspace.tabs.explorerSidebar.close")}
onPress={onClose}
style={styles.closeButton}
testID="explorer-close"
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={18}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<ThemedX size={closeButtonLayout.iconSize} uniProps={mutedIconColorMapping} />
</ToolbarButton>
</View>
</WindowChromeSafeArea>
@@ -520,18 +533,21 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabsContainer: {
tabsContainer: (isCompact: boolean) => ({
flexDirection: "row",
gap: theme.spacing[1],
},
tab: {
// With the tab's own horizontal padding this puts the label on the pane's
// leading rail, the same way the desktop tab rail does.
paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2],
}),
tab: (isCompact: boolean) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
}),
tabActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
@@ -551,10 +567,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
contentArea: {
flex: 1,
minHeight: 0,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
explorerSidebarCloseButtonLayout,
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
@@ -20,3 +21,20 @@ describe("Explorer sidebar layout", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
});
});
describe("Explorer close action", () => {
it("preserves the wide-native touch target and glyph rail", () => {
const layout = explorerSidebarCloseButtonLayout(false);
expect(layout.size).toBe(34);
expect(layout.iconSize).toBe(18);
expect(layout.size + layout.hitSlop * 2).toBe(50);
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16);
});
it("keeps the compact close glyph on the pane rail", () => {
const layout = explorerSidebarCloseButtonLayout(true);
expect(layout.size).toBe(32);
expect(layout.iconSize).toBe(18);
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8);
});
});
@@ -1,3 +1,28 @@
import {
paneContentToolbarIconSize,
paneContentToolbarTrailingPadding,
} from "@/components/ui/pane-content-toolbar";
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
export function explorerSidebarCloseButtonLayout(compact: boolean) {
// The inline native dock retains its padded close action and touch slop.
if (!compact) {
return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 };
}
return {
size: smallIconButtonChromeFrameSize(compact),
iconSize: paneContentToolbarIconSize(compact),
hitSlop: 0,
trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"),
};
}
/**
* Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts
* the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`).
*/
export const EXPLORER_TAB_RAIL_INSET = 4;
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
const MIN_WORKSPACE_BODY_WIDTH = 400;
@@ -1220,7 +1220,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<PaneContentToolbar
style={[styles.paneHeader, { paddingRight: paneContentToolbarTrailingPadding(isCompact) }]}
style={[
styles.paneHeader,
{ paddingRight: paneContentToolbarTrailingPadding(isCompact, "glyph") },
]}
testID="files-pane-header"
>
<Pressable
+10 -1
View File
@@ -1501,6 +1501,10 @@ export const AssistantMessage = memo(function AssistantMessage({
}: AssistantMessageProps) {
const { t } = useTranslation();
const markdownParser = useMemo(createAssistantMarkdownParser, []);
const streamingMarkdownParser = useMemo(
() => createAssistantMarkdownParser({ streaming: true }),
[],
);
const renderedMessage = useMemo(() => capAssistantMessageForRender(message), [message]);
// Paint a paced prefix while the turn is streaming so text arrives at a steady
// rate instead of in whatever lumps the daemon's coalescing window produced.
@@ -1982,7 +1986,11 @@ export const AssistantMessage = memo(function AssistantMessage({
<MemoizedMarkdownBlock
text={block}
rules={markdownRules}
parser={markdownParser}
parser={
phase === "streaming" && index === keyedBlocks.length - 1
? streamingMarkdownParser
: markdownParser
}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
@@ -3159,6 +3167,7 @@ export const ToolCall = memo(function ToolCall({
return (
<PlanCard
text={effectiveDetail.text}
outcome={presentation.planOutcome}
testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing}
/>
+91 -40
View File
@@ -1,9 +1,19 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import Markdown, { type ASTNode } from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import {
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { type ASTNode } from "react-native-markdown-display";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { ChevronRight } from "lucide-react-native";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { createMarkdownParser } from "@/utils/markdown-parser";
@@ -180,54 +190,81 @@ function createPlanMarkdownRules() {
};
}
export function PlanCard({
title,
description,
text,
footer,
disableOuterSpacing = false,
testID,
}: {
export type PlanOutcome = "pending" | "approved" | "rejected" | "canceled";
interface PlanCardProps {
title?: string;
description?: string;
text: string;
outcome?: PlanOutcome;
footer?: ReactNode;
disableOuterSpacing?: boolean;
testID?: string;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
const resolvedTitle = title ?? t("agentStream.permission.plan");
}
const ThemedChevron = withUnistyles(ChevronRight);
const chevronColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const markdownRules = createPlanMarkdownRules();
export function PlanCard(props: PlanCardProps) {
// A resolution starts its own presentation state; subsequent taps stay local.
return <PlanCardContent key={props.outcome ?? "proposed"} {...props} />;
}
function PlanCardContent({
title,
description,
text,
outcome,
footer,
disableOuterSpacing = false,
testID,
}: PlanCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(outcome !== "rejected" && outcome !== "canceled");
const labels = {
pending: title ?? t("agentStream.permission.plan"),
rejected: t("agentStream.permission.rejectedPlan"),
approved: t("agentStream.permission.approvedPlan"),
canceled: t("agentStream.permission.canceledPlan"),
};
const resolvedTitle = labels[outcome ?? "pending"];
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
const webExpandedState = useMemo(
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
[expanded],
);
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
const containerStyle = useMemo(
() => [
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[disableOuterSpacing, theme.colors.surface1, theme.colors.border],
() => [styles.container, disableOuterSpacing && styles.containerCompact],
[disableOuterSpacing],
);
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const descriptionStyle = useMemo(
() => [styles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
const chevronStyle = useMemo(
() => [styles.chevron, expanded && styles.chevronExpanded],
[expanded],
);
return (
<View testID={testID} style={containerStyle}>
<Text style={titleStyle}>{resolvedTitle}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules} markdownit={planMarkdownParser}>
{text}
</Markdown>
<Pressable
{...webExpandedState}
accessibilityRole="button"
accessibilityLabel={resolvedTitle}
accessibilityState={accessibilityState}
onPress={toggleExpanded}
style={styles.header}
>
<View style={chevronStyle}>
<ThemedChevron size={16} uniProps={chevronColor} />
</View>
<Text style={styles.title}>{resolvedTitle}</Text>
</Pressable>
{expanded ? (
<View style={styles.body}>
{description ? <Text style={styles.description}>{description}</Text> : null}
<MarkdownRenderer text={text} rules={markdownRules} markdownit={planMarkdownParser} />
</View>
) : null}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</View>
);
@@ -239,16 +276,30 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
gap: theme.spacing[2],
},
containerCompact: {
marginVertical: 0,
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 24,
},
chevron: {},
chevronExpanded: { transform: [{ rotate: "90deg" }] },
body: { gap: theme.spacing[2] },
title: {
color: theme.colors.foreground,
flexShrink: 1,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 20,
},
+3 -1
View File
@@ -34,6 +34,7 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native";
import {
flip,
@@ -1266,7 +1267,8 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
visible={props.isOpen}
onRequestClose={props.handleClose}
>
{overlay}
{/* Android Modal opens a separate window outside the app's gesture root. */}
<GestureHandlerRootView style={styles.desktopOverlay}>{overlay}</GestureHandlerRootView>
</Modal>
);
}
@@ -0,0 +1,65 @@
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { ICON_SIZE, SPACING } from "@/styles/theme";
const FLOATING_ACTION_BUTTON_SIZE = 56;
const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
/** Space a scrolling surface keeps below its content so the button traps nothing. */
export const FLOATING_ACTION_BUTTON_CLEARANCE =
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
export interface FloatingActionButtonProps {
icon: LucideIcon;
/** Names the action; the button carries no label. */
accessibilityLabel: string;
onPress: () => void;
testID?: string;
}
/**
* The one floating action a scrolling surface can offer. It pins itself to the
* bottom-right of its nearest positioned ancestor, so the ancestor decides what
* the button may cover.
*/
export function FloatingActionButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: FloatingActionButtonProps) {
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
style={styles.button}
highlightStyle={styles.pressed}
testID={testID}
>
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
position: "absolute",
right: FLOATING_ACTION_BUTTON_INSET,
bottom: FLOATING_ACTION_BUTTON_INSET,
width: FLOATING_ACTION_BUTTON_SIZE,
height: FLOATING_ACTION_BUTTON_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
...theme.shadow.md,
},
pressed: {
backgroundColor: theme.colors.interactionHighlight,
},
glyph: {
color: theme.colors.accentForeground,
},
}));
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
import {
paneContentToolbarIconSize,
paneContentToolbarTrailingPadding,
} from "@/components/ui/pane-content-toolbar";
/** Where the last control's painted edge lands, measured from the row's right edge. */
function trailingInkRail(isCompact: boolean, trailing: "glyph" | "framed"): number {
const padding = paneContentToolbarTrailingPadding(isCompact, trailing);
if (trailing === "framed") return padding;
const frameOverhang =
(smallIconButtonChromeFrameSize(isCompact) - paneContentToolbarIconSize(isCompact)) / 2;
return padding + frameOverhang;
}
describe("pane content toolbar trailing rail", () => {
it("lands a bare glyph on the shared rail by letting its hitbox overhang the row", () => {
expect(trailingInkRail(false, "glyph")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(trailingInkRail(true, "glyph")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(paneContentToolbarTrailingPadding(true, "glyph")).toBeLessThan(
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
);
});
it("lands a framed control on the shared rail, because its frame is the ink", () => {
expect(trailingInkRail(false, "framed")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(trailingInkRail(true, "framed")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(paneContentToolbarTrailingPadding(true, "framed")).toBe(
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
);
});
it("pads a framed control more than a glyph, since the glyph's frame is bigger on compact", () => {
expect(paneContentToolbarTrailingPadding(true, "framed")).toBeGreaterThan(
paneContentToolbarTrailingPadding(true, "glyph"),
);
expect(paneContentToolbarTrailingPadding(false, "framed")).toBeGreaterThan(
paneContentToolbarTrailingPadding(false, "glyph"),
);
});
});
@@ -155,8 +155,26 @@ export function paneContentToolbarIconSize(isCompact: boolean): number {
return iconButtonChromeGlyphSize("small", isCompact);
}
/** Keeps a toolbar glyph on the same trailing rail as tree-row glyphs. */
export function paneContentToolbarTrailingPadding(isCompact: boolean): number {
/**
* What the last control in a toolbar row ends with. A `glyph` control paints an icon
* inside a larger invisible hitbox; a `framed` control (bordered trigger, split button)
* paints its own frame, so the frame is the ink.
*/
export type ToolbarTrailingControl = "glyph" | "framed";
/**
* Keeps the last control in a toolbar row on the same trailing rail as tree-row glyphs.
* A bare glyph's hitbox overhangs its ink, so the row pads by less than the rail and lets
* the hitbox run off the edge; a framed control's ink reaches its own edge, so it pads by
* the full rail.
*/
export function paneContentToolbarTrailingPadding(
isCompact: boolean,
trailing: ToolbarTrailingControl,
): number {
if (trailing === "framed") {
return WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
}
const buttonSize = smallIconButtonChromeFrameSize(isCompact);
return (
WORKSPACE_PANE_TRAILING_GLYPH_RAIL - (buttonSize - paneContentToolbarIconSize(isCompact)) / 2
@@ -1,9 +1,10 @@
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { Composer } from "@/composer";
@@ -32,7 +33,6 @@ import type {
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
@@ -85,20 +85,23 @@ function buildChatDraftComposerArgs({
async function callWorkspaceCreation({
creationMethod,
connectedClient,
creationId,
worktreeSlug,
input,
}: {
creationMethod: "create_worktree" | "open_project";
connectedClient: DaemonClient;
input: { cwd: string };
creationId: string;
worktreeSlug: string;
input: { cwd: string; agent?: CreateWorkspaceRequestOptions["agent"] };
}) {
if (creationMethod === "create_worktree") {
return connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
});
}
return connectedClient.createWorkspace({
source: { kind: "directory", path: input.cwd },
idempotencyKey: creationId,
agent: input.agent,
source:
creationMethod === "create_worktree"
? { kind: "worktree", cwd: input.cwd, worktreeSlug }
: { kind: "directory", path: input.cwd },
});
}
@@ -208,7 +211,7 @@ export function WorkspaceSetupDialog() {
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
}, [pendingWorkspaceSetup?.creationId]);
const handleClose = useCallback(() => {
clearWorkspaceSetup();
@@ -249,18 +252,25 @@ export function WorkspaceSetupDialog() {
}, [client, isConnected, t]);
const ensureWorkspace = useCallback(
async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => {
async (input: {
cwd: string;
attachments: MessagePayload["attachments"];
agent?: CreateWorkspaceRequestOptions["agent"];
onAgentCreated?: (agent: AgentSnapshotPayload) => void;
}) => {
if (!pendingWorkspaceSetup) {
throw new Error(t("workspaceSetup.errors.pendingRequired"));
}
if (createdWorkspace) {
if (createdWorkspace && !input.agent) {
return createdWorkspace;
}
const connectedClient = withConnectedClient();
const payload = await callWorkspaceCreation({
creationMethod: pendingWorkspaceSetup.creationMethod,
creationId: pendingWorkspaceSetup.creationId,
worktreeSlug: pendingWorkspaceSetup.worktreeSlug,
connectedClient,
input,
});
@@ -271,6 +281,7 @@ export function WorkspaceSetupDialog() {
);
}
if (payload.agent) input.onAgentCreated?.(payload.agent);
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
if (pendingWorkspaceSetup.creationMethod === "open_project") {
@@ -307,8 +318,7 @@ export function WorkspaceSetupDialog() {
try {
setPendingAction("chat");
setErrorMessage(null);
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error(t("workspaceSetup.errors.composerStateRequired"));
}
@@ -322,21 +332,30 @@ export function WorkspaceSetupDialog() {
}),
});
const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceDirectory({
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired"));
let createdAgent: AgentSnapshotPayload | undefined;
const { workspaceId: _workspaceId, ...agentInput } = buildCreateAgentOptions({
composerState,
text,
attachments: wirePayload.attachments,
encodedImages: encodedImages ?? null,
workspaceDirectory: cwd,
workspaceId: "",
provider: composerState.selectedProvider,
});
const agent = await connectedClient.createAgent(
buildCreateAgentOptions({
composerState,
text,
attachments: wirePayload.attachments,
encodedImages: encodedImages ?? null,
workspaceDirectory,
workspaceId: ensuredWorkspace.id,
provider: composerState.selectedProvider,
}),
);
const ensuredWorkspace = await ensureWorkspace({
cwd,
attachments,
agent: {
...agentInput,
clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`,
},
onAgentCreated: (value) => {
createdAgent = value;
},
});
if (!createdAgent) throw new Error("The daemon did not create the requested agent");
const agent = createdAgent;
if (!getIsStillActive()) {
return;
@@ -362,13 +381,13 @@ export function WorkspaceSetupDialog() {
},
[
composerState,
pendingWorkspaceSetup,
getIsStillActive,
navigateAfterCreation,
serverId,
ensureWorkspace,
t,
toast,
withConnectedClient,
supportsForgeSearch,
],
);
+27 -10
View File
@@ -5,12 +5,11 @@ import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import {
createUserMessage,
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
@@ -123,7 +122,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const [machine, dispatch] = useReducer(
const [localMachine, dispatch] = useReducer(
reducer<TDraftAgent>,
initialAttempt,
(attempt): DraftAgentMachineState<TDraftAgent> =>
@@ -135,10 +134,22 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
},
);
const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]);
// Remounts can precede model hydration. Rebuild the preview when its inputs
// arrive, and observe the original request's failure through shared state.
const machine = useMemo<DraftAgentMachineState<TDraftAgent>>(() => {
if (pending?.lifecycle === "abandoned") {
return { tag: "draft", errorMessage: pending.errorMessage ?? "" };
}
if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) {
return prepareCreateAttempt(initialAttempt, buildDraftAgent);
}
return localMachine;
}, [pending, localMachine, initialAttempt, buildDraftAgent]);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
@@ -233,14 +244,16 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const resolved =
error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent"));
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
markPendingCreateLifecycle({
draftId,
lifecycle: "abandoned",
errorMessage: resolved.message,
});
onCreateError?.(resolved);
throw error;
}
},
[
clearPendingCreateAttempt,
createRequest,
draftId,
getPendingServerId,
@@ -255,7 +268,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
if (
isSubmitting ||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
) {
throw new Error(t("composer.errors.alreadyLoading"));
}
@@ -296,10 +313,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
clientMessageId: `${draftId}:initial-message`,
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
...(images.length > 0 ? { images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
@@ -24,7 +25,7 @@ import type { Agent } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
@@ -136,6 +137,7 @@ function resolveDraftModeId(input: {
}
async function submitDraftCreateRequest(input: {
draftId: string;
attempt: { clientMessageId: string };
text: string;
images?: UserMessageImageAttachment[];
@@ -195,14 +197,18 @@ async function submitDraftCreateRequest(input: {
});
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await requestWorkspaceDraftAgent(client, {
const imagesData = await encodeImages(images);
const options = {
idempotencyKey: input.draftId,
config,
workspaceId,
text,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(images ? { images } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
});
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
return {
agentId: result.id,
@@ -389,10 +395,7 @@ export function WorkspaceDraftAgentTab({
);
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
if (!pendingAutoSubmit || !pendingCreateAttempt) {
return null;
}
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
if (!pendingCreateAttempt) {
return null;
}
return {
@@ -406,7 +409,7 @@ export function WorkspaceDraftAgentTab({
? { attachments: pendingCreateAttempt.attachments }
: {}),
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
}, [pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
@@ -486,8 +489,13 @@ export function WorkspaceDraftAgentTab({
composerState,
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) =>
submitDraftCreateRequest({
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
text,
images,
@@ -500,7 +508,8 @@ export function WorkspaceDraftAgentTab({
composerState,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
});
},
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
@@ -621,75 +630,83 @@ export function WorkspaceDraftAgentTab({
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
);
return (
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
<ComposerViewport style={styles.container} bottomInset={insets.bottom}>
<FileDropZone style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.configScrollContent}
>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.editText}
textReplacement={draftInput.textReplacement}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
</View>
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.editText}
textReplacement={draftInput.textReplacement}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
</KeyboardTranslateView>
</FileDropZone>
</ComposerViewportContent>
</KeyboardTranslateView>
</FileDropZone>
</ComposerViewport>
);
}
const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
},
});
+1 -2
View File
@@ -2407,8 +2407,7 @@ function ComposerContentImpl({
const animatedStaticStyles = RNStyleSheet.create({
container: {
flexDirection: "column",
// KeyboardDock reduces the available column height with bottom padding.
// Keep the composer's constraint chain shrinkable down to its scrolling input.
// Propagate the viewport's height constraint down to the scrolling input.
flexShrink: 1,
position: "relative",
},
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { resolveComposerCapacity } from "./capacity";
describe("composer viewport", () => {
it("leaves five points below the header for a bottom-anchored composer", () => {
const height = resolveComposerCapacity({
height: 582,
bottomInset: 24,
keyboardShift: 308,
centered: false,
});
expect(height).toBe(245);
expect(582 - 24 - 308 - height).toBe(5);
});
it("keeps a centered tablet form below the header after translation", () => {
const height = resolveComposerCapacity({
height: 1000,
bottomInset: 80,
keyboardShift: 300,
centered: true,
});
expect(height).toBe(310);
expect((1000 - 80 - height) / 2 - 300).toBe(5);
});
it("releases space after closing and responds to rotation instead of using window height", () => {
expect(
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
).toBe(553);
expect(
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
).toBe(95);
});
});
@@ -0,0 +1,13 @@
export function resolveComposerCapacity(input: {
height: number;
bottomInset: number;
keyboardShift: number;
centered: boolean;
}): number {
"worklet";
// A centered form grows upward by half its height. Reserve both halves so
// translating it still leaves five layout points below the header.
const clearance = input.keyboardShift + 5;
const reservedSpace = input.centered ? clearance * 2 : clearance;
return Math.max(0, input.height - input.bottomInset - reservedSpace);
}
@@ -0,0 +1,66 @@
import { createContext, useCallback, useContext } from "react";
import { View, type LayoutChangeEvent, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
useDerivedValue,
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { resolveComposerCapacity } from "./capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
centered?: boolean;
}
/** Measure the stationary space below the header, outside keyboard translation. */
export function ComposerViewport({
children,
onLayout,
bottomInset = 0,
centered = false,
...props
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
const { layoutShift } = useKeyboardShift();
const capacity = useDerivedValue(() => {
if (measuredHeight.value === 0) return undefined;
return resolveComposerCapacity({
height: measuredHeight.value,
bottomInset,
keyboardShift: layoutShift.value,
centered,
});
});
const measureViewport = useCallback(
(event: LayoutChangeEvent) => {
measuredHeight.value = event.nativeEvent.layout.height;
onLayout?.(event);
},
[measuredHeight, onLayout],
);
return (
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
</View>
);
}
/** Bound the complete composer (including its controls), not just the text input. */
export function ComposerViewportContent({ style, ...props }: ViewProps) {
const capacity = useContext(ViewportCapacity);
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
const constraint = useAnimatedStyle(() => ({
// Only viewport changes and keyboard start/end events trigger layout. Motion
// stays in KeyboardTranslateView and never changes this constraint per frame.
maxHeight: capacity.value,
}));
return (
<Animated.View testID="composer-viewport-content" {...props} style={[style, constraint]} />
);
}
@@ -0,0 +1,18 @@
import { View, type ViewProps } from "react-native";
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
centered?: boolean;
}
export function ComposerViewport({
bottomInset: _bottomInset,
centered: _centered,
...props
}: ComposerViewportProps) {
return <View {...props} />;
}
export function ComposerViewportContent(props: ViewProps) {
return <View {...props} />;
}
+1 -47
View File
@@ -15,7 +15,7 @@ import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducer
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
buildAgentAttentionNotificationPayload,
@@ -25,8 +25,6 @@ import {
} from "@getpaseo/protocol/agent-attention-notification";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
@@ -45,9 +43,7 @@ import {
createInitDeferred,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import type { AttachmentMetadata } from "@/attachments/types";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
@@ -825,48 +821,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[client],
);
const _createAgent = useCallback(
async ({
config,
initialPrompt,
images,
attachments,
git,
worktreeName,
requestId,
}: {
config: AgentSessionConfig;
initialPrompt: string;
images?: AttachmentMetadata[];
attachments?: AgentAttachment[];
git?: GitSetupOptions;
worktreeName?: string;
requestId?: string;
}) => {
if (!client) {
console.warn("[Session] createAgent skipped: daemon unavailable");
return;
}
const trimmedPrompt = initialPrompt.trim();
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
try {
imagesData = await encodeImages(images);
} catch (error) {
console.error("[Session] Failed to prepare images for agent creation:", error);
}
await client.createAgent({
config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
...(git ? { git } : {}),
...(worktreeName ? { worktreeName } : {}),
...(requestId ? { requestId } : {}),
});
},
[client],
);
const _setAgentMode = useCallback(
(agentId: string, modeId: string) => {
if (!client) {
@@ -130,6 +130,43 @@ function writeSnapshot(
}
describe("provider snapshot cache", () => {
it("normalizes duplicate model identities in live, legacy and persisted catalogs", async () => {
const storage = createStorage();
const entries = snapshotEntries("shared");
const first = entries[0].models![0];
entries[0].models!.push({ ...first, label: "Duplicate" });
const compactSnapshot = compactProviderSnapshot(entries);
const payload = { requestId: "catalog", generatedAt: "2026-09-01T00:00:00.000Z", entries };
const cache = createProviderSnapshotCache(storage);
const legacy = await cache.materialize("server-1", payload);
expect(legacy.entries[0].models).toEqual([first]);
const live = await cache.materialize("server-1", {
...payload,
snapshotHash: "old-hash",
compactSnapshot,
});
expect(live.entries[0].models).toEqual([first]);
expect(live.compactSnapshot).toEqual(compactSnapshot);
await cache.write({
serverId: "server-1",
cwd: "/repo",
hash: "old-hash",
generatedAt: payload.generatedAt,
compactSnapshot,
});
const restarted = createProviderSnapshotCache(storage);
const saved = await restarted.read("server-1", "/repo");
expect(saved?.entries[0].models).toEqual([first]);
const unchanged = await restarted.materialize("server-1", {
...payload,
entries: [],
snapshotHash: "old-hash",
notModified: true,
});
expect(unchanged.entries[0].models).toEqual([first]);
expect(unchanged.snapshotHash).toBe("old-hash");
});
it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => {
const storage = createStorage();
const cache = createProviderSnapshotCache(storage);
@@ -1,6 +1,9 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Buffer } from "buffer";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import {
normalizeAgentModelCatalog,
type ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import {
expandProviderSnapshot,
type CompactProviderSnapshot,
@@ -77,6 +80,17 @@ export class ProviderSnapshotCacheMissError extends Error {
}
}
// COMPAT(duplicateModelCatalog): added in v0.8.1, remove after 2027-03-14 once
// the daemon floor is v0.8.1 and pre-fix cached catalogs are no longer supported.
// Keep the original compact body/hash: normalization belongs to its decoded view.
function normalizeCatalogs(entries: ProviderSnapshotEntry[]): ProviderSnapshotEntry[] {
return entries.map((entry) => {
if (!entry.models) return entry;
const models = normalizeAgentModelCatalog(entry.models);
return models === entry.models ? entry : { ...entry, models };
});
}
function cacheKey(serverId: string, cwd: string | null): string {
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`;
}
@@ -303,7 +317,7 @@ export function createProviderSnapshotCache(
const body = {
...stored,
compactSnapshot: stored.compactSnapshot,
entries: expandProviderSnapshot(stored.compactSnapshot),
entries: normalizeCatalogs(expandProviderSnapshot(stored.compactSnapshot)),
};
bodies.set(key, body);
return body;
@@ -328,7 +342,7 @@ export function createProviderSnapshotCache(
return {
async materialize(serverId, snapshot) {
const { snapshotHash: hash, compactSnapshot } = snapshot;
if (!hash) return snapshot;
if (!hash) return { ...snapshot, entries: normalizeCatalogs(snapshot.entries) };
let body = await safely(async () => {
const cached = await readBody(serverId, hash);
if (cached) return cached;
@@ -348,7 +362,7 @@ export function createProviderSnapshotCache(
hash,
generatedAt: snapshot.generatedAt,
compactSnapshot,
entries: expandProviderSnapshot(compactSnapshot),
entries: normalizeCatalogs(expandProviderSnapshot(compactSnapshot)),
};
}
return {
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useMemo, useState } from "react";
import { StyleSheet } from "react-native-unistyles";
// The sheet-aware list: inside a bottom sheet the tree scrolls with the sheet
// gesture, and outside one it is the ordinary React Native FlatList.
import { FlatList } from "@/components/ui/scroll-view";
import type { WorkingDiffMode } from "@/git/diff-document";
import { DiffFolderRow } from "@/git/diff-folder-row";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "@/git/diff-tree";
import { FileHeader } from "@/git/file-header";
import type { ParsedDiffFile } from "@/git/use-diff-query";
export interface ChangedFilesTreeProps {
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}
/** The changed-files tree shared by the desktop Changes rail and the compact Jump to file sheet. */
export function ChangedFilesTree({
files,
mode,
onSelectFile,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: ChangedFilesTreeProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
const items = useMemo(
() => flattenDiffTree(compressedTree, collapsedFolders),
[collapsedFolders, compressedTree],
);
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
const handleSelectFile = useCallback(
(path: string) => {
setSelectedPath(path);
onSelectFile(path);
},
[onSelectFile],
);
const handleToggleFolder = useCallback(
(dirPath: string) => {
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
onCollapsedFolderPathsChange(next);
},
[collapsedFolders, onCollapsedFolderPathsChange],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
onCollapsedFolderPathsChange([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
(folderPath) => folderPath === dirPath || folderPath.startsWith(prefix),
),
]),
]);
},
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
if (item.kind === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={collapsedFolders.has(item.dirPath)}
isSelected={selectedPath === item.dirPath}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onCollapse={handleCollapseFolder}
onSelect={handleSelectPath}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
return (
<FileHeader
file={item.file}
workspaceFileDragScope={mode.workspaceFileDragScope}
bodyVisible={false}
showsBodyState={false}
isSelected={selectedPath === item.file.path}
depth={item.depth}
showDir={false}
onActivate={handleSelectFile}
onSelect={handleSelectPath}
onOpenFile={mode.onOpenFile}
onOpenToSide={mode.onOpenToSide}
onAddToChat={mode.onAddToChat}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDownload={mode.onDownload}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-tree-file-${item.fileIndex}`}
/>
);
},
[
handleCollapseFolder,
handleSelectFile,
handleSelectPath,
handleToggleFolder,
collapsedFolders,
mode,
selectedPath,
],
);
const keyExtractor = useCallback(
(item: DiffTreeRow) =>
item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`,
[],
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="changes-file-tree"
/>
);
}
const styles = StyleSheet.create((theme) => ({
scrollView: {
flex: 1,
},
contentContainer: {
paddingBottom: theme.spacing[8],
},
}));
@@ -4,27 +4,6 @@ import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommitsSection } from "./commits-section";
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? factory({
borderRadius: { full: 9999, sm: 4 },
borderWidth: { 1: 1 },
colors: {
border: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
statusDanger: "#c33",
surface2: "#222",
},
fontSize: { base: 15, sm: 13 },
spacing: [0, 4, 8, 12],
})
: factory,
},
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 34, left: 0 }),
}));
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { treeRowPaddingLeft } from "@/components/tree-primitives";
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { CommitRow } from "./commit-row";
@@ -175,7 +176,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
// Same leading rail as a depth-0 tree row, so the disclosure chevron lines up
// with the folder chevrons above it.
paddingLeft: treeRowPaddingLeft(0),
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
flexShrink: 0,
@@ -256,9 +256,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
consumedFocusRef.current = requestKey;
}
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
const contentInsetBottom = props.contentInsetBottom ?? 0;
const contentStyle = useMemo(
() => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
[model.height, viewport.height],
() => ({
minHeight: Math.max(model.height, viewport.height) + contentInsetBottom,
backgroundColor: "transparent",
}),
[contentInsetBottom, model.height, viewport.height],
);
return (
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
@@ -10,6 +10,7 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useToast } from "@/contexts/toast-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
@@ -557,23 +558,26 @@ export function DiffSurface(props: DiffSurfaceProps) {
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
[pointHitAt],
);
const setSelection = useCallback(
(selection: DiffSelection | null) => {
selectionRef.current = selection;
const currentModel = modelRef.current;
setHasSelection(
Boolean(
selection && currentModel && selectedSourceText(currentModel, selection).length > 0,
),
);
schedulePaint();
},
[schedulePaint],
);
const setSelection = useStableEvent((selection: DiffSelection | null) => {
selectionRef.current = selection;
const currentModel = modelRef.current;
setHasSelection(
Boolean(selection && currentModel && selectedSourceText(currentModel, selection).length > 0),
);
schedulePaint();
});
// Source and code-layout changes invalidate selection coordinates. A new
// repaint callback (header typography, palette, viewport height) does not.
useEffect(() => {
dragRef.current = null;
setSelection(null);
}, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]);
}, [
props.collapsedFilePaths,
props.displayPreferences.layout,
props.files,
desiredTypography,
setSelection,
]);
const pointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
@@ -768,9 +772,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
[props.palette.surface],
);
const contentInsetBottom = props.contentInsetBottom ?? 0;
const contentStyle = useMemo<React.CSSProperties>(
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
[model.height, viewport.height],
() => ({
...CONTENT_STYLE,
height: Math.max(model.height, viewport.height) + contentInsetBottom,
}),
[contentInsetBottom, model.height, viewport.height],
);
const affordanceStyle = useMemo<ViewStyle>(
() => ({
@@ -4,6 +4,8 @@ import type { ReviewableDiffTarget } from "@/utils/diff-layout";
interface DiffDocumentBaseProps {
files: ParsedDiffFile[];
/** Scrollable space kept below the last line so a floating control never traps content. */
contentInsetBottom?: number;
displayPreferences: {
layout: "unified" | "split";
wrapLines: boolean;
+51 -148
View File
@@ -8,7 +8,6 @@ import {
View,
Text,
Pressable,
FlatList,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
@@ -33,15 +32,8 @@ import { type ParsedDiffFile } from "@/git/use-diff-query";
import type { ChangesState } from "@/panels/changes/state";
import { defaultChangesState } from "@/panels/changes/state";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { FileHeader } from "@/git/file-header";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { ChangedFilesTree } from "@/git/changed-files-tree";
import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file";
import {
selectPrHintFromStatus,
type PrHint,
@@ -81,6 +73,7 @@ import {
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
paneContentToolbarTrailingPadding,
type ToolbarTrailingControl,
ToolbarButton,
ToolbarControls,
} from "@/components/ui/pane-content-toolbar";
@@ -568,7 +561,12 @@ function ChangesDiffOnlyToolbar({
sidebarSurface: boolean;
}) {
return (
<ChangesToolbarRow compact={compact} sidebarSurface={sidebarSurface} testID="changes-header">
<ChangesToolbarRow
compact={compact}
sidebarSurface={sidebarSurface}
testID="changes-header"
trailing="glyph"
>
<ChangesToolbarLeading />
<ChangesToolbarTrailing>
<ChangesToolbarActions mode={mode} compact={compact} />
@@ -582,19 +580,22 @@ function ChangesToolbarRow({
compact,
sidebarSurface,
testID,
trailing,
}: {
children: ReactNode;
compact: boolean;
sidebarSurface: boolean;
testID: string;
/** What the row's last control paints, so it can pad to the shared trailing rail. */
trailing: ToolbarTrailingControl;
}) {
const toolbarStyle = useMemo(
() => [
styles.changesToolbar,
{ paddingRight: paneContentToolbarTrailingPadding(compact) },
{ paddingRight: paneContentToolbarTrailingPadding(compact, trailing) },
sidebarSurface ? styles.changesToolbarSidebar : null,
],
[compact, sidebarSurface],
[compact, sidebarSurface, trailing],
);
return (
<PaneContentToolbar style={toolbarStyle} testID={testID}>
@@ -625,6 +626,7 @@ function ChangesRepositoryToolbar({
compact={compact}
sidebarSurface={sidebarSurface}
testID="changes-repository-header"
trailing={model.gitActions ? "framed" : "glyph"}
>
<ChangesToolbarLeading>
<BranchSwitcher
@@ -720,7 +722,12 @@ function ChangesComparisonToolbar({
sidebarSurface: boolean;
}) {
return (
<ChangesToolbarRow compact={compact} sidebarSurface={sidebarSurface} testID="changes-header">
<ChangesToolbarRow
compact={compact}
sidebarSurface={sidebarSurface}
testID="changes-header"
trailing="glyph"
>
<ChangesToolbarLeading>
<DiffModeMenu
diffMode={model.diffMode}
@@ -1239,134 +1246,23 @@ function buildToggleButtonStyle(
return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)];
}
function ChangedFilesTree({
files,
mode,
onSelectFile,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: {
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
const items = useMemo(
() => flattenDiffTree(compressedTree, collapsedFolders),
[collapsedFolders, compressedTree],
);
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
const handleSelectFile = useCallback(
(path: string) => {
setSelectedPath(path);
onSelectFile(path);
},
[onSelectFile],
);
const handleToggleFolder = useCallback(
(dirPath: string) => {
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
onCollapsedFolderPathsChange(next);
},
[collapsedFolders, onCollapsedFolderPathsChange],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
onCollapsedFolderPathsChange([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
(folderPath) => folderPath === dirPath || folderPath.startsWith(prefix),
),
]),
]);
},
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
if (item.kind === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={collapsedFolders.has(item.dirPath)}
isSelected={selectedPath === item.dirPath}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onCollapse={handleCollapseFolder}
onSelect={handleSelectPath}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
return (
<FileHeader
file={item.file}
workspaceFileDragScope={mode.workspaceFileDragScope}
bodyVisible={false}
showsBodyState={false}
isSelected={selectedPath === item.file.path}
depth={item.depth}
showDir={false}
onActivate={handleSelectFile}
onSelect={handleSelectPath}
onOpenFile={mode.onOpenFile}
onOpenToSide={mode.onOpenToSide}
onAddToChat={mode.onAddToChat}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDownload={mode.onDownload}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-tree-file-${item.fileIndex}`}
/>
);
},
[
handleCollapseFolder,
handleSelectFile,
handleSelectPath,
handleToggleFolder,
collapsedFolders,
mode,
selectedPath,
],
);
const keyExtractor = useCallback(
(item: DiffTreeRow) =>
item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`,
[],
);
interface JumpToFileVisibility {
isCompact: boolean;
presentation: ChangesPresentation;
hasChanges: boolean;
isDiffLoading: boolean;
}
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="changes-file-tree"
/>
);
/**
* The room the floating Jump to file action needs over the diff, and zero where
* it is not offered. Compact users never see the tree rail, so the diff is their
* only view of what changed; the tree and diff presentations are already halves
* of a desktop split.
*/
function jumpToFileClearance(visibility: JumpToFileVisibility): number {
if (!visibility.isCompact || visibility.presentation !== "combined") return 0;
if (!visibility.hasChanges || visibility.isDiffLoading) return 0;
return JUMP_TO_FILE_CLEARANCE;
}
function ChangesTreeRail({
@@ -1834,6 +1730,13 @@ export function ChangesSurface({
);
const hasChanges = files.length > 0;
const jumpToFileInset = jumpToFileClearance({
isCompact: isMobile,
presentation,
hasChanges,
isDiffLoading,
});
const showJumpToFile = jumpToFileInset > 0;
const selectedDiffStat = useMemo(
() => computeSelectedDiffStat(files, isDiffLoading),
[files, isDiffLoading],
@@ -1890,6 +1793,7 @@ export function ChangesSurface({
>
<DiffDocument
files={files}
contentInsetBottom={jumpToFileInset}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
@@ -2023,7 +1927,12 @@ export function ChangesSurface({
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</View>
<View style={styles.diffContainer}>
{bodyContent}
{showJumpToFile ? (
<JumpToFile files={files} mode={workingMode} onSelectFile={handleSelectTreeFile} />
) : null}
</View>
<ChangesCommits
presentation={presentation}
@@ -2092,17 +2001,11 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,
},
scrollContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
contentContainer: {
paddingBottom: theme.spacing[8],
},
loadingContainer: {
flex: 1,
alignItems: "center",
+127
View File
@@ -0,0 +1,127 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FolderTree } from "lucide-react-native";
import {
AdaptiveModalSheet,
SHEET_HEADER_CLOSE_PADDING_SCALE,
SHEET_HORIZONTAL_PADDING_SCALE,
} from "@/components/adaptive-modal-sheet";
import {
treeRowPaddingLeft,
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
} from "@/components/tree-primitives";
import {
FLOATING_ACTION_BUTTON_CLEARANCE,
FloatingActionButton,
} from "@/components/ui/floating-action-button";
import { ChangedFilesTree } from "@/git/changed-files-tree";
import type { WorkingDiffMode } from "@/git/diff-document";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
// The sheet header indents its leading glyph by the sheet's own content inset,
// while a tree row indents itself by `treeRowPaddingLeft`. The body carries the
// difference so a depth-0 file icon lands on the header glyph's rail.
const TREE_LEADING_INSET = SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - treeRowPaddingLeft(0);
// The header's close glyph sits at the content inset plus the button's own
// padding, while a tree row ends its trailing glyph at
// `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body carries the difference so row
// change icons land on the header X's rail.
const TREE_TRAILING_INSET =
SPACING[SHEET_HORIZONTAL_PADDING_SCALE] +
SPACING[SHEET_HEADER_CLOSE_PADDING_SCALE] -
WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
const ThemedFolderTree = withUnistyles(FolderTree);
const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
/** Scrollable space the diff keeps below its last line while the action is shown. */
export const JUMP_TO_FILE_CLEARANCE = FLOATING_ACTION_BUTTON_CLEARANCE;
export interface JumpToFileProps {
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
}
/** Compact overview of a loaded diff: a floating action that opens the changed-files tree. */
export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
// The sheet is a transient overview, so it always opens fully expanded and
// never writes to the panel's persisted folder state.
const [collapsedFolderPaths, setCollapsedFolderPaths] = useState<string[]>([]);
const title = t("workspace.git.diff.jumpToFile.title");
const header = useMemo(
() => ({
title,
leading: <ThemedFolderTree size={ICON_SIZE.md} uniProps={foregroundMutedIconColorMapping} />,
}),
[title],
);
const open = useCallback(() => {
setCollapsedFolderPaths([]);
setIsOpen(true);
}, []);
const close = useCallback(() => setIsOpen(false), []);
const handleSelectFile = useCallback(
(path: string) => {
setIsOpen(false);
onSelectFile(path);
},
[onSelectFile],
);
return (
<>
<FloatingActionButton
icon={FolderTree}
accessibilityLabel={title}
onPress={open}
testID="changes-jump-to-file"
/>
<AdaptiveModalSheet
header={header}
visible={isOpen}
onClose={close}
scrollable={false}
contentStyle={styles.sheetBody}
testID="changes-jump-to-file-sheet"
>
<View style={styles.tree}>
<ChangedFilesTree
files={files}
mode={mode}
onSelectFile={handleSelectFile}
collapsedFolderPaths={collapsedFolderPaths}
onCollapsedFolderPathsChange={setCollapsedFolderPaths}
/>
</View>
</AdaptiveModalSheet>
</>
);
}
const styles = StyleSheet.create((theme) => ({
// Rows own their rails, so the sheet's horizontal inset is zeroed here.
sheetBody: {
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
gap: 0,
},
tree: {
flex: 1,
minHeight: 0,
paddingLeft: TREE_LEADING_INSET,
paddingRight: TREE_TRAILING_INSET,
paddingTop: theme.spacing[2],
},
}));
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult } from "./use-diff-query";
function createParsedDiffFile(path: string): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions: 3,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "x", tokens: [] }],
},
],
} as ParsedDiffFile;
}
function createPayload(files: ParsedDiffFile[]) {
return { requestId: "req-1", cwd: "/repo", files, error: null };
}
describe("deriveCheckoutDiffResult", () => {
// No payload covers every not-yet-fetched case: the query is disabled because the
// retained panel is still inactive, the request is in flight, or the host is
// disconnected. None of them mean "the diff is empty".
it("reports loading while no payload has arrived, so an empty diff is never implied", () => {
const result = deriveCheckoutDiffResult(null);
expect(result.isLoading).toBe(true);
expect(result.files).toEqual([]);
});
it("reports a settled empty diff once a payload with zero files arrives", () => {
const result = deriveCheckoutDiffResult(createPayload([]));
expect(result.isLoading).toBe(false);
expect(result.files).toEqual([]);
});
it("passes the payload files through once the diff arrives", () => {
const files = [createParsedDiffFile("src/app.ts")];
const result = deriveCheckoutDiffResult(createPayload(files));
expect(result.isLoading).toBe(false);
expect(result.files).toEqual(files);
});
it("surfaces a payload error and the too-large flag", () => {
const error = { code: "NOT_GIT_REPO", message: "not a git repository" } as const;
const result = deriveCheckoutDiffResult({ ...createPayload([]), error, diffTooLarge: true });
expect(result).toEqual({
files: [],
payloadError: error,
diffTooLarge: true,
isLoading: false,
isFetching: false,
isError: true,
error: null,
});
});
});
+24 -3
View File
@@ -91,14 +91,35 @@ export function useCheckoutDiffQuery({
}),
});
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;
return deriveCheckoutDiffResult(query.data ?? null);
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
payloadError: CheckoutDiffQueryPayload["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(
payload: CheckoutDiffQueryPayload | null,
): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null && queryEnabled && isConnected,
isLoading: payload === null,
isFetching: false,
isError: Boolean(payloadError),
error: null,
@@ -3,6 +3,8 @@ import type { SharedValue } from "react-native-reanimated";
export interface KeyboardShiftContextValue {
shift: SharedValue<number>;
/** Space reserved at keyboard boundaries, never updated by animation frames. */
layoutShift: SharedValue<number>;
isMoving: SharedValue<boolean>;
bottomInset: SharedValue<number>;
}
@@ -1,5 +1,15 @@
export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
/** Reserve both endpoints during motion so content cannot grow into a closing IME. */
export function reserveKeyboardLayoutShift(input: {
current: number;
target: number;
phase: "start" | "end";
}): number {
"worklet";
return input.phase === "start" ? Math.max(input.current, input.target) : input.target;
}
export function resolveStreamKeyboardInset(input: {
platform: "android" | "ios";
settledShift: number;
@@ -3,9 +3,32 @@ import {
resolveStreamKeyboardInset,
shouldReconcileHiddenKeyboardEnd,
resolveKeyboardShift,
reserveKeyboardLayoutShift,
shouldUseCompactExplorerKeyboardPadding,
} from "./keyboard-shift-policy";
describe("keyboard layout reservation", () => {
it("reserves the opening destination before motion and releases it only after closing", () => {
const opened = reserveKeyboardLayoutShift({ current: 0, target: 308, phase: "start" });
expect(opened).toBe(308);
const closing = reserveKeyboardLayoutShift({ current: opened, target: 0, phase: "start" });
expect(closing).toBe(308);
expect(reserveKeyboardLayoutShift({ current: closing, target: 0, phase: "end" })).toBe(0);
});
it("keeps enough space when a resize or reversal interrupts keyboard motion", () => {
const resizing = reserveKeyboardLayoutShift({ current: 308, target: 250, phase: "start" });
expect(resizing).toBe(308);
const reversing = reserveKeyboardLayoutShift({
current: resizing,
target: 340,
phase: "start",
});
expect(reversing).toBe(340);
expect(reserveKeyboardLayoutShift({ current: reversing, target: 340, phase: "end" })).toBe(340);
});
});
describe("resolveStreamKeyboardInset", () => {
it("uses the native scroll inset on iOS without changing content size", () => {
expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({
@@ -17,6 +17,7 @@ import { scheduleOnRN } from "react-native-worklets";
import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
reserveKeyboardLayoutShift,
shouldReconcileHiddenKeyboardEnd,
} from "@/hooks/keyboard-shift-policy";
import {
@@ -33,6 +34,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const bottomInset = useSharedValue(insets.bottom);
const isIos = Platform.OS === "ios";
const isMoving = useSharedValue(false);
const layoutShift = useSharedValue(0);
const [settledShift, setSettledShift] = useState(0);
const publishSettledShift = useCallback((nextShift: number) => {
setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift));
@@ -55,12 +57,36 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
useGenericKeyboardHandler(
{
onStart: () => {
onStart: (event) => {
"worklet";
const target = resolveKeyboardShift({
rawKeyboardHeight: event.height,
keyboardProgress: event.height > 0 ? 1 : 0,
bottomInset: bottomInset.value,
isIos,
iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
});
layoutShift.value = reserveKeyboardLayoutShift({
current: layoutShift.value,
target,
phase: "start",
});
isMoving.value = true;
},
onEnd: (event) => {
"worklet";
const target = resolveKeyboardShift({
rawKeyboardHeight: event.height,
keyboardProgress: event.progress,
bottomInset: bottomInset.value,
isIos,
iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
});
layoutShift.value = reserveKeyboardLayoutShift({
current: layoutShift.value,
target,
phase: "end",
});
if (isIos && shouldReconcileHiddenKeyboardEnd(event)) {
keyboardHeight.value = 0;
keyboardProgress.value = 0;
@@ -68,7 +94,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
isMoving.value = false;
},
},
[isIos, isMoving, keyboardHeight, keyboardProgress],
[bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift],
);
useAnimatedReaction(
@@ -84,10 +110,11 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const value = useMemo(
() => ({
shift,
layoutShift,
isMoving,
bottomInset,
}),
[bottomInset, isMoving, shift],
[bottomInset, isMoving, layoutShift, shift],
);
return createElement(
+8 -1
View File
@@ -154,7 +154,7 @@ export const ar: TranslationResources = {
initialPromptRequired: "مطلوب موجه الأولي",
alreadyLoading: "جارٍ التحميل بالفعل",
uploadFailed: "Failed to upload file",
noClipboardImage: "لا توجد صورة في الحافظة",
noClipboardImage: "لا توجد صورة في الحافظة الحالية. جرّب اللصق من لوحة المفاتيح.",
pasteImageFailed: "تعذر لصق الصورة",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
@@ -209,6 +209,10 @@ export const ar: TranslationResources = {
historyLoadFailed: "تعذر تحميل سجل الوكيل",
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
permission: {
rejectedPlan: "خطة مرفوضة",
approvedPlan: "خطة معتمدة",
canceledPlan: "خطة ملغاة",
plan: "يخطط",
required: "الإذن مطلوب",
deny: "ينكر",
@@ -880,6 +884,9 @@ export const ar: TranslationResources = {
openChangesTab: "فتح علامة تبويب التغييرات",
openDiffTab: "فتح علامة تبويب الفرق",
closeChangesTab: "إغلاق علامة تبويب التغييرات",
jumpToFile: {
title: "الانتقال إلى ملف",
},
binaryFile: "ملف ثنائي",
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته",
+8 -1
View File
@@ -151,7 +151,7 @@ export const en = {
initialPromptRequired: "Initial prompt is required",
alreadyLoading: "Already loading",
uploadFailed: "Failed to upload file",
noClipboardImage: "No image in clipboard",
noClipboardImage: "No image in the current clipboard. Try pasting from your keyboard.",
pasteImageFailed: "Failed to paste image",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
@@ -206,6 +206,10 @@ export const en = {
historyLoadFailed: "Couldn't load agent history",
messageCapped: "This message was capped ({{bytes}} bytes).",
permission: {
rejectedPlan: "Rejected plan",
approvedPlan: "Approved plan",
canceledPlan: "Canceled plan",
plan: "Plan",
required: "Permission Required",
deny: "Deny",
@@ -889,6 +893,9 @@ export const en = {
openChangesTab: "Open Changes tab",
openDiffTab: "Open Diff tab",
closeChangesTab: "Close Changes tab",
jumpToFile: {
title: "Jump to file",
},
binaryFile: "Binary file",
tooLarge: "Diff too large to display",
previewTooLargeTitle: "This diff is too large to preview",
+9 -1
View File
@@ -154,7 +154,8 @@ export const es: TranslationResources = {
initialPromptRequired: "Se requiere aviso inicial",
alreadyLoading: "Ya cargando",
uploadFailed: "Failed to upload file",
noClipboardImage: "No hay ninguna imagen en el portapapeles",
noClipboardImage:
"No hay ninguna imagen en el portapapeles actual. Prueba a pegar desde el teclado.",
pasteImageFailed: "No se pudo pegar la imagen",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
@@ -209,6 +210,10 @@ export const es: TranslationResources = {
historyLoadFailed: "No se pudo cargar el historial del agente",
messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).",
permission: {
rejectedPlan: "Plan rechazado",
approvedPlan: "Plan aprobado",
canceledPlan: "Plan cancelado",
plan: "Plan",
required: "Permiso requerido",
deny: "Denegar",
@@ -911,6 +916,9 @@ export const es: TranslationResources = {
openChangesTab: "Abrir la pestaña Cambios",
openDiffTab: "Abrir la pestaña Diferencia",
closeChangesTab: "Cerrar la pestaña Cambios",
jumpToFile: {
title: "Ir al archivo",
},
binaryFile: "archivo binario",
tooLarge: "La diferencia es demasiado grande para mostrarse",
previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo",

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