Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 1d545a6628 fix(app): preserve thinking defaults in legacy fork drafts
Resolve the legacy default model placeholder through the same canonical model lookup used for selection and identity comparisons.
2026-11-07 22:55:37 +00:00
paseo-bot[bot] 9e0c9e8537 fix(app): preserve pending form edits without repeated updates
Ignore equivalent pending inputs so freshly built fork defaults do not keep the view rendering. Apply fork defaults only to their provider and model, preserving mode and thinking choices made while discovery is pending.
2026-11-07 22:51:32 +00:00
paseo-bot[bot]andMatt Cowger 4f7201665e fix(app): resolve remembered models when host catalogs arrive
Keep opening model resolution pending until its selected catalog is ready, so a loading snapshot cannot freeze an unavailable preference. Preserve user edits and error presentation while awaiting recovery.

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-11-07 22:25:32 +00:00
Matt Cowger 20120d111b fix(app): select available host model by default 2026-11-07 13:27:25 -08:00
paseo-bot[bot] 19848899d0 Keep host identity and error details in desktop logs (#9038)
* fix(app): preserve host error details in desktop logs

Bind host context through the existing client logger and emit structured text so desktop console capture retains the error record, including for adopted probe clients.

* fix(app): retain discovered host identity and nested error causes

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 21:19:18 +00:00
paseo-bot[bot] 249c35891b fix(app): load History after a browser reload (#9029)
Consume host connection statuses in History instead of a version-only dependency that the React Compiler removes. Keep saved sessions reachable when the host connects after the screen mounts.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 20:02:59 +00:00
paseo-bot[bot] b7a308ca00 fix(relay): stop handshake retries when the channel closes (#9025)
Cancel handshake work at the channel close transition instead of waiting for a socket acknowledgment from an unreachable peer.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 18:59:08 +00:00
paseo-bot[bot] 063b402d7c test(cli): align worker upgrade checks with protocol 6 (#9033)
The upgrade journey still expected protocol 5 after the worker moved to 6, stopping every main CLI run before adoption coverage. Keep obsolete versions 0 through 4 and exercise newer-worker rejection with version 7.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 18:19:31 +00:00
paseo-bot[bot] c908eddce6 Fill controlled form fields through browser tools (#9022)
* fix(desktop): fill controlled form fields before submission

Write text controls through native DOM setters so page-level value tracking sees the input event as a change. Cover state updates, submission, replacement and clearing through the real browser tool journey.

* refactor(desktop): select native field setters with explicit branches

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 17:52:53 +00:00
8a14d7b2c3 Collect idle Claude runtimes reopened without a turn (#9019)
Reopening a chat lists its commands, which resumes the agent and starts
a Claude process with no prompt. Claude only sends init with a turn, so
the runtime's background-work state stayed unknown and idle cleanup
vetoed it on every sweep: the process stayed resident until the daemon
restarted.

A process that has not been sent a prompt and has not reported init has
run no turn and owns no background work, so it can stop. Older CLIs
that ran a turn without a roster are still kept.

Fixes #9017

Co-authored-by: Brandon Elliott <923787+Brandon168@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:54:27 +00:00
paseo-bot[bot] ab8e1e61ff fix(app): keep the composer ready after clicking Send (#9015)
Pointer submission blurs the editor before sending and clears retained focus intent. Restore focus through the existing default send action for mouse-driven layouts, while keeping touch keyboard dismissal intact.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 16:18:07 +00:00
Mohamed Boudra 9131505810 Fill short collapsed chat histories (#8358)
* fix(app): fill short collapsed chat histories

Collapsed whole turns can leave the initial viewport short even when older history exists. Continue paging through measured pages before revealing a fresh chat, while preserving visible history and the reading anchor.

* fix(app): settle padded and stalled chat history

Measure the native history edge using the same content area as viewport filling, and bound child history requests so retry can start after a stalled fetch.

* fix(app): adapt viewport filling to the current timeline

* fix(app): resume history filling after switching tabs
2026-11-07 23:12:08 +07:00
Mohamed Boudra c6f0c24bf6 Make a folded turn's summary line its own chat row (#9009)
* fix(app): make a folded turn's summary line its own chat row

The summary line of a collapsed turn was drawn inside its prompt's row, so the
row gap landed below the summary and the prompt touched it. Turn collapse now
emits a `turn_summary` row between the host and the reply, and one spacing
rule covers every prompt: 16 above whatever answers it (reply, summary row or
tool row). The summary row spaces like the work rows it stands for, 4 above the
reply and flush under a tool host.

This removes the per-row summary lookup, the host revision tracking and the
fragments in the view. A fold reuses a turn's previous summary row object while
its work and open state are unchanged, so mounted rows do not re-render.

* fix(app): keep the turn summary row at its host's timeline position

The chat outline resolves the active prompt from the timeline position of
the row under the reading line. The summary row had none, so the outline lost
its mark while the row sat there. The row now carries its host's position and
is replaced when that position lands.

* fix(app): keep the reader on a folding turn's host, not its summary row

When an older page completes the oldest loaded turn and it folds, the reading
position moves from the hidden rows to the turn's host row, as on main, so
the prompt and its summary row both land in view. The pagination helper now
finds the summary line in the row under the prompt.
2026-11-07 23:00:13 +07:00
paseo-bot[bot] ef7de3d066 test(server): control recursive watcher reconciliation timing (#9010)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 15:01:32 +00:00
Mohamed Boudra b7b38412a0 Keep turn footer timing within the available chat width (#9007)
* fix(app): keep turn footer timing within the chat width

Let visible timing text determine its height within the available chat width instead of sizing an absolute overlay by character count. Keep actions anchored when duration and timestamp labels swap.

* fix(app): keep footer timestamp hover target stable

* fix(app): reserve both wrapped timing label heights
2026-11-07 21:32:14 +07:00
a48bc3aa6f Keep concurrent Codex discovery from failing during startup (#9008)
* Serialize Codex app-server startup by state directory

* Keep Codex session constructor unchanged

* Type Codex startup failures and inject process launch port

* fix(codex): retain startup diagnostics emitted after exit

Classify failed initialization after stdio closes so the bounded SQLite retry sees the complete stderr. Keep running-session exit handling immediate. The regression delivers the SQLite diagnostic between exit and close.

* test(codex): inject history test process through the public dependency

The history fixture still replaced the old private spawn method, so provider launch preparation tried to find a real CLI in CI. Inject the same process interface as other provider tests and use an invalid command as a guard against accidental real launches.

---------

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <1246619+colonelpanic8@users.noreply.github.com>
2026-11-07 14:03:47 +00:00
Patrick WangandCopilot 28c01ea1ce fix(terminal): preserve mouse encoding when replaying sessions (#9004)
Restore SGR and pixel mouse encoding alongside tracking modes so Copilot CLI scrolling survives switching conversations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-07 13:21:25 +00:00
Mohamed Boudra a449271d57 Let plugins provide custom sidebar views across hosts (#8906)
* feat(plugin): expose stable sidebar entity data across hosts

Collections return interned references and preserve their arrays when membership and order stay unchanged. Entity selectors share one hook with identity defaults for full snapshots and shallow defaults for selections. The app caches host projections and shares underlying store and host subscriptions. Browser render counts exercise the real session store and plugin provider without memoized rows. Terminal hooks consume the collection source port; daemon-wide terminal discovery is the separate terminal phase.

* feat(plugin): let users select a sidebar view

An exclusive view replaces the sidebar content while the host retains navigation, callouts, and footer. The device remembers the selection, unavailable views fall back to the default, and throwing views provide recovery. Device-scoped plugin settings persist locally with schema validation and revision conflicts. Web E2E and Android tablet QA cover selection, preserved chrome, disabled-view fallback, and error recovery.

* feat(plugin): add sidebar view examples and ref actions

Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders.

* feat(plugin): observe terminals across connected hosts

Share host-wide terminal observations across plugin surfaces and gate them on terminalCollection. Keep directory observations compatible, and carry creation time in worker v6 state so v5 terminals can drain with an unknown timestamp.
2026-11-07 18:05:31 +07:00
paseo-bot[bot] aac228e0a8 test(server): separate legacy plugin setup from migration deadline (#8983)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 09:01:32 +00:00
paseo-bot[bot] 0e40022519 Preserve linked resources when pasting prompts (#8972)
* fix(app): preserve linked resources when pasting prompts

Rich clipboard links carried their destination only in HTML, while the composer let textarea default paste insert the plain label. Convert linked fragments to Markdown at ingestion and replace the selected draft range through the existing editor command.

* fix(app): keep pasted links undoable while drafting offline

Use the browser edit command for converted paste so selection replacement participates in Undo and normal input publication. Keep complete Markdown clipboard text when it already carries the link destinations, and limit connection gating to image uploads.

* fix(app): preserve copied Markdown with normalized links

Copy renders normalized URLs in HTML while retaining original Markdown in plain text. Compare parsed destinations using the same Markdown parser so pasting complete responses keeps tables and formatting intact.

* fix(app): retain external linked tables when pasting prompts

Recovering missing link destinations must retain table rows and columns. Use the existing table converter and keep cell delimiters and line breaks inside their cells.

* test(app): name rich clipboard paste arguments

* fix(app): preserve pasted links around rowless tables

The table converter requires a first row. Convert rowless table content separately so empty markup cannot abort recovery of surrounding destinations.

* test(app): wait for workspace restore before undo teardown

The directory appears before git finishes recreating the worktree. Wait for the host to publish its active workspace before cleanup removes the source repository.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 07:04:10 +00:00
paseo-bot[bot]andAbdulhakeem Mustapha 751dc82e6c Keep browser evaluation working on strict-policy pages without shadowing page globals (#8973)
* fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval'

* test(desktop): browser_evaluate on pages that allow and forbid eval

* test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it

* fix(desktop): preserve page globals in browser evaluation

Define the supplied function outside every wrapper binding so strict-policy evaluation keeps the original page scope.

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>

---------

Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 06:03:12 +00:00
paseo-bot[bot] daf26892d3 Keep the provider's actual model after unavailable selections (#8957)
* fix(server): keep the model selected by the provider

A live model setter can retain or resolve a different model than the requested ID. Read its effective selection before persisting manager state so an ignored selection cannot overwrite the actual model. Preserve explicit preference resets.

* fix(server): preserve completed model selections

Return the effective selection from the provider mutation so a separate status request cannot reject an already completed switch or leave configured and runtime models inconsistent.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 04:18:27 +00:00
paseo-bot[bot] c5cf6cbb4c test(server): await shared fetch observation readiness (#8975)
Snapshot refresh completion does not mean both workspaces have joined the repository fetch target. Wait for observation setup before releasing the first fetch, and retain delayed checkout resolution to exercise that ordering.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 03:01:45 +00:00
Abdulhakeem Adetunji Mustapha 78a089a14e fix(desktop): browser_logs reports the HTTP status of each request (#8956)
* fix(desktop): browser_logs reports the HTTP status of each request

* test(desktop): browser_logs reports the status the browser recorded

* test(desktop): e2e checks browser_logs reports a 404 and a 200

* docs(browser): browser_logs also reports the HTTP status
2026-11-07 01:00:56 +00:00
paseo-bot[bot] ec3ae09926 Keep the native model when importing sessions (#8948)
* fix(server): preserve the selected model when importing sessions

Import preparation resolved a new-session catalog default and sent it as an explicit resume override. Keep import selections with the resumed provider and persist its reported model for subsequent reloads.

* fix(codex): allow the first reply after importing a session

Resolve collaboration settings again when default model and effort are applied so imported sessions send a complete first-turn request.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-07 00:08:25 +00:00
197 changed files with 8566 additions and 1392 deletions
+3
View File
@@ -47,6 +47,9 @@ The component renders state and dispatches intent. That is all it does.
4. **Resolution is explicit model state, per host** (`idle | pending |
complete`), keyed off the opened snapshot's serverId. Waiting for data is a
state you can render, not an effect race.
Create-agent model preferences are shared across hosts, so resolve remembered
models against the selected host's ready catalog before applying them. Keep
explicit initial values and user selections intact.
5. **Displays are owned state.** The selected option's label is captured at
selection/seed time (`setProject(value, display)`), never re-derived from a
live options list — list churn must not flicker or blank a selection.
+7
View File
@@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource(
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Replace sidebar content
The sidebar view slot is global across hosts, while header and footer contributions retain their
host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns
its layout. This keeps plugin failures from removing navigation or recovery controls. The
[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API.
## Contribute sidebar items
Sidebar header and footer items are plugin components, not descriptors. The
+1 -1
View File
@@ -15,7 +15,7 @@ supervisor (paseo.pid) ──► daemon worker ──► launcher ──► term
- The worker exits on its own when it has no controller and no terminals, on `shutdown`, on SIGTERM (killing its terminals), and when its record disappears (a deleted `$PASEO_HOME`: nobody can ever reach it again).
- Its stderr goes to `$PASEO_HOME/terminal-worker.log`. No pipe to the daemon survives the daemon.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
The worker retains a fixed 10,000 history rows plus the screen in its existing xterm. Replay uses `@xterm/addon-serialize` 0.14 against headless xterm 6; no second cell grid or lifetime byte log is retained. The serializer boundary owns cursor position, visibility and explicit program-selected presentation; the addon alone does not preserve all three. An unset program style leaves each client’s configured cursor defaults intact. It also restores SGR mouse encoding: the addon preserves mouse tracking but omits its encoding, so switching away and back otherwise sends legacy wheel reports that Copilot CLI cannot use. These missing modes require xterm's internal services because its public modes API omits them. Standard modes come from the serializer; the existing input-mode tracker retains protocol modes such as Kitty and Win32 input. Commit that tracker with parsed output so replay bytes and metadata describe the same cut.
Which triggers keep terminals and which close them is owned by the stop reason (`keepTerminals`) that flows from the request through the supervisor to `stop()`. The kill side always ends in the reaper (`terminal-worker-reaper.ts`), the only path that signals terminal processes outside a connected daemon.
+2 -2
View File
@@ -69,8 +69,8 @@ Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward.
and presentation; it cannot recover historical batching or steering. An underfilled viewport
loads older history until it fills or history is exhausted. Further history is user-driven.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
A fixed 1s retry turned a persistent daemon-side refusal — a Codex thread that already has an active
@@ -198,3 +198,81 @@ test.describe("Turn end", () => {
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
async function expectTimingInsideFooter(page: Page): Promise<void> {
const label = page.getByRole("button", { name: /^Worked for/ }).last();
const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => {
const labelBounds = element.parentElement!.getBoundingClientRect();
const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect();
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const lines: boolean[] = [];
while (walker.nextNode()) {
const range = document.createRange();
range.selectNodeContents(walker.currentNode);
for (const rect of range.getClientRects()) {
lines.push(
rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 &&
rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 &&
rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 &&
rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5,
);
}
}
return lines;
});
expect(paintedLines.length).toBeGreaterThan(0);
expect(paintedLines.every(Boolean)).toBe(true);
}
async function expectFooterWithinWidth(page: Page, width: number): Promise<void> {
const footer = page.getByTestId("turn-footer").last();
await footer.evaluate((element, availableWidth) => {
element.style.width = `${availableWidth}px`;
}, width);
const label = page.getByRole("button", { name: /^Worked for/ }).last();
await expect(label).toBeVisible();
await expectTimingInsideFooter(page);
const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ });
const before = await controls.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
}),
);
const footerBefore = await footer.boundingBox();
const labelBefore = await label.boundingBox();
await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } });
await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/);
expect(await label.boundingBox()).toMatchObject({
x: labelBefore!.x,
width: labelBefore!.width,
height: labelBefore!.height,
});
expect(
await controls.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y };
}),
),
).toEqual(before);
expect(await footer.boundingBox()).toMatchObject({
x: footerBefore!.x,
y: footerBefore!.y,
width: footerBefore!.width,
});
await expectTimingInsideFooter(page);
await page.mouse.move(0, 0);
await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for");
}
test("footer timing fits the chat column and swapping labels keeps controls in place", async ({
page,
agent,
}) => {
await openAgentRoute(page, agent);
await expectFooterWithinWidth(page, 140);
await expectFooterWithinWidth(page, 150);
await expectFooterWithinWidth(page, 300);
await expectFooterWithinWidth(page, 880);
});
@@ -1,5 +1,11 @@
import { expect, test, type Page } from "../support/fixtures";
import {
pasteRichTextIntoComposer,
fillComposerDraft,
copyLatestTurnIntoComposer,
selectComposerDraftRange,
undoComposerEdit,
redoComposerEdit,
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
@@ -13,6 +19,7 @@ import {
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
submitMessageWithButton,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -28,6 +35,7 @@ import {
openMobileAgentSidebarWithKeyboard,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { readScrollMetrics } from "../support/helpers/agent-bottom-anchor";
import {
expectTimelinePromptPositionPreserved,
@@ -55,7 +63,9 @@ test("clicking chat text or background focuses the feed for keyboard paging", as
await expect
.poll(async () => (await readScrollMetrics(page)).offsetY)
.toBeLessThan(beforePaging.offsetY - beforePaging.viewportHeight / 2);
await page.screenshot({ path: testInfo.outputPath("chat-keyboard-paging.png") });
await page.screenshot({
path: testInfo.outputPath("chat-keyboard-paging.png"),
});
await page.keyboard.press("PageDown");
await expect
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom)
@@ -124,20 +134,86 @@ test("focusing the feed preserves assistant text selection and code copy control
});
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const copiedTable =
"| Resource | Status |\n| --- | --- |\n| [Unicode](https://example.com/日本語) | ~~closed~~ |\n| [Space](<https://example.com/a b>) | **open** |\n| [ASCII](https://example.com/1) | closed |";
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-",
title: "Composer focus",
featureValues: { mockAssistantResponse: copiedTable },
});
const connection = await installDaemonWebSocketGate(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await submitMessageWithButton(page, "Pointer submission");
await expectComposerDraft(page, "");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Ready after Send");
await expectComposerDraft(page, "Ready after Send");
await agent.client.waitForFinish(agent.agentId, 15_000);
await expectComposerFocused(page);
await submitMessage(page, "First message");
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, "Second message");
await expectComposerDraft(page, "Second message");
await typeIntoFocusedComposer(page, "Investigate: replace this. Thanks");
await selectComposerDraftRange(page, 13, 25);
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/issues/39150">this FR</a>',
text: "this FR",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await undoComposerEdit(page);
await expectComposerDraft(page, "Investigate: replace this. Thanks");
await redoComposerEdit(page);
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150). Thanks",
);
await typeIntoFocusedComposer(page, " next");
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next. Thanks",
);
await agent.client.waitForFinish(agent.agentId, 15_000);
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
await connection.drop();
await connection.waitForBlockedConnection();
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/offline">offline link</a>',
text: "offline link",
});
await expectComposerDraft(
page,
"Investigate: [this FR](https://example.com/issues/39150) next[offline link](https://example.com/offline). Thanks",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
text: "Resource\tStatus\nIssue\tclosed",
});
await expectComposerDraft(
page,
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
await fillComposerDraft(page, "");
await pasteRichTextIntoComposer({
page,
html: '<a href="https://example.com/">Issue</a><table></table>',
text: "Issue",
});
await expectComposerDraft(page, "[Issue](https://example.com/)");
await copyLatestTurnIntoComposer(page);
await expectComposerDraft(page, copiedTable);
await expectComposerFocused(page);
} finally {
await agent.cleanup();
}
@@ -168,7 +244,11 @@ async function waitForAnimationFrames(page: Page, count: number): Promise<void>
test.describe("touch screen wide enough for the desktop layout", () => {
// A tablet or an unfolded foldable gets the desktop layout, but focusing the
// composer there raises the on-screen keyboard over the conversation.
test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true });
test.use({
viewport: { width: 1024, height: 768 },
isMobile: true,
hasTouch: true,
});
test("remounting an inline composer does not restore focus on touch", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -192,7 +272,9 @@ test.describe("touch screen wide enough for the desktop layout", () => {
test("returning to an inactive inline composer does not restore focus on touch", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" });
const workspace = await seedWorkspace({
repoPrefix: "composer-touch-retained-",
});
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
@@ -240,6 +322,9 @@ test.describe("touch screen wide enough for the desktop layout", () => {
// Autofocus makes its first attempt on the next animation frame.
await waitForAnimationFrames(page, 4);
await expectComposerNotFocused(page);
await submitMessageWithButton(page, "Touch submission");
await expectComposerDraft(page, "");
await expectComposerNotFocused(page);
} finally {
await agent.cleanup();
}
@@ -60,6 +60,15 @@ async function verifyChronologicalFiltering(page: Page): Promise<void> {
});
}
async function verifyHistorySurvivesReload(page: Page): Promise<void> {
await test.step("reloads History without visiting a workspace to reconnect", async () => {
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
await page.reload();
await expectVisibleTitles(page, [TITLES.unbilled, TITLES.billing, TITLES.terminal]);
await expect(page.getByTestId("sessions-host-errors")).toBeHidden();
});
}
async function verifyRecencyBeforeMatchStrength(page: Page): Promise<void> {
await test.step("keeps newer partial matches before older stronger matches", async () => {
await search(page, `${NONCE} bill`);
@@ -151,6 +160,8 @@ test.describe("History search", () => {
await verifyChronologicalFiltering(page);
await verifyHistorySurvivesReload(page);
await verifyRecencyBeforeMatchStrength(page);
await verifyExactAndTypoHighlights(page);
@@ -0,0 +1,18 @@
import { test } from "../support/fixtures";
import { withSidebarViews } from "../support/helpers/sidebar-views";
test("sidebar views replace the content, preserve chrome, and recover to the default", async ({
page,
}) => {
test.setTimeout(150_000);
await withSidebarViews(page, async (views) => {
await views.expectDefault();
await views.chooseWorkspaces();
await views.exerciseExamples();
await views.exerciseTerminals();
await views.restoreDefault();
await views.recoverFromFailure();
await views.chooseWorkspaces();
await views.disableChosenView();
});
});
+84 -10
View File
@@ -63,6 +63,26 @@ async function rowsBetweenLatestSummaryAndReply(
}, replyTestId);
}
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
const summaryRow = () =>
page.evaluate(() => {
const latest = (testId: string) =>
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
const summary = latest("turn-summary");
const row = summary.closest("[data-message-id]")!;
return {
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
gapBelowPrompt: Math.round(
summary.getBoundingClientRect().top -
latest("user-message").getBoundingClientRect().bottom,
),
};
});
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
@@ -208,6 +228,7 @@ export default function contribute(client) {
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expectSummaryLineSitsApartFromPrompt(page);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
@@ -470,34 +491,87 @@ async function seedWholeTurnHistory() {
return agent;
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
async function expectInitialFillIsConcealed(page: Page) {
await expect(page.getByTestId("agent-initial-fill")).toBeVisible();
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "0");
}
async function expectFilledChatAtBottom(page: Page) {
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
const chat = page.getByTestId("agent-chat-scroll");
await expect(chat).toHaveCSS("opacity", "1");
await expect
.poll(() =>
chat.evaluate((element) => ({
hasOverflow: element.scrollHeight > element.clientHeight,
atBottom: Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 2,
})),
)
.toEqual({ hasOverflow: true, atBottom: true });
}
test("fills collapsed history before revealing a fresh chat", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await history.expectRequestedPages(1);
await expectInitialFillIsConcealed(page);
history.releasePage(1);
await history.expectRequestedPages(2);
await expectInitialFillIsConcealed(page);
history.releasePage(2);
await expectFilledChatAtBottom(page);
await page.setViewportSize({ width: 1280, height: 8000 });
await history.expectRequestedPages(3);
await expect(page.getByTestId("agent-chat-scroll")).toHaveCSS("opacity", "1");
await expect(page.getByTestId("agent-initial-fill")).toBeHidden();
history.releasePage(3);
await history.expectSettledWithRequestedPages(3);
history.expectNoRepeatedEntries();
} finally {
await agent.cleanup();
}
});
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await history.expectRequestedPages(1);
history.releasePage(1);
await history.expectRequestedPages(2);
history.releasePage(2);
await expectFilledChatAtBottom(page);
await expectComposerVisible(page);
const latest = page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await expect(
page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(3);
history.releasePage(3);
await page.getByTestId("scroll-to-bottom-button").click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await scrollTimelineRowIntoView(page, latest);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -112,6 +112,7 @@ test.describe("Workspace archive undo", () => {
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
+77 -10
View File
@@ -31,7 +31,9 @@ export async function expectComposerVisible(
page: Page,
options?: { timeout?: number },
): Promise<void> {
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
await expect(composerInput(page)).toBeVisible({
timeout: options?.timeout ?? 15_000,
});
}
export async function expectComposerDisabled(page: Page): Promise<void> {
@@ -156,7 +158,9 @@ export async function attachImageFromMenu(
}
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId(testID).first()).toBeVisible({
timeout: 10_000,
});
}
export async function attachFileFromMenu(
@@ -218,18 +222,24 @@ export async function expectGithubAttachmentPill(
export async function openImageLightbox(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open image attachment" }).first().click();
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({
timeout: 5_000,
});
}
export async function closeImageLightbox(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({
timeout: 5_000,
});
}
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
await openAttachmentMenu(page);
await page.getByTestId("message-input-attachment-menu-item-github").click();
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({
timeout: 5_000,
});
}
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
@@ -283,7 +293,10 @@ export async function startRunningMockAgent(
model: opts.model,
featureValues: opts.featureValues,
});
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
const agentUrl = `${buildHostWorkspaceRoute(
serverId,
workspace.id,
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
await page.goto(agentUrl);
await expectComposerVisible(page);
await client.sendAgentMessage(agent.id, opts.prompt);
@@ -347,7 +360,11 @@ export async function pasteComposerTestImage(page: Page): Promise<boolean> {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
const event = new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
});
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
@@ -505,7 +522,10 @@ export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
return Reflect.get(window, "__composerGeometryFrames") as {
input: number;
root: number;
}[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
@@ -516,7 +536,10 @@ export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
const activeTab = page.getByRole("button", {
name: activeChatTitle,
exact: true,
});
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
@@ -556,7 +579,10 @@ export async function roundTripComposerTabBeforePaint(
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
observer.observe(tab, {
attributes: true,
attributeFilter: ["aria-selected"],
});
shortcut("BracketLeft");
});
});
@@ -566,3 +592,44 @@ export async function roundTripComposerTabBeforePaint(
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
/** Paste both clipboard representations through the browser's normal paste command. */
export async function pasteRichTextIntoComposer({
page,
html,
text,
}: {
page: Page;
html: string;
text: string;
}): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate(
async ({ html: richHtml, text: plainText }) => {
await navigator.clipboard.write([
new ClipboardItem({
"text/html": new Blob([richHtml], { type: "text/html" }),
"text/plain": new Blob([plainText], { type: "text/plain" }),
}),
]);
},
{ html, text },
);
await composerInput(page).press("ControlOrMeta+v");
}
export async function undoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+z");
}
export async function redoComposerEdit(page: Page): Promise<void> {
await composerInput(page).press("ControlOrMeta+Shift+z");
}
/** Reuse the actual response Copy action and the browser's native paste. */
export async function copyLatestTurnIntoComposer(page: Page): Promise<void> {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.getByRole("button", { name: "Copy turn", exact: true }).last().click();
await expect(page.getByRole("button", { name: "Copied", exact: true })).toBeVisible();
await composerInput(page).fill("");
await composerInput(page).press("ControlOrMeta+v");
}
@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "createTerminal"
| "killTerminal"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
@@ -34,6 +36,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
| "setWorkspaceLabel"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -0,0 +1,191 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { gotoWorkspace } from "./launcher";
import { openSidebarNavSettings, leaveSettings } from "./sidebar-nav-settings";
import { visibleTestId } from "./plugin-sidebar-items";
import { getServerId } from "./server-id";
export async function withSidebarViews(
page: Page,
run: (views: ReturnType<typeof sidebarViews>) => Promise<void>,
) {
const example = await copyPluginExample("sidebar-views");
const entry = path.join(example.directory, "index.client.tsx");
const original = await readFile(entry, "utf8");
await writeFile(
entry,
original.replace(
"const cleanups = [",
'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [',
),
);
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const workspace = await seedWorkspace({
repoPrefix: "sidebar-views-",
title: "Sidebar view workspace",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize({ width: 1280, height: 900 });
await gotoWorkspace(page, workspace.workspaceId);
await run(sidebarViews(page, workspace.workspaceId, client));
} finally {
await client.removePlugin("sidebar-views");
await client.close();
await workspace.cleanup();
await example.cleanup();
}
}
function sidebarViews(
page: Page,
workspaceId: string,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
) {
const builtinRow = () =>
visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
async function choose(title: string) {
await openSidebarNavSettings(page);
await page.getByRole("button", { name: /^View:/ }).click();
await page.getByRole("menuitem", { name: title, exact: true }).click();
await expect(page.getByRole("button", { name: `View: ${title}`, exact: true })).toBeVisible();
await leaveSettings(page);
}
async function expectDefault() {
await expect(builtinRow()).toBeVisible();
}
async function expectPlugin() {
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Sidebar view workspace", {
exact: true,
}),
).toBeVisible();
await expect(builtinRow()).toHaveCount(0);
await expect(visibleTestId(page, "sidebar-global-new-workspace")).toBeVisible();
await expect(visibleTestId(page, "sidebar-settings")).toBeVisible();
}
return {
expectDefault,
async chooseWorkspaces() {
await choose("Example workspaces");
await expectPlugin();
},
async exerciseExamples() {
await choose("Example agents");
await expect(
visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }),
).toBeVisible();
await choose("Example nested agents");
await expectPlugin();
await choose("Example label folders");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toContain("Inbox");
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Later", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Later"]);
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Unlabelled", color: "red" },
assigned: true,
});
await client.setWorkspaceLabel({
workspaceId,
label: { name: "Later", color: "red" },
assigned: false,
});
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Unlabelled"]);
await slot
.getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true })
.click();
await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click();
await expect
.poll(
async () =>
(await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId)
?.labels,
)
.toEqual(["Inbox"]);
},
async exerciseTerminals() {
await choose("Example terminals");
const slot = visibleTestId(page, "plugin-sidebar-view");
await slot
.getByRole("button", { name: `Terminals on ${getServerId()}`, exact: true })
.click();
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
const workspace = (await client.fetchWorkspaces()).entries.find(
(item) => item.id === workspaceId,
)!;
const created = await client.createTerminal(
workspace.workspaceDirectory,
"Sidebar terminal",
undefined,
{ workspaceId },
);
if (!created.terminal) throw new Error(created.error ?? "Terminal creation failed");
try {
await expect(
slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`),
).toBeVisible();
await slot.getByTestId(`sidebar-terminal-open-${created.terminal.id}`).click();
await expect(page.getByTestId("terminal-surface").first()).toBeVisible();
await slot.getByTestId(`sidebar-terminal-kill-${created.terminal.id}`).click();
await expect
.poll(async () => (await client.listTerminals()).terminals.map((item) => item.id))
.not.toContain(created.terminal!.id);
await expect(slot.getByText("No terminals", { exact: true })).toBeVisible();
} finally {
await client.killTerminal(created.terminal.id);
}
},
async restoreDefault() {
await choose("Default view");
await expectDefault();
},
async recoverFromFailure() {
await choose("Broken example");
await expect(page.getByText("Sidebar view failed to load.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use default view", exact: true }).click();
await expectDefault();
},
async disableChosenView() {
await client.disablePlugin("sidebar-views");
await expectDefault();
await openSidebarNavSettings(page);
await expect(
page.getByText("The selected view is unavailable or disabled. Showing Default view.", {
exact: true,
}),
).toBeVisible();
await leaveSettings(page);
},
};
}
@@ -575,8 +575,10 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
// The folded turn's summary line is the row under its prompt.
const hostId = await promptRow.getAttribute("data-history-row-id");
const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`);
await expect(summaryRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
@@ -1,197 +1,310 @@
import { describe, expect, it } from "vitest";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { describe, expect, it, vi } from "vitest";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
createHistoryStartPagination,
type HistoryStartPaginationInput,
} from "./history-start-pagination";
function createArmedHistoryStartPaginationState() {
return rearmHistoryStartPagination(createHistoryStartPaginationState());
}
const visibleHistoryStart: HistoryStartPaginationInput = {
const initial: HistoryStartPaginationInput = {
distanceFromHistoryStart: 0,
isUnderfilled: true,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "epoch-1:20",
progressKey: "20",
};
function harness(initiallyVisible = false) {
let geometry = { ...initial };
const requests: Array<(value: boolean) => void> = [];
let settles = 0;
const pagination = createHistoryStartPagination({
initiallyVisible,
read: () => geometry,
load: () => new Promise<boolean>((resolve) => requests.push(resolve)),
changed: () => {},
scheduleSettle: () => {
settles++;
},
});
return {
pagination,
requests,
get settles() {
return settles;
},
set(patch: Partial<HistoryStartPaginationInput>) {
geometry = { ...geometry, ...patch };
pagination.update();
},
};
}
describe("history start pagination", () => {
it("treats locally revealed history as progress before remote pagination", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const localReveal = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:20:local-60",
});
expect(first.shouldLoad).toBe(true);
expect(localReveal.state).toEqual({
status: "settling",
loadedProgressKey: "epoch-1:20:local-60",
});
it("waits for a delayed request and its committed page before settling", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
expect(h.settles).toBe(0);
h.requests[0](true);
await Promise.resolve();
expect(h.settles).toBe(1);
expect(h.pagination.state.visible).toBe(false);
h.set({ isUnderfilled: false });
h.pagination.settle();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("waits for user scroll intent before loading", () => {
const dormant = createHistoryStartPaginationState();
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false });
it("waits for the committed page after a request completes", async () => {
const h = harness();
h.pagination.update();
h.requests[0](true);
await Promise.resolve();
expect(h.settles).toBe(0);
h.set({ progressKey: "10" });
expect(h.settles).toBe(1);
h.pagination.dispose();
});
it("waits for anchored page geometry before authorizing another page", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const pageApplied = evaluateHistoryStartPagination(inFlight.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([
true,
false,
false,
]);
expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" });
it("continues short pages and reveals on exhaustion", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.requests).toHaveLength(2);
h.set({ hasOlderHistory: false });
h.requests[1](true);
await Promise.resolve();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("loads one page each time anchored geometry leaves and returns to history start", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const settledAway = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
progressKey: "epoch-1:10",
});
const returned = evaluateHistoryStartPagination(settledAway.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect([
first.shouldLoad,
pageApplied.shouldLoad,
settledAway.shouldLoad,
returned.shouldLoad,
]).toEqual([true, false, false, true]);
it("releases a failed or stalled request and allows retry", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
vi.advanceTimersByTime(15_000);
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
h.pagination.rearm();
expect(h.requests).toHaveLength(2);
h.requests[1](false);
await Promise.resolve();
expect(h.pagination.state.status).toBe("failed");
h.pagination.dispose();
vi.useRealTimers();
});
it("continues the same loading operation when anchored geometry remains at history start", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const pageApplied = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
const continued = settleHistoryStartPagination(pageApplied.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect(continued.shouldLoad).toBe(true);
expect(isHistoryStartLoadingOperation(first.state)).toBe(true);
expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true);
expect(isHistoryStartLoadingOperation(continued.state)).toBe(true);
it("keeps visible history visible while filling", () => {
const h = harness(true);
h.pagination.update();
expect(h.pagination.state.visible).toBe(true);
h.pagination.dispose();
});
it("latches a request that finishes without cursor progress", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart);
const away = evaluateHistoryStartPagination(duplicate.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 300,
});
expect(finished.state).toEqual({ status: "latched" });
expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([
false,
false,
false,
]);
expect(away.state).toEqual({ status: "ready" });
it("does not load a full viewport until user intent and respects inactive views", () => {
const h = harness();
h.set({ isUnderfilled: false });
expect(h.requests).toHaveLength(0);
h.set({ isReady: false });
h.pagination.rearm();
expect(h.requests).toHaveLength(0);
h.set({ isReady: true });
expect(h.requests).toHaveLength(1);
h.pagination.dispose();
});
it("allows another user attempt after a request finishes without progress", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const inFlight = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
isLoadingOlderHistory: true,
});
const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(failed.state),
visibleHistoryStart,
);
expect(failed.state).toEqual({ status: "latched" });
expect(retried.shouldLoad).toBe(true);
});
it("latches an attempt that becomes invalid before entering flight", () => {
const requested = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({
status: "latched",
});
});
it("does not mistake repeated edge observations for a finished request", () => {
const first = evaluateHistoryStartPagination(
createArmedHistoryStartPaginationState(),
visibleHistoryStart,
);
const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
expect(repeated.state).toBe(first.state);
expect(repeated.shouldLoad).toBe(false);
});
it("waits while history loading is unavailable or already active", () => {
const state = createArmedHistoryStartPaginationState();
expect([
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
.shouldLoad,
]).toEqual([false, false, false, false]);
it("preserves the reading position after an anchored page moves away from the start", async () => {
const h = harness(true);
h.set({ isUnderfilled: false });
h.pagination.rearm();
h.set({ progressKey: "10", distanceFromHistoryStart: 300 });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.requests).toHaveLength(1);
h.set({ distanceFromHistoryStart: 0 });
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
});
it("ignores a timed-out response after the reader retries", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
vi.advanceTimersByTime(15_000);
h.pagination.rearm();
h.requests[0](true);
await Promise.resolve();
expect(h.pagination.state.status).toBe("loading");
expect(h.settles).toBe(0);
h.requests[1](false);
await Promise.resolve();
expect(h.pagination.state.status).toBe("failed");
h.pagination.dispose();
vi.useRealTimers();
});
it("does not repeat a successful page that made no progress", async () => {
vi.useFakeTimers();
const h = harness();
h.pagination.update();
h.requests[0](true);
await Promise.resolve();
h.pagination.update();
expect(h.requests).toHaveLength(1);
vi.advanceTimersByTime(15_000);
expect(h.pagination.state).toEqual({ status: "failed", visible: true });
h.pagination.dispose();
vi.useRealTimers();
});
it("waits until the cursor and active history are available", () => {
const h = harness();
h.set({ isReady: false, progressKey: null });
h.set({ isReady: true, isLoadingOlderHistory: true });
h.set({ isLoadingOlderHistory: false });
expect(h.requests).toHaveLength(0);
h.set({ progressKey: "20" });
expect(h.requests).toHaveLength(1);
h.pagination.dispose();
});
it("reveals immediately on exhausted history without offering retry", () => {
const h = harness();
h.set({ hasOlderHistory: false });
expect(h.requests).toHaveLength(0);
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("keeps a settled page pending while its view is inactive", async () => {
const h = harness();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.set({ isReady: false });
h.pagination.settle();
expect(h.pagination.state.status).toBe("settling");
expect(h.requests).toHaveLength(1);
h.set({ isReady: true, isUnderfilled: false });
h.pagination.settle();
expect(h.pagination.state).toEqual({ status: "dormant", visible: true });
h.pagination.dispose();
});
it("keeps cached history visible after synchronization and subsequent pagination", async () => {
const h = harness();
h.pagination.reveal();
h.pagination.update();
h.set({ progressKey: "10" });
h.requests[0](true);
await Promise.resolve();
h.pagination.settle();
expect(h.pagination.state.visible).toBe(true);
expect(h.requests).toHaveLength(2);
h.pagination.dispose();
});
it.each([true, false])(
"handles native padding when short history has older=%s",
(hasOlderHistory) => {
const geometry = nativeHistoryStartGeometry({
contentHeight: 825,
viewportHeight: 700,
offsetY: 125,
topOverlayInset: 123,
});
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: false,
read: () => ({ ...initial, ...geometry, hasOlderHistory }),
load: () => {
requests++;
return new Promise<boolean>(() => {});
},
changed: () => {},
scheduleSettle: () => {},
});
pagination.update();
expect(requests).toBe(hasOlderHistory ? 1 : 0);
expect(pagination.state.visible).toBe(!hasOlderHistory);
pagination.dispose();
},
);
it("uses the forward native list's top edge to request older history", () => {
let offsetY = 1300;
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: true,
read: () => ({
...initial,
...nativeHistoryStartGeometry({
contentHeight: 2000,
viewportHeight: 700,
offsetY,
topOverlayInset: 123,
}),
}),
load: () => {
requests++;
return new Promise<boolean>(() => {});
},
changed: () => {},
scheduleSettle: () => {},
});
pagination.rearm();
expect(requests).toBe(0);
offsetY = 155;
pagination.update();
expect(requests).toBe(1);
pagination.dispose();
});
it.each([null, { ...initial, isReady: false }])(
"resumes a completed page after its view stays inactive beyond the deadline (%j)",
async (inactive) => {
vi.useFakeTimers();
let geometry: HistoryStartPaginationInput | null = { ...initial };
let complete!: (applied: boolean) => void;
let requests = 0;
const pagination = createHistoryStartPagination({
initiallyVisible: false,
read: () => geometry,
load: () => {
requests++;
return new Promise<boolean>((resolve) => {
complete = resolve;
});
},
changed: () => {},
scheduleSettle: () => {},
});
try {
pagination.update();
geometry = inactive;
pagination.update();
complete(true);
await Promise.resolve();
vi.advanceTimersByTime(30_000);
expect(pagination.state.status).toBe("loading");
geometry = { ...initial, progressKey: "10" };
pagination.update();
expect(pagination.state.status).toBe("settling");
geometry = inactive;
pagination.update();
pagination.settle();
vi.advanceTimersByTime(30_000);
expect(pagination.state.status).toBe("settling");
geometry = { ...initial, progressKey: "10" };
pagination.update();
pagination.settle();
expect(requests).toBe(2);
expect(pagination.state.status).toBe("loading");
vi.advanceTimersByTime(15_000);
expect(pagination.state.status).toBe("failed");
} finally {
pagination.dispose();
vi.useRealTimers();
}
},
);
@@ -1,138 +1,149 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export type HistoryStartPaginationState =
| { status: "dormant" }
| { status: "ready" }
| { status: "loading"; requestedProgressKey: string; requestObserved: boolean }
| { status: "settling"; loadedProgressKey: string }
| { status: "latched" };
export interface HistoryStartPaginationInput {
distanceFromHistoryStart: number;
isUnderfilled: boolean;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
}
export interface HistoryStartPaginationTransition {
state: HistoryStartPaginationState;
shouldLoad: boolean;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { status: "dormant" };
export interface HistoryStartPaginationState {
status: "dormant" | "ready" | "loading" | "settling" | "failed";
visible: boolean;
}
export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean {
return state.status === "loading" || state.status === "settling";
}
export function rearmHistoryStartPagination(
state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state;
}
/** Owns one pagination operation, including the initial viewport reveal. */
export function createHistoryStartPagination(input: {
initiallyVisible: boolean;
read: () => HistoryStartPaginationInput | null;
load: () => Promise<boolean>;
changed: (state: HistoryStartPaginationState) => void;
scheduleSettle: () => void;
}) {
let state: HistoryStartPaginationState = { status: "dormant", visible: input.initiallyVisible };
let requestedKey: string | null = null;
let completed = false;
let fill = false;
let generation = 0;
let timeout: ReturnType<typeof setTimeout> | undefined;
export function abandonHistoryStartPaginationRequest(
state: HistoryStartPaginationState,
requestedProgressKey: string,
): HistoryStartPaginationState {
if (
state.status !== "loading" ||
state.requestObserved ||
state.requestedProgressKey !== requestedProgressKey
) {
return state;
}
return { status: "latched" };
}
const publish = (status: HistoryStartPaginationState["status"], visible = state.visible) => {
if (status === state.status && visible === state.visible) return;
state = { status, visible };
input.changed(state);
};
const clear = () => {
generation++;
clearTimeout(timeout);
timeout = undefined;
};
const finish = (status: HistoryStartPaginationState["status"]) => {
clear();
publish(status, true);
};
const fail = () => finish("failed");
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status === "dormant") {
return { state, shouldLoad: false };
}
if (state.status === "loading") {
if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) {
return {
state: { status: "settling", loadedProgressKey: input.progressKey },
shouldLoad: false,
};
const watchProgress = (geometry: HistoryStartPaginationInput | null) => {
if (completed && !geometry?.isReady) {
clearTimeout(timeout);
timeout = undefined;
} else if (timeout === undefined) {
const request = generation;
timeout = setTimeout(() => {
timeout = undefined;
// A completed request waits for its retained view to become measurable again.
// Unfinished requests still time out even while the view is inactive.
if (request === generation && (!completed || input.read()?.isReady)) fail();
}, 15_000);
}
if (input.isLoadingOlderHistory && !state.requestObserved) {
return { state: { ...state, requestObserved: true }, shouldLoad: false };
}
if (!input.isLoadingOlderHistory && !input.hasOlderHistory) {
return { state: { status: "latched" }, shouldLoad: false };
}
if (
state.requestObserved &&
!input.isLoadingOlderHistory &&
input.progressKey === state.requestedProgressKey
) {
return { state: { status: "latched" }, shouldLoad: false };
}
return { state, shouldLoad: false };
}
};
if (state.status === "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (!isAtHistoryStart) {
return state.status === "ready"
? { state, shouldLoad: false }
: { state: { status: "ready" }, shouldLoad: false };
}
if (
state.status === "latched" ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return { state, shouldLoad: false };
}
const update = () => {
const geometry = input.read();
if (isHistoryStartLoadingOperation(state)) watchProgress(geometry);
if (!geometry) return;
if (state.status === "loading") {
if (!completed) return;
if (geometry.progressKey !== null && geometry.progressKey !== requestedKey) {
publish("settling");
input.scheduleSettle();
} else if (!geometry.hasOlderHistory) {
finish("dormant");
}
return;
}
if (state.status === "settling" || state.status === "failed" || !geometry.isReady) return;
const atStart = geometry.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (state.status === "dormant" && !geometry.isUnderfilled) {
publish("dormant", true);
return;
}
if (!atStart || (!geometry.isUnderfilled && state.status !== "ready")) return;
if (!geometry.hasOlderHistory) {
finish("dormant");
return;
}
if (geometry.isLoadingOlderHistory || geometry.progressKey === null) return;
fill = geometry.isUnderfilled;
requestedKey = geometry.progressKey;
completed = false;
publish("loading");
const request = ++generation;
watchProgress(geometry);
void input.load().then(
(applied) => {
if (request !== generation) return undefined;
if (!applied) {
fail();
return undefined;
}
completed = true;
update();
return undefined;
},
() => {
if (request === generation) fail();
},
);
};
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
get state() {
return state;
},
shouldLoad: true,
};
}
export function settleHistoryStartPagination(
state: HistoryStartPaginationState,
input: HistoryStartPaginationInput,
): HistoryStartPaginationTransition {
if (state.status !== "settling") {
return { state, shouldLoad: false };
}
const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX;
if (
!isAtHistoryStart ||
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return {
state: isAtHistoryStart ? { status: "latched" } : { status: "ready" },
shouldLoad: false,
};
}
return {
state: {
status: "loading",
requestedProgressKey: input.progressKey,
requestObserved: false,
update,
settle() {
if (state.status !== "settling") return;
const geometry = input.read();
if (!geometry?.isReady) return;
clear();
if (fill && !geometry.isUnderfilled) {
publish("dormant", true);
} else if (geometry.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
publish("ready", true);
} else if (!geometry.hasOlderHistory) {
publish("dormant", true);
} else {
publish("ready");
update();
}
},
shouldLoad: true,
rearm() {
if (state.status === "dormant" || state.status === "failed") publish("ready", true);
update();
},
reveal() {
publish(state.status, true);
},
reset(visible: boolean) {
clear();
publish("dormant", visible);
},
dispose: clear,
};
}
@@ -0,0 +1,12 @@
export function nativeHistoryStartGeometry(input: {
contentHeight: number;
viewportHeight: number;
offsetY: number;
topOverlayInset: number;
}) {
const contentHeight = input.contentHeight - 32 - input.topOverlayInset;
return {
distanceFromHistoryStart: Math.max(0, input.offsetY - input.topOverlayInset - 32),
isUnderfilled: contentHeight < input.viewportHeight,
};
}
@@ -608,8 +608,11 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", ()
display: "collapseTurns",
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
});
@@ -636,12 +639,14 @@ it("keeps streaming replies native, then folds work without losing manual expans
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
const expandedTurnHostIds = new Set(["prompt"]);
const opened = present({ ...completed, expandedTurnHostIds });
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"tool-one",
"probe-plugin/reply/0",
]);
@@ -649,6 +654,7 @@ it("keeps streaming replies native, then folds work without losing manual expans
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
});
@@ -675,7 +681,7 @@ it.each(["full", "collapseToolCalls"] as const)(
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.turnsByHostId.size).toBe(0);
expect(result.turns.summaryRowsByHostId.size).toBe(0);
},
);
@@ -697,7 +703,13 @@ it("opening a turn reveals individual calls rather than another tool group", ()
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"tool-one",
"tool-two",
"reply",
]);
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
@@ -739,7 +751,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)(
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
],
});
// Both the original partial group and its replacement resolve directly to the turn.
// Both the original partial group and its replacement resolve directly to the turn's host.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
@@ -788,7 +800,7 @@ it("does not reopen the previous turn while a new run waits for its first row",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
@@ -810,7 +822,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope
isTurnActive: true,
activeTurnId: null,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
@@ -867,7 +879,13 @@ it.each([false, true])(
const closed = present(input);
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
expect(opened.tail).toEqual(tail);
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"thought",
"tool-one",
"reply",
]);
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
expect(opened.groupsByHostId.size).toBe(0);
},
@@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
previousSummaryRowsByHostId: new Map(),
});
/** Source messages reach plugins before display projection. */
@@ -135,7 +135,8 @@ export function createStreamPresentation() {
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
previousSummaryRowsByHostId:
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model";
import type { StreamItem } from "@/types/stream";
const message: StreamItem = {
kind: "assistant_message",
@@ -7,6 +8,27 @@ const message: StreamItem = {
text: "First\n\nSecond",
timestamp: new Date(0),
};
const prompt: StreamItem = {
kind: "user_message",
id: "p",
text: "Fix it",
timestamp: new Date(0),
};
const thought: StreamItem = {
kind: "thought",
id: "t",
text: "Reading",
timestamp: new Date(0),
status: "ready",
};
const summary: StreamItem = {
kind: "turn_summary",
id: "turn-summary:p",
hostId: "p",
expanded: false,
timestamp: new Date(0),
tools: summarizeToolCalls([]),
};
describe("whole-message spacing", () => {
it("compacts only the gap to its turn footer", () => {
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
@@ -17,4 +39,18 @@ describe("whole-message spacing", () => {
it("keeps separate messages separated", () => {
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
});
it("sets a prompt the same distance above whatever answers it", () => {
const belowPrompt = getGapBetweenStreamItems(prompt, thought);
expect(belowPrompt).toBeGreaterThan(0);
expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt);
expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt);
});
it("spaces a turn's summary row like the work rows it stands for", () => {
expect(getGapBetweenStreamItems(summary, message)).toBe(
getGapBetweenStreamItems(thought, message),
);
expect(getGapBetweenStreamItems(thought, summary)).toBe(
getGapBetweenStreamItems(thought, thought),
);
});
});
+12 -11
View File
@@ -11,8 +11,12 @@ export function getAssistantMessageSpacing(params: {
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
/** A row of the agent's work, including the summary row that stands in for a folded turn's. */
const isWorkItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" ||
item?.kind === "thought" ||
item?.kind === "todo_list" ||
item?.kind === "turn_summary";
export function getGapBetweenStreamItems(
item: StreamItem | null,
@@ -25,19 +29,16 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
if (isUserMessageItem(item)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
if (isWorkItem(item) && isWorkItem(belowItem)) {
return 0;
}
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
return SPACING[4];
+111 -101
View File
@@ -25,6 +25,9 @@ import {
} from "@legendapp/list/react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Button } from "@/components/ui/button";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useTranslation } from "react-i18next";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -38,15 +41,11 @@ import { createNewRowEstimates } from "./new-row-estimates";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import { nativeHistoryStartGeometry } from "./native-history-start-geometry";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
@@ -108,6 +107,16 @@ function distanceFromEnd(input: {
}): number {
return Math.max(0, input.contentHeight - input.viewportHeight - input.offsetY);
}
const viewportStyle: ViewStyle = { flex: 1 };
const initialFillStyle: ViewStyle = {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: "center",
justifyContent: "center",
};
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -125,6 +134,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
@@ -135,6 +146,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
baseListContentContainerStyle,
strategy,
} = props;
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
@@ -157,10 +170,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isReleasedAtEndRef = useRef(false);
const programmaticScrollEventBudgetRef = useRef(0);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
const historyItems = useMemo(() => {
@@ -191,48 +200,36 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
return {
distanceFromHistoryStart: metrics.offsetY,
...nativeHistoryStartGeometry({
contentHeight: metrics.contentHeight,
viewportHeight: metrics.viewportHeight,
offsetY: metrics.offsetY,
topOverlayInset,
}),
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
isReady:
isActive &&
isAuthoritativeHistoryReady &&
historyStartReadyRef.current &&
hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
};
});
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (transition.shouldLoad) {
const requestedProgressKey = olderHistoryProgressKey;
if (requestedProgressKey === null) {
return;
}
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
}
},
const { state: historyStartPaginationState, controller: historyStartPagination } =
useHistoryStartPagination({
initiallyVisible: !isInitialHistoryLoading,
read: getHistoryStartPaginationInput,
load: async () => onNearHistoryStart(),
scheduleSettle: () => scheduleHistoryStartSettle(),
});
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const isHistoryStartSettling = useStableEvent(
() => historyStartPagination.state.status === "settling",
);
const evaluateHistoryStart = useStableEvent(() => {
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
});
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const retryHistoryStart = useStableEvent(() => historyStartPagination.rearm());
const scheduleHistoryStartSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -240,15 +237,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: requestAnimationFrame,
cancelFrame: cancelAnimationFrame,
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isSettling: () => isHistoryStartSettling(),
isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory,
onSettle: () => {
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
getHistoryStartPaginationInput(),
);
applyHistoryStartPaginationTransition(transition);
},
onSettle: () => historyStartPagination.settle(),
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -390,10 +381,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
}, [clearPendingUserScrollEnd, disposeHistoryStart, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -506,6 +498,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
finishInitialFill();
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
@@ -514,14 +507,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// no row LegendList may anchor a history page on. The gesture's end decides following.
disclosureHold.cancel();
bottomAnchorController.beginUserScroll();
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed !== historyStartPaginationStateRef.current) {
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
}
historyStartPagination.rearm();
});
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
// A gesture ends at the end when the finger lifts at or past it, or when its momentum stops there.
// A release past the end bounces back to it on iOS, and lines that land during the bounce scroll
// in as they do while following. Momentum stopping short of the end, or output that arrived below
@@ -598,7 +590,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
publishNearBottom();
}
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartSettle();
}
});
@@ -611,19 +603,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: "native-virtualized",
};
bottomAnchorController.handleContentSizeChange();
evaluateHistoryStart();
});
useEffect(() => {
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartSettle,
isHistoryStartSettling,
]);
// LegendList re-renders a mounted row only when its item or `extraData` changes, not when
@@ -658,51 +654,65 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<View style={historyStartSlotStyle} testID="older-history-slot">
{isLoadingOperation ? (
{isLoadingOperation && (
<View testID="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<Button variant="ghost" size="sm" onPress={retryHistoryStart}>
{t("paneFind.retry")}
</Button>
)}
</View>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, retryHistoryStart, t]);
return (
<LegendList<StreamItem>
ref={listRef}
data={rows}
renderItem={renderItem}
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
automaticallyAdjustContentInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
contentInsetAdjustmentBehavior="never"
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
alignItemsAtEnd
initialScrollAtEnd
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
getEstimatedItemSize={getEstimatedItemSize}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
/>
<View style={viewportStyle}>
<LegendList<StreamItem>
ref={listRef}
data={rows}
renderItem={renderItem}
extraData={renderHistoryMountedRow}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={historyStartContent}
ListFooterComponent={liveAuxiliaryContent ?? undefined}
contentContainerStyle={listContentContainerStyle}
automaticallyAdjustContentInsets={false}
automaticallyAdjustsScrollIndicatorInsets={false}
contentInsetAdjustmentBehavior="never"
style={[listStyle, { opacity: historyStartPaginationState.visible ? 1 : 0 }]}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
alignItemsAtEnd
initialScrollAtEnd
drawDistance={NATIVE_STREAM_DRAW_DISTANCE}
getEstimatedItemSize={getEstimatedItemSize}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
/>
{!historyStartPaginationState.visible ? (
<View pointerEvents="none" testID="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</View>
);
}
@@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
onNearHistoryStart: async () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -152,7 +152,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
onNearHistoryStart: async () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
@@ -274,7 +274,7 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
onNearHistoryStart: async () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
+70 -77
View File
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useTranslation } from "react-i18next";
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
@@ -30,15 +31,10 @@ import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./
import { useRevisedHistoryRows } from "./history-row-revision";
import { createStreamStrategy } from "./strategy";
import {
abandonHistoryStartPaginationRequest,
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
isHistoryStartLoadingOperation,
rearmHistoryStartPagination,
settleHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useHistoryStartPagination } from "./use-history-start-pagination";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -77,6 +73,14 @@ const historyStartSlotStyle: CSSProperties = {
};
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
const initialFillStyle: CSSProperties = {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
};
const streamRowStyle: CSSProperties = {
display: "flex",
@@ -298,6 +302,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange,
onReadingPositionChange,
onNearHistoryStart,
@@ -318,6 +324,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
[historyMounted, historyVirtualized, inputSegments],
);
const isActive = useRetainedPanelActive();
const { t } = useTranslation();
const rowsHostRef = useRef<HTMLDivElement | null>(null);
const auxiliaryRef = useRef<HTMLDivElement | null>(null);
const isActiveRef = useRef(isActive);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
@@ -351,10 +360,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const [historyStartPaginationState, setHistoryStartPaginationState] = useState(
createHistoryStartPaginationState,
);
const historyStartPaginationStateRef = useRef(historyStartPaginationState);
const readingAnchor = useMemo(createReadingAnchor, []);
const measuredRowHeights = useRef(new Map<string, { width: number; height: number }>());
const historyStartSettleSchedulerRef = useRef<HistoryStartSettleScheduler | null>(null);
@@ -481,60 +486,36 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
return {
distanceFromHistoryStart: scrollContainer.scrollTop,
isUnderfilled:
(rowsHostRef.current?.getBoundingClientRect().height ?? 0) +
(auxiliaryRef.current?.getBoundingClientRect().height ?? 0) <
scrollContainer.clientHeight,
hasOlderHistory,
isLoadingOlderHistory,
// An explicit jump owns the landing position. Revealing another local
// page here changes the virtual index before the target has mounted.
isReady: historyStartReadyRef.current && bottomAnchorSettled && !isJumpSettling(),
isReady:
historyStartReadyRef.current &&
isAuthoritativeHistoryReady &&
bottomAnchorSettled &&
!isJumpSettling(),
progressKey: olderHistoryProgressKey,
};
});
const applyHistoryStartPaginationTransition = useStableEvent(
(transition: HistoryStartPaginationTransition) => {
const previousState = historyStartPaginationStateRef.current;
historyStartPaginationStateRef.current = transition.state;
if (transition.state !== previousState) {
setHistoryStartPaginationState(transition.state);
}
if (!transition.shouldLoad || olderHistoryProgressKey === null) {
return;
}
const requestedProgressKey = olderHistoryProgressKey;
void (async () => {
const started = await onNearHistoryStart();
if (started === true) {
return;
}
applyHistoryStartPaginationTransition({
state: abandonHistoryStartPaginationRequest(
historyStartPaginationStateRef.current,
requestedProgressKey,
),
shouldLoad: false,
});
})();
},
const { state: historyStartPaginationState, controller: historyStartPagination } =
useHistoryStartPagination({
initiallyVisible: !isInitialHistoryLoading,
read: getHistoryStartPaginationInput,
load: async () => onNearHistoryStart(),
scheduleSettle: () => scheduleHistoryStartPrependSettle(),
});
const evaluateHistoryStart = useStableEvent(() => historyStartPagination.update());
const disposeHistoryStart = useStableEvent(() => historyStartPagination.dispose());
const isHistoryStartSettling = useStableEvent(
() => historyStartPagination.state.status === "settling",
);
const evaluateHistoryStart = useStableEvent(() => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = evaluateHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
});
const rearmHistoryStartFromUserIntent = useStableEvent(() => {
const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current);
if (rearmed === historyStartPaginationStateRef.current) {
return;
}
historyStartPaginationStateRef.current = rearmed;
setHistoryStartPaginationState(rearmed);
evaluateHistoryStart();
});
const finishInitialFill = useStableEvent(() => historyStartPagination.reveal());
const rearmHistoryStartFromUserIntent = useStableEvent(() => historyStartPagination.rearm());
const scheduleHistoryStartPrependSettle = useStableEvent(() => {
let scheduler = historyStartSettleSchedulerRef.current;
if (!scheduler) {
@@ -542,22 +523,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
settleFrames: HISTORY_START_SETTLE_FRAMES,
requestFrame: (callback) => window.requestAnimationFrame(callback),
cancelFrame: (frame) => window.cancelAnimationFrame(frame),
isSettling: () => historyStartPaginationStateRef.current.status === "settling",
isSettling: () => isHistoryStartSettling(),
isLoading: () => {
const input = getHistoryStartPaginationInput();
return !input || input.isLoadingOlderHistory;
},
onSettle: () => {
const input = getHistoryStartPaginationInput();
if (!input) {
return;
}
const transition = settleHistoryStartPagination(
historyStartPaginationStateRef.current,
input,
);
applyHistoryStartPaginationTransition(transition);
},
onSettle: () => historyStartPagination.settle(),
});
historyStartSettleSchedulerRef.current = scheduler;
}
@@ -794,6 +765,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
finishInitialFill();
cancelPendingStickToBottom();
if (followOutputRef.current) {
setFollowOutput(false);
@@ -801,6 +773,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
rearmHistoryStartFromUserIntent();
});
useEffect(() => {
if (revealInitialHistory) finishInitialFill();
}, [finishInitialFill, revealInitialHistory]);
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
@@ -846,10 +822,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
disposeHistoryStart();
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart]);
}, [disposeHistoryStart, evaluateHistoryStart]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -938,16 +915,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics();
}
evaluateHistoryStart();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartPrependSettle();
}
}, [
evaluateHistoryStart,
hasOlderHistory,
isActive,
isAuthoritativeHistoryReady,
isLoadingOlderHistory,
olderHistoryProgressKey,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -986,7 +965,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastObservedViewportGeometryRef.current = nextGeometry;
}
reconcileReadingPosition();
if (historyStartPaginationStateRef.current.status === "settling") {
if (isHistoryStartSettling()) {
scheduleHistoryStartPrependSettle();
}
updateScrollMetrics();
@@ -1020,6 +999,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
isActive,
reportReadingPosition,
scheduleHistoryStartPrependSettle,
isHistoryStartSettling,
forceStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
@@ -1241,8 +1221,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Detached reading has one owner: reconcileReadingPosition.
overflowAnchor: followOutput ? "auto" : "none",
scrollbarWidth: overlayScrollbarEnabled ? "none" : undefined,
opacity: historyStartPaginationState.visible ? 1 : 0,
};
}, [followOutput, isMobileBreakpoint, scrollEnabled]);
}, [followOutput, historyStartPaginationState.visible, isMobileBreakpoint, scrollEnabled]);
const viewportStyle = useMemo(
(): CSSProperties => ({
position: "relative",
@@ -1319,14 +1300,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState);
return (
<div style={historyStartSlotStyle} data-testid="older-history-slot">
{isLoadingOperation ? (
{isLoadingOperation && (
<div data-testid="load-older-history-spinner">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
)}
{!isLoadingOperation &&
historyStartPaginationState.status === "failed" &&
hasOlderHistory && (
<button type="button" onClick={rearmHistoryStartFromUserIntent}>
{t("paneFind.retry")}
</button>
)}
</div>
);
}, [historyStartPaginationState]);
}, [historyStartPaginationState, hasOlderHistory, rearmHistoryStartFromUserIntent, t]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -1346,7 +1334,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
<div style={streamRowsHostStyle}>
<div ref={rowsHostRef} style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
@@ -1374,10 +1362,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
...mountedRows,
]}
</div>
{liveAuxiliary}
<div ref={auxiliaryRef}>{liveAuxiliary}</div>
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
</div>
{!historyStartPaginationState.visible ? (
<div data-testid="agent-initial-fill" style={initialFillStyle}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</div>
) : null}
{scrollEnabled && !isMobileBreakpoint ? (
<DomOverlayScrollbar
scrollContainerRef={scrollContainerRef}
+3 -1
View File
@@ -85,11 +85,13 @@ export interface StreamRenderInput {
viewportRef: RefObject<StreamViewportHandle | null>;
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
onNearBottomChange: (value: boolean) => void;
// The history row under the top of the viewport, for surfaces that mark where the reader
// is in the transcript. Only the web viewport measures it today.
onReadingPositionChange?: (rowId: string | null) => void;
onNearHistoryStart: () => boolean | Promise<boolean>;
onNearHistoryStart: () => Promise<boolean>;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
@@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
previousSummaryRowsByHostId: new Map(),
...options,
});
}
@@ -73,17 +73,29 @@ describe("collapseTurns", () => {
pluginReply("final", "right"),
];
const result = collapse(items);
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
expect(ids(result.tail)).toEqual([
"prompt",
"turn-summary:prompt",
"math/final/left",
"math/final/right",
]);
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
expect(expanded.tail).toEqual(items);
expect(ids(expanded.tail)).toEqual([
"prompt",
"turn-summary:prompt",
"acknowledgment",
"call",
"math/final/left",
"math/final/right",
]);
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
});
it("folds intermediate plugin replies even without tool calls", () => {
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]);
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
});
@@ -93,7 +105,7 @@ describe("collapseTurns", () => {
const card = { ...pluginReply("card"), sourceKind };
const items = [user("prompt"), shell("call"), card];
expect(collapse(items).tail).toEqual(items);
expect(collapse(items).turnsByHostId.size).toBe(0);
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
},
);
@@ -102,38 +114,41 @@ describe("collapseTurns", () => {
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
});
// A history row renders again only when its item changes, so a prompt that is already retained
// when its turn closes needs its row revised to show the summary line.
it("reports the hosts whose summary line appeared, changed or went away", () => {
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
// A mounted row renders again only when its item changes, so a turn whose work and open state
// are unchanged keeps its summary row object across folds of the same history.
it("keeps a summary row until its turn's work or open state changes", () => {
const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
isLatestTurnOpen: true,
});
expect([...open.updatedHostIds]).toEqual(["u1"]);
const closed = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: open.turnsByHostId,
},
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
);
expect([...closed.updatedHostIds]).toEqual(["u2"]);
const unchanged = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: closed.turnsByHostId,
},
);
expect(unchanged.updatedHostIds.size).toBe(0);
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
const moreWork = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
{ previousTurnsByHostId: unchanged.turnsByHostId },
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
);
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
previousTurnsByHostId: moreWork.turnsByHostId,
expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1"));
expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2"));
const opened = collapse(moreWork.tail, {
previousSummaryRowsByHostId: moreWork.summaryRowsByHostId,
expandedHostIds: new Set(["u1"]),
});
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
});
it("maps hidden rows only while their turn is closed", () => {
it("stands at its host's timeline position, taking a new row when that position lands", () => {
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
const cursor = { epoch: "epoch-1", seq: 7 };
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor);
const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId });
expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1"));
});
it("maps hidden rows to their turn's host only while the turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
@@ -168,15 +183,15 @@ describe("collapseTurns", () => {
reply("done"),
].map((item) => Object.assign(item, { turnId: "same-run" }));
const result = collapse(items);
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
commandCount: 1,
otherToolCount: 0,
paseoActions: { "Created agent": 1 },
});
});
it("folds a finished turn's work into its user message and keeps the reply", () => {
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
thought("t1"),
@@ -186,14 +201,16 @@ describe("collapseTurns", () => {
reply("final"),
]);
expect(ids(result.tail)).toEqual(["u1", "final"]);
expect(result.turnsByHostId.get("u1")).toEqual({
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
expect(result.tail[1]).toEqual({
kind: "turn_summary",
id: "turn-summary:u1",
hostId: "u1",
timestamp: at,
expanded: false,
summary: {
tools: expect.objectContaining({ commandCount: 2 }),
},
tools: expect.objectContaining({ commandCount: 2 }),
});
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
});
@@ -202,9 +219,9 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["u1"]),
});
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
});
it("leaves the latest turn alone while it is still producing output", () => {
@@ -212,8 +229,8 @@ describe("collapseTurns", () => {
const result = collapse(tail, { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
});
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
@@ -226,8 +243,8 @@ describe("collapseTurns", () => {
reply("r2"),
]);
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
});
it("does not fold a turn that only thought before its reply", () => {
@@ -236,7 +253,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
expect(result.summaryRowsByHostId.size).toBe(0);
});
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
@@ -245,7 +262,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
expect(result.summaryRowsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
@@ -254,15 +271,15 @@ describe("collapseTurns", () => {
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
});
it("keeps mid-turn messages visible when work is folded", () => {
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
for (const item of tail) item.turnId = "same-turn";
const result = collapse(tail);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
@@ -272,7 +289,7 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
@@ -282,8 +299,6 @@ describe("collapseTurns", () => {
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
});
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
tools: { commandCount: 3 },
});
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
});
});
@@ -3,35 +3,25 @@ import {
getStreamItemMessageId,
type StreamItem,
type ThoughtItem,
type TimelinePosition,
type ToolCallItem,
type TurnSummaryItem,
} from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The input or first loaded row that carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
}
export interface TurnCollapse {
tail: StreamItem[];
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** Rows of expanded turns, which render inside the turn's indent rail. */
nestedItemIds: ReadonlySet<string>;
/** The turn a folded message belongs to, for surfaces that must reveal it. */
/** The host of the turn a folded message belongs to, for surfaces that must reveal it. */
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
/**
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
* history row renders again only when its item changes, so these rows are revised.
* The summary rows by host. A mounted row renders again only when its item changes, so a fold
* keeps the previous row object for a turn whose work and open state are unchanged.
*/
updatedHostIds: ReadonlySet<string>;
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
export interface CollapseTurnsInput {
@@ -41,39 +31,39 @@ export interface CollapseTurnsInput {
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** The previous fold of the same history, whose unchanged summary rows are reused. */
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
const EMPTY_IDS: ReadonlySet<string> = new Set();
const EMPTY_ROWS: ReadonlyMap<string, TurnSummaryItem> = new Map();
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
function summarizeTurn(
items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
): OverviewSummary {
const calls: (ToolCallItem | ThoughtItem)[] = [];
for (const item of items) {
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { tools: summarizeToolCalls(calls) };
return summarizeToolCalls(calls);
}
function hasWork(summary: TurnSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
function hasWork(tools: OverviewSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
return (
a.expanded === b.expanded &&
hasSameCounts(countsA, countsB) &&
hasSameCounts(actionsA, actionsB)
);
function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean {
const { paseoActions: actionsA, ...countsA } = a;
const { paseoActions: actionsB, ...countsB } = b;
return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB);
}
function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) {
return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq);
}
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
@@ -81,19 +71,32 @@ function hasSameCounts(a: Record<string, number>, b: Record<string, number>): bo
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}
function findUpdatedHosts(
previous: ReadonlyMap<string, CollapsedTurn>,
next: ReadonlyMap<string, CollapsedTurn>,
): ReadonlySet<string> {
const updated = new Set<string>();
for (const [hostId, turn] of next) {
const before = previous.get(hostId);
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
function summaryRow(
host: StreamItem,
expanded: boolean,
tools: OverviewSummary,
previous: TurnSummaryItem | undefined,
): TurnSummaryItem {
if (
previous &&
previous.expanded === expanded &&
hasSameWork(previous.tools, tools) &&
hasSamePosition(previous.timelineCursor, host.timelineCursor)
) {
return previous;
}
for (const hostId of previous.keys()) {
if (!next.has(hostId)) updated.add(hostId);
}
return updated.size === 0 ? EMPTY_IDS : updated;
return {
kind: "turn_summary",
id: `turn-summary:${host.id}`,
// The row stands at its host's place in the timeline, so the reading position over it
// still resolves to the turn's prompt.
...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}),
...(host.turnId !== undefined ? { turnId: host.turnId } : {}),
timestamp: host.timestamp,
hostId: host.id,
expanded,
tools,
};
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
@@ -124,15 +127,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num
}
/**
* Folds each answered turn into its user message: everything the agent did before its last
* message collapses into one summary line, and that message stays in the chat. A turn folds
* only once it is complete, its user message is loaded, and it sent messages or called tools
* before its reply. Reasoning is revealed only when the turn opens.
* Folds each answered turn behind a summary row under its user message: everything the agent
* did before its last message collapses into that row, and the last message stays in the chat.
* A turn folds only once it is complete, its user message is loaded, and it sent messages or
* called tools before its reply. Reasoning is revealed only when the turn opens.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
const output: StreamItem[] = [];
const turnsByHostId = new Map<string, CollapsedTurn>();
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
@@ -152,13 +155,19 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
const tools = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const summary = summaryRow(
host,
expanded,
tools,
input.previousSummaryRowsByHostId.get(host.id),
);
summaryRowsByHostId.set(host.id, summary);
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
@@ -173,6 +182,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
else hostIdByHiddenRowId.set(item.id, host.id);
}
output.push(
summary,
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
);
output.push(...tail.slice(replyIndex, runEnd));
@@ -190,26 +200,20 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
flushRun(tail.length);
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
if (turnsByHostId.size === 0) {
if (summaryRowsByHostId.size === 0) {
return {
tail,
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
updatedHostIds,
summaryRowsByHostId: EMPTY_ROWS,
};
}
return {
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
tail: output,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
updatedHostIds,
summaryRowsByHostId,
};
}
@@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { ExpandableBadge } from "@/components/message";
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
import type { CollapsedTurn } from "./model";
import type { TurnSummaryItem } from "@/types/stream";
/**
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
* Pressing it opens the turn's rows beneath it.
*/
export const TurnSummaryLine = memo(function TurnSummaryLine({
turn,
item,
onExpandedChange,
}: {
turn: CollapsedTurn;
item: TurnSummaryItem;
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
[hold, onExpandedChange, turn.expanded, turn.hostId],
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
[hold, onExpandedChange, item.expanded, item.hostId],
);
return (
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
testID="turn-summary"
label={label}
icon={Summary}
isExpanded={turn.expanded}
isExpanded={item.expanded}
onToggle={toggle}
borderlessWhenExpanded
/>
@@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
alignSelf: "stretch",
minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
@@ -0,0 +1,33 @@
import { useRef, useState } from "react";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
createHistoryStartPagination,
type HistoryStartPaginationInput,
type HistoryStartPaginationState,
} from "./history-start-pagination";
export function useHistoryStartPagination(input: {
initiallyVisible: boolean;
read: () => HistoryStartPaginationInput | null;
load: () => Promise<boolean>;
scheduleSettle: () => void;
}) {
const read = useStableEvent(input.read);
const load = useStableEvent(input.load);
const scheduleSettle = useStableEvent(input.scheduleSettle);
const [state, setState] = useState<HistoryStartPaginationState>(() => ({
status: "dormant",
visible: input.initiallyVisible,
}));
const controller = useRef<ReturnType<typeof createHistoryStartPagination> | null>(null);
if (!controller.current) {
controller.current = createHistoryStartPagination({
initiallyVisible: input.initiallyVisible,
read,
load,
scheduleSettle,
changed: setState,
});
}
return { state, controller: controller.current };
}
+25 -45
View File
@@ -248,6 +248,8 @@ export interface AgentStreamViewProps {
turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
isInitialHistoryLoading?: boolean;
revealInitialHistory?: boolean;
/** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayTailClearance?: number;
/** Bottom offset required for controls floating above that overlay. */
@@ -345,6 +347,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
isInitialHistoryLoading,
revealInitialHistory,
bottomOverlayTailClearance = 0,
bottomOverlayControlClearance,
toast,
@@ -689,26 +693,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return revealLoadedHistory(messageId);
});
// Read through stable events so a collapse rerun does not change the renderer identity;
// rows whose turn state changed are revised through `historyRowRevision`.
const getCollapsedTurn = useStableEvent((hostId: string) =>
presentation.turns.turnsByHostId.get(hostId),
);
// Read through a stable event so a collapse rerun does not change the renderer identity;
// nested rows are revised through `historyRowRevision`.
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
const { nestedItemIds } = presentation.turns;
if (nestedItemIds.has(layoutItem.item.id)) {
const { item } = layoutItem;
if (nestedItemIds.has(item.id)) {
const below = layoutItem.belowItem;
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
}
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -728,25 +729,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -931,17 +915,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderToolCallItem(layoutItem, item);
case "tool_call":
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
return renderToolCallItem(layoutItem, item);
case "turn_summary":
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -972,7 +949,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderUserMessageItem,
renderAssistantMessageItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
@@ -1151,19 +1127,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: {
has: (id: string) =>
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id),
},
displayStateById: {
// An opened lone call also marks its future group open; that row stays unrevised
// until a group actually forms there.
has: (id: string) =>
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
expandedTurnHostIds.has(id) ||
nestedItemIds.has(id),
},
globalDisplayState: isMobile,
@@ -1171,11 +1145,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[
expandedToolCallGroups,
getToolCallGroup,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
updatedTurnHostIds,
],
);
@@ -1209,6 +1181,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
@@ -1351,6 +1325,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.isInitialHistoryLoading !== right.isInitialHistoryLoading) {
reasons.push("isInitialHistoryLoading");
}
if (left.revealInitialHistory !== right.revealInitialHistory) {
reasons.push("revealInitialHistory");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
@@ -65,6 +65,7 @@ export function estimateStreamItemHeight({
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
case "turn_summary":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "todo_list":
return 144;
+55 -50
View File
@@ -1,3 +1,4 @@
import { SidebarViewSlot } from "@/plugins/sidebar-view";
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
@@ -642,31 +643,33 @@ function MobileSidebar({
)}
<SidebarSeparator />
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={handleWorkspacePress}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarFooter
handleOpenProject={handleOpenProject}
@@ -838,31 +841,33 @@ function DesktopSidebar({
<SidebarNavRows style={sidebarHeaderGroupStyle} onBeforeNavigate={closeSidebar} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarViewSlot dismiss={closeSidebar}>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
</SidebarViewSlot>
<SidebarCalloutSlot />
+58 -25
View File
@@ -557,6 +557,15 @@ interface AssistantTurnFooterProps {
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
minWidth: 0,
minHeight: 24,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
actions: {
height: 24,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
marginTop: 0,
marginLeft: -theme.spacing[1],
},
labelWrapper: {
position: "relative",
labelButton: {
flex: 1,
minHeight: 24,
flexDirection: "row",
alignItems: "center",
minWidth: 0,
},
labelSizer: {
label: {
width: "100%",
flexShrink: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
overlappingLabel: {
marginLeft: "-100%",
},
hiddenLabel: {
opacity: 0,
},
labelOverlay: {
position: "absolute",
top: 0,
left: 0,
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
},
}));
const TIMESTAMP_REVEAL_MS = 3000;
@@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{getContent || canFork ? (
<View style={assistantTurnFooterStylesheet.actions}>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
</View>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
style={assistantTurnFooterStylesheet.labelButton}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : primaryLabel}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel}
{/* Both labels participate in layout at the available width. Overlapping their
columns reserves the taller wrapped height without measuring text or characters. */}
<Text
testID={!showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{primaryLabel}
</Text>
{canSwap ? (
<Text
testID={showTimestamp ? "turn-footer-timing-label" : undefined}
aria-hidden={!showTimestamp}
style={[
assistantTurnFooterStylesheet.label,
assistantTurnFooterStylesheet.overlappingLabel,
!showTimestamp && assistantTurnFooterStylesheet.hiddenLabel,
]}
>
{timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : primaryLabel}
</Text>
</View>
) : null}
</Pressable>
) : null}
</View>
+14 -6
View File
@@ -88,6 +88,7 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import { pasteLinkedClipboardText } from "@/composer/pasted-text";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
EditingTextInput,
@@ -513,7 +514,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
return native instanceof HTMLElement ? native : null;
}
interface PasteImagesEffectArgs {
interface PasteEffectArgs {
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
@@ -522,7 +523,7 @@ interface PasteImagesEffectArgs {
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
}
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
function usePasteEffect(args: PasteEffectArgs): void {
const {
textarea,
isConnected,
@@ -533,16 +534,20 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
} = args;
useEffect(() => {
if (!isWeb || !onAddImages) return;
if (!isWeb) return;
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
if (disabled || isDictating || isRealtimeVoiceForCurrentAgent) return;
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData);
if (imageFiles.length === 0) return;
if (imageFiles.length === 0) {
if (pasteLinkedClipboardText(textarea, event.clipboardData)) event.preventDefault();
return;
}
if (!isConnected || !onAddImages) return;
event.preventDefault();
@@ -1786,6 +1791,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleSendMessage,
handleQueueMessage,
});
if (hasFinePointer()) {
textInputRef.current?.focus();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
const handleAlternateSendAction = useCallback(() => {
@@ -1811,7 +1819,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleAlternateSendAction();
}, [alternateSendAction, handleAlternateSendAction]);
usePasteImagesEffect({
usePasteEffect({
textarea: webTextArea,
isConnected,
disabled,
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, it } from "vitest";
import { createMarkdownClipboardContent } from "@/utils/rich-clipboard";
import { pasteLinkedClipboardText } from "./pasted-text.web";
const inputs: HTMLTextAreaElement[] = [];
function editor(): HTMLTextAreaElement {
const input = document.createElement("textarea");
document.body.append(input);
inputs.push(input);
return input;
}
afterEach(() => {
for (const input of inputs.splice(0)) input.remove();
});
function clipboard(html: string, text: string): DataTransfer {
const data = new DataTransfer();
data.setData("text/html", html);
data.setData("text/plain", text);
return data;
}
describe("pasted prompt links", () => {
it.each([
"https://example.com/1",
"https://example.com/日本語",
"<https://example.com/a b>",
"https://例え.テスト/issue",
])("keeps complete Markdown from Copy with destination %s", (destination) => {
const source = `| Resource | Status |\n| --- | --- |\n| [issue](${destination}) | ~~closed~~ |`;
const content = createMarkdownClipboardContent(source);
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(content.html, content.plainText))).toBe(false);
expect(input.value).toBe("");
});
it("converts external links when plain text names the resource but omits a destination", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<a href="https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E">日本語</a> and <a href="https://example.com/missing">another</a>',
"[日本語](https://example.com/日本語) and another",
),
),
).toBe(true);
expect(input.value).toBe(
"[日本語](https://example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E) and [another](https://example.com/missing)",
);
});
it("preserves a labeled resource URL as Markdown", () => {
const data = clipboard(
'Hey can you take a look at <a href="https://example.com/issues/39150">this FR</a> and...',
"Hey can you take a look at this FR and...",
);
const input = editor();
expect(pasteLinkedClipboardText(input, data)).toBe(true);
expect(input.value).toBe(
"Hey can you take a look at [this FR](https://example.com/issues/39150) and...",
);
});
it("preserves external linked table rows and columns", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<table><thead><tr><th>Resource</th><th>Status</th></tr></thead><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr></tbody></table>',
"Resource\tStatus\nIssue\tclosed",
),
),
).toBe(true);
expect(input.value).toBe(
"| Resource | Status |\n| --- | --- |\n| [Issue](https://example.com/issue) | closed |",
);
});
it("keeps table cell separators and line breaks inside their cells", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<table><tr><th>Resource</th><th>Status</th></tr><tr><td><a href="https://example.com/issue">Issue | tracker</a></td><td>closed<br>today</td></tr></table>',
"Resource\tStatus\nIssue | tracker\tclosed today",
),
),
).toBe(true);
expect(input.value).toBe(
"| Resource | Status |\n| --- | --- |\n| [Issue \\| tracker](https://example.com/issue) | closed<br>today |",
);
});
it("retains headerless tables as HTML without inventing a heading", () => {
const html =
'<table><tbody><tr><td><a href="https://example.com/issue">Issue</a></td><td>closed</td></tr><tr><td>Other</td><td>open</td></tr></tbody></table>';
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard(html, "Issue\tclosed\nOther\topen"))).toBe(
true,
);
expect(input.value).toBe(html);
});
it.each([
["<table></table>", ""],
["<table><caption>Context</caption></table>", "\n\nContext"],
])("keeps surrounding links when a table has no rows: %s", (table, caption) => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(`<a href="https://example.com/">Issue</a>${table}`, "Issue"),
),
).toBe(true);
expect(input.value).toBe(`[Issue](https://example.com/)${caption}`);
});
it("leaves plain text and rich text without links to native paste", () => {
const input = editor();
expect(pasteLinkedClipboardText(input, clipboard("", "[link](https://example.com)"))).toBe(
false,
);
expect(pasteLinkedClipboardText(input, clipboard("<b>hello</b>", "hello"))).toBe(false);
expect(pasteLinkedClipboardText(input, null)).toBe(false);
expect(input.value).toBe("");
});
it("keeps paragraphs and multiple links", () => {
const input = editor();
expect(
pasteLinkedClipboardText(
input,
clipboard(
'<p>See <a href="https://example.com/a">one</a></p><p>and <a href="https://example.com/b">two</a></p>',
"See one\n\nand two",
),
),
).toBe(true);
expect(input.value).toBe(
"See [one](https://example.com/a)\n\nand [two](https://example.com/b)",
);
});
});
+7
View File
@@ -0,0 +1,7 @@
/** Native inputs retain their platform clipboard handling. */
export function pasteLinkedClipboardText(
_input: HTMLElement,
_clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
return false;
}
@@ -0,0 +1,59 @@
import TurndownService from "turndown";
import { tables } from "turndown-plugin-gfm";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
const markdown = new TurndownService({
codeBlockStyle: "fenced",
bulletListMarker: "-",
});
// Heading tables become Markdown; headerless tables retain their HTML structure.
markdown.use(tables);
markdown.addRule("rowlessTable", {
filter: (node) => node.nodeName === "TABLE" && (node as HTMLTableElement).rows.length === 0,
replacement: (content) => (content ? `\n\n${content}\n\n` : ""),
});
markdown.addRule("tableCell", {
filter: ["th", "td"],
replacement: (content, node) => {
const prefix = node.previousElementSibling ? " " : "| ";
const cell = content.trim().replace(/\|/g, "\\|").replace(/ *\n/g, "<br>");
return `${prefix}${cell} |`;
},
});
const copiedMarkdown = createAssistantMarkdownParser();
function linkDestinations(fragment: Document): string[] {
return Array.from(fragment.querySelectorAll("a[href]"), (link) => link.getAttribute("href")!);
}
function containsDestinations(plainText: string, destinations: string[]): boolean {
if (destinations.every((url) => plainText.includes(url))) return true;
// Copy normalizes Markdown URLs in HTML (percent encoding, internationalized
// domains). Compare parsed destinations using that same parser, not URL spelling.
const rendered = new DOMParser().parseFromString(copiedMarkdown.render(plainText), "text/html");
const links = new Set(linkDestinations(rendered));
return destinations.every((url) => plainText.includes(url) || links.has(url));
}
/** Paste linked HTML as Markdown through the browser's undoable editing command. */
export function pasteLinkedClipboardText(
input: HTMLElement,
clipboard: Pick<DataTransfer, "getData"> | null,
): boolean {
const html = clipboard?.getData("text/html");
if (!html) return false;
// Parse in a detached document. Clipboard markup is never mounted in the app.
const fragment = new DOMParser().parseFromString(html, "text/html");
const destinations = linkDestinations(fragment);
const plainText = clipboard!.getData("text/plain");
// Copy actions already put complete Markdown on the clipboard. Keep that
// representation, including tables and formatting, whenever it carries the URLs.
if (containsDestinations(plainText, destinations)) return false;
const text = markdown.turndown(fragment.body);
input.focus();
// Direct value assignment drops the textarea's undo history. This also emits
// the normal input event so the editor publishes its new draft as for typing.
return input.ownerDocument.execCommand("insertText", false, text);
}
+4 -4
View File
@@ -55,8 +55,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}>;
unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }>;
} {
const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [],
@@ -71,8 +71,8 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
subscriptionId: string;
}> = [];
const unsubscribeCheckoutDiffCalls: string[] = [];
const subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }> = [];
const subscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
const unsubscribeTerminalCalls: Array<{ cwd?: string; workspaceId?: string }> = [];
function on<K extends RouterMessageType>(
type: K,
+5 -2
View File
@@ -41,8 +41,11 @@ export function useReplicaQuery<
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input));
>(
input: ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: QueryClient,
): UseQueryResult<TData, TError> {
return useQuery(replicaQueryOptions(input), queryClient);
}
export function useFetchQuery<
@@ -113,7 +113,7 @@ function buildProviderModelsByProvider(
for (const entry of snapshotEntries ?? []) {
map.set(
entry.provider,
entry.status === "ready" ? filterSelectableModels(entry.models ?? null) : null,
entry.status === "ready" ? filterSelectableModels(entry.models ?? []) : null,
);
}
return map;
@@ -249,8 +249,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const modelSelectorProviders = snapshotModelSelectorProviders;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const selectedProviderHasError =
snapshotSelectedEntry?.status === "error" || snapshotSelectedEntry?.status === "unavailable";
const isModelSelectionLoading =
isConnected &&
!selectedProviderHasError &&
(resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading);
const isAllModelsLoading = isModelSelectionLoading;
+13 -12
View File
@@ -4,10 +4,14 @@ import type {
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -222,32 +226,29 @@ export function useAgentHistory(options: {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const serverIds = useMemo(
() => (serverId ? [serverId] : daemons.map((daemon) => daemon.serverId)),
[daemons, serverId],
);
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
// A host the user asked about splits two ways: one this fetch can reach, and
// one whose sessions will be missing from the answer. Both come out of here,
// because dropping the unreachable ones silently is what lets the list — and
// worse, "No sessions match" — overstate what was actually searched.
const { targetHosts, unreachableHosts } = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
const unreachable: AgentHistoryHostError[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
if (!client || !isHostRuntimeConnected(snapshot)) {
if (!client || connectionStatuses.get(targetServerId) !== "online") {
unreachable.push({ serverId: targetServerId, serverName });
continue;
}
@@ -255,7 +256,7 @@ export function useAgentHistory(options: {
}
return { targetHosts: hosts, unreachableHosts: unreachable };
}, [daemons, runtime, runtimeVersion, serverId]);
}, [daemons, runtime, connectionStatuses, serverIds]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
// One gate, checked before the field is offered: a fleet where any host
// predates search has no search, rather than a list that silently omits that
@@ -129,7 +129,7 @@ describe("loadOlderAgentHistory", () => {
expect(client.calls).toEqual([]);
expect(inFlight.values).toEqual([true]);
expect(started).toBe(true);
expect(started).toBe(false);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
@@ -153,6 +153,7 @@ describe("loadOlderAgentHistory", () => {
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
timeout: 10_000,
},
},
]);
@@ -169,7 +170,7 @@ describe("loadOlderAgentHistory", () => {
const toast = createToast();
const logger = createLogger();
await loadOlderAgentHistory(agentId, {
const applied = await loadOlderAgentHistory(agentId, {
client,
cursor: someCursor,
hasOlder: true,
@@ -179,6 +180,7 @@ describe("loadOlderAgentHistory", () => {
logger,
});
expect(applied).toBe(false);
expect(client.calls).toHaveLength(1);
expect(toast.shown).toEqual([
{
@@ -18,6 +18,7 @@ export interface LoadOlderAgentHistoryClient {
cursor: { epoch: string; seq: number };
limit: number;
projection: "projected";
timeout: number;
},
) => Promise<unknown>;
}
@@ -44,7 +45,7 @@ export async function loadOlderAgentHistory(
const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } =
deps;
if (isLoadingOlder) {
return true;
return false;
}
if (!client || !cursor || !hasOlder) {
return false;
@@ -52,16 +53,17 @@ export async function loadOlderAgentHistory(
setInFlight(true);
try {
await client.fetchAgentTimeline(
agentId,
planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
);
await client.fetchAgentTimeline(agentId, {
...planTimelineOlderFetch({ epoch: cursor.epoch, seq: cursor.startSeq }),
timeout: 10_000,
});
} catch (error) {
(logger ?? console).warn("[Timeline] failed to load older agent history", agentId, error);
toast?.show(failedMessage ?? i18n.t("loadOlderHistory.failed"), {
durationMs: 2200,
testID: "agent-load-older-history-toast",
});
return false;
} finally {
setInFlight(false);
}
+14
View File
@@ -1179,6 +1179,14 @@ export const ar: TranslationResources = {
},
},
sidebar: {
view: {
failed: "تعذر تحميل عرض الشريط الجانبي.",
useDefault: "استخدام العرض الافتراضي",
readFailed: "تعذر تحميل تفضيل عرض الشريط الجانبي.",
agentUnavailable: "لم يعد الوكيل متاحًا",
terminalUnavailable: "لم تعد الطرفية متاحة",
projectUnavailable: "لم يعد المشروع متاحًا",
},
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
@@ -2607,6 +2615,12 @@ export const ar: TranslationResources = {
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
view: {
title: "عرض الشريط الجانبي",
label: "العرض",
default: "العرض الافتراضي",
unavailable: "العرض المحدد غير متاح أو معطل. يتم عرض العرض الافتراضي.",
},
header: {
title: "الرأس",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
+14
View File
@@ -1187,6 +1187,14 @@ export const en = {
},
},
sidebar: {
view: {
failed: "Sidebar view failed to load.",
useDefault: "Use default view",
readFailed: "Could not load your sidebar view preference.",
agentUnavailable: "Agent is no longer available",
terminalUnavailable: "Terminal is no longer available",
projectUnavailable: "Project is no longer available",
},
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
@@ -2698,6 +2706,12 @@ export const en = {
description: "Show an outline for jumping between prompts",
},
sidebar: {
view: {
title: "Sidebar view",
label: "View",
default: "Default view",
unavailable: "The selected view is unavailable or disabled. Showing Default view.",
},
header: {
title: "Header",
description: "Choose which items appear at the top of the sidebar and in what order",
+15
View File
@@ -1215,6 +1215,14 @@ export const es: TranslationResources = {
},
},
sidebar: {
view: {
failed: "No se pudo cargar la vista de la barra lateral.",
useDefault: "Usar vista predeterminada",
readFailed: "No se pudo cargar tu preferencia de vista de la barra lateral.",
agentUnavailable: "El agente ya no está disponible",
terminalUnavailable: "La terminal ya no está disponible",
projectUnavailable: "El proyecto ya no está disponible",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
@@ -2627,6 +2635,13 @@ export const es: TranslationResources = {
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
view: {
title: "Vista de la barra lateral",
label: "Vista",
default: "Vista predeterminada",
unavailable:
"La vista seleccionada no está disponible o está desactivada. Se muestra la vista predeterminada.",
},
header: {
title: "Encabezado",
description:
+15
View File
@@ -1208,6 +1208,14 @@ export const fr: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Impossible de charger la vue de la barre latérale.",
useDefault: "Utiliser la vue par défaut",
readFailed: "Impossible de charger votre préférence de vue de la barre latérale.",
agentUnavailable: "Cet agent n’est plus disponible",
terminalUnavailable: "Ce terminal n’est plus disponible",
projectUnavailable: "Ce projet n’est plus disponible",
},
statusGroupAccessibility: "Groupe {{label}}",
statusBucket: {
needsInput: "Attend une réponse",
@@ -2628,6 +2636,13 @@ export const fr: TranslationResources = {
description: "Afficher un plan pour naviguer entre les prompts",
},
sidebar: {
view: {
title: "Vue de la barre latérale",
label: "Vue",
default: "Vue par défaut",
unavailable:
"La vue sélectionnée est indisponible ou désactivée. La vue par défaut est affichée.",
},
header: {
title: "En-tête",
description:
+15
View File
@@ -1192,6 +1192,14 @@ export const ja: TranslationResources = {
},
},
sidebar: {
view: {
failed: "サイドバーのビューを読み込めませんでした。",
useDefault: "デフォルトのビューを使用",
readFailed: "サイドバーのビュー設定を読み込めませんでした。",
agentUnavailable: "このエージェントは利用できなくなりました",
terminalUnavailable: "このターミナルは利用できなくなりました",
projectUnavailable: "このプロジェクトは利用できなくなりました",
},
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
@@ -2594,6 +2602,13 @@ export const ja: TranslationResources = {
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
view: {
title: "サイドバーのビュー",
label: "ビュー",
default: "デフォルトのビュー",
unavailable:
"選択したビューは利用できないか無効です。デフォルトのビューを表示しています。",
},
header: {
title: "ヘッダー",
description: "サイドバー上部に表示する項目とその順序を選択します",
+15
View File
@@ -1186,6 +1186,14 @@ export const ko: TranslationResources = {
},
},
sidebar: {
view: {
failed: "사이드바 보기를 불러오지 못했습니다.",
useDefault: "기본 보기 사용",
readFailed: "사이드바 보기 설정을 불러오지 못했습니다.",
agentUnavailable: "에이전트를 더 이상 사용할 수 없습니다",
terminalUnavailable: "터미널을 더 이상 사용할 수 없습니다",
projectUnavailable: "프로젝트를 더 이상 사용할 수 없습니다",
},
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
@@ -2585,6 +2593,13 @@ export const ko: TranslationResources = {
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
view: {
title: "사이드바 보기",
label: "보기",
default: "기본 보기",
unavailable:
"선택한 보기를 사용할 수 없거나 비활성화되어 있습니다. 기본 보기를 표시합니다.",
},
header: {
title: "헤더",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
+15
View File
@@ -1205,6 +1205,14 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Não foi possível carregar a visualização da barra lateral.",
useDefault: "Usar visualização padrão",
readFailed: "Não foi possível carregar sua preferência de visualização da barra lateral.",
agentUnavailable: "O agente não está mais disponível",
terminalUnavailable: "O terminal não está mais disponível",
projectUnavailable: "O projeto não está mais disponível",
},
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
@@ -2609,6 +2617,13 @@ export const ptBR: TranslationResources = {
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
view: {
title: "Visualização da barra lateral",
label: "Visualização",
default: "Visualização padrão",
unavailable:
"A visualização selecionada está indisponível ou desativada. Exibindo a visualização padrão.",
},
header: {
title: "Cabeçalho",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
+14
View File
@@ -1196,6 +1196,14 @@ export const ru: TranslationResources = {
},
},
sidebar: {
view: {
failed: "Не удалось загрузить вид боковой панели.",
useDefault: "Использовать стандартный вид",
readFailed: "Не удалось загрузить настройку вида боковой панели.",
agentUnavailable: "Агент больше недоступен",
terminalUnavailable: "Терминал больше недоступен",
projectUnavailable: "Проект больше недоступен",
},
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
@@ -2615,6 +2623,12 @@ export const ru: TranslationResources = {
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
view: {
title: "Вид боковой панели",
label: "Вид",
default: "Стандартный вид",
unavailable: "Выбранный вид недоступен или отключён. Показан стандартный вид.",
},
header: {
title: "Верх",
description:
+14
View File
@@ -1171,6 +1171,14 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
view: {
failed: "无法加载侧边栏视图。",
useDefault: "使用默认视图",
readFailed: "无法加载侧边栏视图偏好设置。",
agentUnavailable: "此智能体已不可用",
terminalUnavailable: "此终端已不可用",
projectUnavailable: "此项目已不可用",
},
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
@@ -2544,6 +2552,12 @@ export const zhCN: TranslationResources = {
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
view: {
title: "侧边栏视图",
label: "视图",
default: "默认视图",
unavailable: "所选视图不可用或已禁用。正在显示默认视图。",
},
header: {
title: "顶部",
description: "选择侧边栏顶部显示的项目及其顺序",
+15
View File
@@ -1012,6 +1012,7 @@ function ChatAgentContent({
effectiveAgent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasCachedHistory={replicaTimelineStatus === "painted"}
toastApi={toastApi}
toast={toastState}
dismiss={dismissToast}
@@ -1043,6 +1044,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasCachedHistory,
toastApi,
toast,
dismiss,
@@ -1071,6 +1073,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
effectiveAgent: AgentScreenAgent | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasCachedHistory: boolean;
toastApi: ToastApi;
toast: ToastState | null;
dismiss: () => void;
@@ -1198,6 +1201,12 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
draft={draft}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={
routeBottomAnchorRequest?.reason === "initial-entry" &&
!hasCachedHistory &&
!showHistorySyncError
}
revealInitialHistory={hasCachedHistory || showHistorySyncError}
hasActiveComposer={hasActiveComposer}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
@@ -1359,6 +1368,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
draft,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
isInitialHistoryLoading,
revealInitialHistory,
hasActiveComposer,
toast,
onOpenWorkspaceFile,
@@ -1370,6 +1381,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
draft: DraftConversation | null;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
isInitialHistoryLoading: boolean;
revealInitialHistory: boolean;
hasActiveComposer: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
@@ -1460,6 +1473,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
isInitialHistoryLoading={isInitialHistoryLoading}
revealInitialHistory={revealInitialHistory}
bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayControlClearance={bottomOverlayControlClearance}
toast={toast}
@@ -26,6 +26,7 @@ import {
providerSubagentLifecycleStatus,
refreshProviderSubagents,
observeProviderSubagentTimeline,
loadOlderProviderSubagentTimeline,
useProviderSubagentStore,
} from "@/subagents/provider-store";
import { useTranslation } from "react-i18next";
@@ -177,26 +178,24 @@ function ProviderSubagentPanel() {
});
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback((): boolean => {
const loadOlder = useCallback(async (): Promise<boolean> => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
return false;
}
const firstSeq = timeline.cursor?.startSeq ?? null;
if (firstSeq === null) return false;
setIsLoadingOlder(true);
void client
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
direction: "before",
cursor: { epoch: timeline.epoch, seq: firstSeq },
try {
return await loadOlderProviderSubagentTimeline({
client,
serverId,
parentAgentId: target.parentAgentId,
subagentId: target.subagentId,
limit: TIMELINE_FETCH_PAGE_SIZE,
})
.then((payload) => {
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
return undefined;
})
.catch(() => undefined)
.finally(() => setIsLoadingOlder(false));
return true;
});
} catch {
return false;
} finally {
setIsLoadingOlder(false);
}
}, [
client,
isLoadingOlder,
+2 -1
View File
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { callPluginRpc } from "@getpaseo/plugin/client/host";
import type {
PluginAgentCommandContext,
@@ -32,7 +33,7 @@ export function createPluginCapabilities(
navigation.openSurface(plugin.id, screenId, params);
}
return {
paseo: plugin.paseo,
paseo: getPluginClientApi(plugin).paseo,
rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import appPackage from "../../package.json";
import { afterEach, expect, it } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -139,7 +140,7 @@ function harness() {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected runtime RPC");
+2 -3
View File
@@ -1,6 +1,5 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import { getPluginClientApi } from "./client-state/api";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
@@ -27,7 +26,7 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
hosts: getPluginClientApi(installation).hosts,
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -0,0 +1,68 @@
import { createPluginReferenceApi } from "@getpaseo/plugin/client/host";
import { router } from "expo-router";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { createPluginHosts } from "../hosts";
import type { InstalledPlugin } from "../types";
import { createPluginClientStateSource } from "./source";
interface InstallationApi {
paseo: ReturnType<typeof createPluginReferenceApi>;
hosts: ReturnType<typeof createPluginHosts>;
}
// Registry presentation clones share the installation lifetime and its client pool.
const apis = new WeakMap<AbortSignal, InstallationApi>();
export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi {
const cached = apis.get(plugin.lifetime.signal);
if (cached) return cached;
const state = createPluginClientStateSource();
const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal);
const paseo = createPluginReferenceApi({
paseo: plugin.paseo,
resolveHost: hosts.getPaseoClient,
readProject: state.getProject,
navigation: {
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
},
openAgent(ref) {
const agent = state.getAgent(ref);
if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
},
openTerminal(ref) {
const terminal = state.getTerminal(ref);
if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable"));
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
},
openProject(ref) {
const project = state.getProject(ref);
if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable"));
usePanelStore.getState().showMobileAgent();
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
},
},
});
const api = { paseo, hosts };
apis.set(plugin.lifetime.signal, api);
return api;
}
@@ -0,0 +1,72 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { PluginConnectedHostsSource } from "./source";
interface PluginHostsRuntime {
getHosts(): readonly { serverId: string }[];
getSnapshot(serverId: string): { connectionStatus: string } | null | undefined;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
acquireDirectoryDemand(serverId: string): () => void;
}
export function createConnectedPluginHosts(
getRuntime: () => PluginHostsRuntime,
): PluginConnectedHostsSource {
let snapshot: readonly string[] = [];
const listeners = new Set<() => void>();
let release: (() => void) | null = null;
const source: PluginConnectedHostsSource = {
getSnapshot() {
const runtime = getRuntime();
const next = runtime
.getHosts()
.filter((host) => runtime.getSnapshot(host.serverId)?.connectionStatus === "online")
.map((host) => host.serverId);
if (next.length !== snapshot.length || next.some((id, index) => id !== snapshot[index]))
snapshot = Object.freeze(next);
return snapshot;
},
subscribe(listener) {
listeners.add(listener);
if (!release) {
const runtime = getRuntime();
const demands = new Map<string, () => void>();
const reconcile = () => {
const ids = new Set(source.getSnapshot());
for (const [id, releaseDemand] of demands) {
if (!ids.has(id)) {
demands.delete(id);
releaseDemand();
}
}
for (const id of ids) {
if (!demands.has(id)) demands.set(id, runtime.acquireDirectoryDemand(id));
}
};
const notify = () => {
reconcile();
for (const subscriber of listeners) subscriber();
};
const releaseRuntime = runtime.subscribeAll(notify);
const releaseHosts = runtime.subscribeHostList(notify);
reconcile();
release = () => {
releaseRuntime();
releaseHosts();
for (const releaseDemand of demands.values()) releaseDemand();
demands.clear();
};
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && release) {
release();
release = null;
}
};
},
};
return source;
}
export const connectedPluginHosts = createConnectedPluginHosts(getHostRuntimeStore);
@@ -0,0 +1,53 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
export const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
export const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
@@ -0,0 +1,3 @@
import { createEntityRefs } from "@getpaseo/plugin/client/host";
export const entityRefs = createEntityRefs();
@@ -1,66 +1,20 @@
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
const workspace: WorkspaceDescriptor = {
createdAt: null,
lastUserMessageAt: null,
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
createPluginAgentSnapshot,
createPluginWorkspaceSnapshot,
createPluginProjectSnapshot,
} from "./snapshots";
import { workspace, agent } from "./fixtures";
describe("plugin context snapshots", () => {
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
const snapshot = createPluginWorkspaceSnapshot(workspace);
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
const snapshot = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -90,7 +44,7 @@ describe("plugin context snapshots", () => {
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
expect(snapshot).toEqual({
expect(snapshot).toMatchObject({
id: "agent-1",
workspaceId: "workspace-1",
provider: "codex",
@@ -119,3 +73,82 @@ describe("plugin context snapshots", () => {
expect(agent.labels).toEqual({ phase: "implementation" });
});
});
describe("sidebar entity snapshots", () => {
it("interns cross-host references and preserves identity across unrelated reads", () => {
const first = createPluginWorkspaceSnapshot(workspace, "host-1");
const second = createPluginWorkspaceSnapshot(workspace, "host-2");
expect(first.ref).not.toBe(second.ref);
expect(first.ref.hostId).toBe("host-1");
expect(first.project.id).toBe(workspace.projectId);
expect(Object.isFrozen(first.labels)).toBe(true);
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(first);
expect(createPluginAgentSnapshot(agent, workspace.id).workspace).toBe(first.ref);
});
it("invalidates a done workspace when its effective root-agent status changes", () => {
const idle: WorkspaceDescriptor = { ...workspace, status: "done" };
const before = createPluginWorkspaceSnapshot(idle, "host-1");
const activity = new Map([
[idle.id, { agentId: agent.id, status: "needs_input" as const, enteredAt: null }],
]);
const after = createPluginWorkspaceSnapshot(idle, "host-1", {
workspaceAgentActivity: activity,
});
expect(before.indicator).toBe("done");
expect(after.status).toBe("done");
expect(after.indicator).toBe("needs_input");
expect(after.indicatorLabel).toBe("Needs input");
expect(after.ref).toBe(before.ref);
expect(
createPluginWorkspaceSnapshot(idle, "host-1", { workspaceAgentActivity: activity }),
).toBe(after);
});
it("reads projects without requiring a workspace", () => {
const project = {
projectId: "empty",
projectDisplayName: "Empty",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git" as const,
};
const snapshot = createPluginProjectSnapshot(project, "host-1");
expect(createPluginProjectSnapshot(project, "host-1")).toBe(snapshot);
expect(snapshot.displayName).toBe("Empty");
expect(snapshot.ref.id).toBe("empty");
expect(Object.isFrozen(snapshot)).toBe(true);
});
});
it("uses the app status labels and invalidates a snapshot on language change", async () => {
i18n.addResourceBundle("fr", "translation", fr);
const previousLanguage = i18n.language;
try {
await i18n.changeLanguage("en");
const english = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(english.indicatorLabel).toBe("Working");
await i18n.changeLanguage("fr");
const french = createPluginWorkspaceSnapshot(workspace, "host-1");
expect(french).not.toBe(english);
expect(french.ref).toBe(english.ref);
expect(french.indicatorLabel).toBe("En cours");
expect(createPluginWorkspaceSnapshot(workspace, "host-1")).toBe(french);
} finally {
await i18n.changeLanguage(previousLanguage);
}
});
it("preserves the host-qualified parent of a remotely delegated agent", () => {
const child = {
...agent,
serverId: "host-2",
parentAgentId: null,
labels: { "paseo.parent-server-id": "host-1", "paseo.parent-agent-id": "parent" },
};
expect(createPluginAgentSnapshot(child, workspace.id).parentAgent).toMatchObject({
hostId: "host-1",
id: "parent",
kind: "agent",
});
});
@@ -1,16 +1,57 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { i18n } from "@/i18n/i18next";
import type {
PluginAgentSnapshot,
PluginWorkspaceSnapshot,
PluginProjectSnapshot,
} from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor, ProjectDescriptor } from "@/stores/session-store";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
import { entityRefs } from "./refs";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
interface CachedWorkspace {
language: string;
snapshot: PluginWorkspaceSnapshot;
}
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, Map<string, CachedWorkspace>>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
export function createPluginWorkspaceSnapshot(
workspace: WorkspaceDescriptor,
hostId: string,
context: Omit<Parameters<typeof deriveEffectiveWorkspaceStatus>[0], "workspace" | "serverId"> & {
parentAgent?: PluginWorkspaceSnapshot["parentAgent"];
} = {},
): PluginWorkspaceSnapshot {
const cached = workspaceSnapshots.get(workspace);
if (cached) return cached;
const indicator = deriveEffectiveWorkspaceStatus({
...context,
workspace,
serverId: hostId,
}).status;
const parentAgent = context.parentAgent ?? null;
const cached = workspaceSnapshots.get(workspace)?.get(hostId);
if (
cached &&
cached.language === i18n.language &&
cached.snapshot.indicator === indicator &&
cached.snapshot.parentAgent === parentAgent
)
return cached.snapshot;
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
const snapshot = Object.freeze({
ref: entityRefs.workspace(hostId, workspace.id),
hostId,
project: entityRefs.project(hostId, workspace.projectId),
pinnedAt: workspace.pinnedAt ?? null,
labels: Object.freeze([...(workspace.labels ?? [])]),
background: workspace.background ?? false,
branch: workspace.gitRuntime?.currentBranch ?? null,
createdAt: serializeTimestamp(workspace.createdAt),
lastPromptAt: serializeTimestamp(workspace.lastUserMessageAt),
parentAgent,
indicator,
indicatorLabel: i18n.t(indicatorLabelKeys[indicator]),
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
@@ -21,18 +62,25 @@ export function createPluginWorkspaceSnapshot(
name: workspace.name,
title: workspace.title ?? null,
status: workspace.status,
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
statusEnteredAt: serializeTimestamp(workspace.statusEnteredAt),
archivingAt: workspace.archivingAt,
diffStat,
});
workspaceSnapshots.set(workspace, snapshot);
const byHost = workspaceSnapshots.get(workspace) ?? new Map<string, CachedWorkspace>();
byHost.set(hostId, { language: i18n.language, snapshot });
workspaceSnapshots.set(workspace, byHost);
return snapshot;
}
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
const cached = agentSnapshots.get(agent)?.get(workspaceId);
if (cached) return cached;
const parent = getParentAddressFromLabels(agent.labels, agent.serverId);
const snapshot = Object.freeze({
ref: entityRefs.agent(agent.serverId, agent.id),
hostId: agent.serverId,
workspace: entityRefs.workspace(agent.serverId, workspaceId),
parentAgent: parent ? entityRefs.agent(parent.serverId, parent.agentId) : null,
id: agent.id,
workspaceId,
provider: agent.provider,
@@ -55,3 +103,36 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
agentSnapshots.set(agent, byWorkspace);
return snapshot;
}
const indicatorLabelKeys = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
running: "sidebar.statusBucket.working",
attention: "sidebar.statusBucket.readyToReview",
done: "sidebar.statusBucket.done",
} as const;
const projectSnapshots = new WeakMap<ProjectDescriptor, Map<string, PluginProjectSnapshot>>();
export function createPluginProjectSnapshot(
project: ProjectDescriptor,
hostId: string,
): PluginProjectSnapshot {
const cached = projectSnapshots.get(project)?.get(hostId);
if (cached) return cached;
const snapshot = Object.freeze({
ref: entityRefs.project(hostId, project.projectId),
hostId,
displayName: project.projectCustomName ?? project.projectDisplayName,
rootPath: project.projectRootPath,
kind: project.projectKind,
iconRevision: project.projectCustomIconRevision ?? project.projectIconRevision ?? null,
});
const byHost = projectSnapshots.get(project) ?? new Map<string, PluginProjectSnapshot>();
byHost.set(hostId, snapshot);
projectSnapshots.set(project, byHost);
return snapshot;
}
function serializeTimestamp(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
@@ -0,0 +1,55 @@
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createPluginClientStateSource } from "./source";
import { workspace } from "./fixtures";
setFlagsFromString("--expose_gc");
const collectGarbage: () => void = runInNewContext("gc");
setFlagsFromString("--no-expose_gc");
function observeHost() {
const hostId = "removed-plugin-host";
let hosts = [hostId];
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(hostId, new Map([[workspace.id, { ...workspace }]]));
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => hosts, subscribe: () => () => {} },
});
const release = source.subscribe(() => source.getSnapshot());
source.getSnapshot();
const reference = new WeakRef(useSessionStore.getState().sessions[hostId]!.workspaces);
const remove = () => {
hosts = [];
store.clearSession(hostId);
};
return { reference, release, remove };
}
it("releases removed hosts' projected data while other plugin readers remain", async () => {
const { reference, release, remove } = observeHost();
remove();
try {
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
} finally {
release();
}
});
it("releases previous projections when the last plugin reader leaves", async () => {
const { reference, release, remove } = observeHost();
release();
remove();
for (let attempt = 0; attempt < 3; attempt++) {
await setImmediate();
collectGarbage();
}
expect(reference.deref()).toBeUndefined();
});
@@ -0,0 +1,420 @@
import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "../theme";
import { createConnectedPluginHosts } from "./connected-hosts";
import { i18n } from "@/i18n/i18next";
import { fr } from "@/i18n/resources/fr";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { createEntityRefs, PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import {
useAgents,
useWorkspaces,
useWorkspace,
useAgent,
type WorkspaceRef,
type AgentRef,
} from "@getpaseo/plugin/client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createPluginClientStateSource, type PluginConnectedHostsSource } from "./source";
import { agent, workspace } from "./fixtures";
let root: Root | null = null;
let container: HTMLDivElement | null = null;
const renders = new Map<string, number>();
function count(key: string) {
renders.set(key, (renders.get(key) ?? 0) + 1);
}
function WorkspaceRow({ reference }: { reference: WorkspaceRef }) {
const item = useWorkspace(reference);
count(reference.key);
return (
<div>
{item?.name}:{item?.indicator}
</div>
);
}
function Workspaces() {
count("workspaces");
const items = useWorkspaces({ orderBy: "name" });
return (
<div>
{items.map((reference) => (
<WorkspaceRow key={reference.key} reference={reference} />
))}
</div>
);
}
function SelectedWorkspace({ reference }: { reference: WorkspaceRef }) {
const name = useWorkspace(reference, (item) => ({ name: item.name }));
const custom = useWorkspace(reference, undefined, (a, b) => a?.name === b?.name);
count("selected");
return (
<div>
{name?.name}:{custom?.name}
</div>
);
}
function AgentRow({ reference }: { reference: AgentRef }) {
const item = useAgent(reference);
return <div>{item?.title}</div>;
}
function AgentSection({ section }: { section: string }) {
const agents = useAgents({
where: (item) => (item.requiresAttention ? "waiting" : item.status) === section,
});
count(section);
return (
<div>
{agents.map((reference) => (
<AgentRow key={reference.key} reference={reference} />
))}
</div>
);
}
function Agents() {
return (
<div>
<AgentSection section="running" />
<AgentSection section="waiting" />
<AgentSection section="idle" />
</div>
);
}
function seedHost(hostId: string) {
const store = useSessionStore.getState();
store.initializeSession(hostId, null);
store.setWorkspaces(
hostId,
new Map([
["workspace-1", { ...workspace, id: "workspace-1", status: "done", name: "First" }],
["workspace-2", { ...workspace, id: "workspace-2", status: "done", name: "Second" }],
]),
);
store.setProjects(hostId, [
{
projectId: "empty",
projectDisplayName: "Empty project",
projectCustomName: null,
projectRootPath: "/empty",
projectKind: "git",
},
]);
store.setAgents(
hostId,
new Map<string, Agent>([
[
"agent-1",
{
...agent,
serverId: hostId,
id: "agent-1",
workspaceId: "workspace-1",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
[
"agent-2",
{
...agent,
serverId: hostId,
id: "agent-2",
workspaceId: "workspace-2",
status: "running",
requiresAttention: false,
attentionReason: null,
turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null },
},
],
]),
);
}
it("keeps archived agents readable individually while excluding them from collections", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
const reference = source.getSnapshot().agents[0]!.ref;
useSessionStore.getState().setAgents(
"host-1",
new Map([
[
agent.id,
{
...agent,
archivedAt: new Date("2026-11-01T00:00:00.000Z"),
},
],
]),
);
expect(source.getSnapshot().agents).toEqual([]);
expect(source.getAgent(reference)?.title).toBe(agent.title);
expect(source.getAgent(agent.id)).toBe(source.getAgent(reference));
});
it("demands directories for connected hosts and releases them with its last reader", () => {
const hostId = "plugin-demand-host";
let online = true;
let demands = 0;
const listeners = new Set<() => void>();
const hosts = createConnectedPluginHosts(() => ({
getHosts: () => [{ serverId: hostId }],
getSnapshot: () => ({ connectionStatus: online ? "online" : "offline" }),
subscribeAll: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
subscribeHostList: () => () => {},
acquireDirectoryDemand: () => {
demands++;
seedHost(hostId);
return () => {
demands--;
};
},
}));
const source = createPluginClientStateSource(undefined, { hosts });
const first = source.subscribe(() => {});
const second = source.subscribe(() => {});
expect(demands).toBe(1);
expect(source.getSnapshot().workspaces.map((item) => item.hostId)).toEqual([hostId, hostId]);
useSessionStore
.getState()
.setWorkspaces(
hostId,
(previous) =>
new Map([...previous].map(([id, item]) => [id, { ...item, name: "Live update" }])),
);
expect(source.getSnapshot().workspaces.map((item) => item.name)).toEqual([
"Live update",
"Live update",
]);
online = false;
for (const listener of listeners) listener();
expect(demands).toBe(0);
expect(source.getSnapshot().workspaces).toEqual([]);
online = true;
for (const listener of listeners) listener();
expect(demands).toBe(1);
first();
expect(demands).toBe(1);
second();
expect(demands).toBe(0);
expect(listeners.size).toBe(0);
});
function waitForInput() {
useSessionStore.getState().setAgents("host-1", (previous) => {
const current = previous.get("agent-1");
if (!current) throw new Error("Missing test agent");
const next = new Map(previous);
next.set(current.id, {
...current,
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
attentionReason: "permission",
});
return next;
});
}
class ConnectedHosts implements PluginConnectedHostsSource {
private ids: readonly string[] = ["host-1"];
private listeners = new Set<() => void>();
getSnapshot = () => this.ids;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
connect(id: string) {
this.ids = [...this.ids, id];
for (const listener of this.listeners) listener();
}
}
function mount(children: React.ReactNode, hosts = new ConnectedHosts()) {
const source = createPluginClientStateSource("host-1", { hosts });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() =>
root?.render(<PluginClientStateProvider source={source}>{children}</PluginClientStateProvider>),
);
return source;
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
useSessionStore.getState().clearSession("host-1");
useSessionStore.getState().clearSession("host-2");
renders.clear();
});
it("shows a restored child in the nested example while its cached parent remains archived", () => {
seedHost("host-1");
const parent = {
...agent,
id: "archived-parent",
title: "Archived parent",
archivedAt: new Date("2026-11-01T00:00:00Z"),
};
const child = {
...agent,
id: "restored-child",
title: "Restored child",
parentAgentId: parent.id,
labels: { "paseo.parent-agent-id": parent.id },
archivedAt: parent.archivedAt,
};
const store = useSessionStore.getState();
store.setProjects("host-1", [
{
projectId: workspace.projectId,
projectDisplayName: "Project",
projectCustomName: null,
projectRootPath: workspace.projectRootPath,
projectKind: "git",
},
]);
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, child],
]),
);
const view = {
theme: toPluginTheme(lightTheme),
layout: { compact: false, platform: "web" as const },
active: { workspace: null, agent: null },
navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} },
dismiss() {},
};
const source = mount(<Nested {...view} />);
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
expect(container?.textContent).not.toContain(child.title);
act(() =>
store.setAgents(
"host-1",
new Map<string, Agent>([
[parent.id, parent],
[child.id, { ...child, archivedAt: null }],
]),
),
);
expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull();
expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull();
});
describe("sidebar data hooks through the app store and plugin provider", () => {
it("renders only one workspace row when its root agent starts waiting", () => {
seedHost("host-1");
const source = mount(<Workspaces />);
const first = source.getSnapshot().workspaces[0]!;
const second = source.getSnapshot().workspaces[1]!;
const before = source.getSnapshot();
act(waitForInput);
expect(renders.get("workspaces")).toBe(1);
expect(renders.get(first.ref.key)).toBe(2);
expect(renders.get(second.ref.key)).toBe(1);
expect(source.getSnapshot().workspaces[0]?.indicator).toBe("needs_input");
expect(source.getSnapshot().workspaces[1]).toBe(before.workspaces[1]);
});
it("moves the agent and renders only its two affected sections without memo", () => {
seedHost("host-1");
mount(<Agents />);
act(waitForInput);
expect(renders.get("running")).toBe(2);
expect(renders.get("waiting")).toBe(2);
expect(renders.get("idle")).toBe(1);
});
it("uses shallow selected equality and custom equality without a selector", () => {
seedHost("host-1");
const source = createPluginClientStateSource("host-1", { hosts: new ConnectedHosts() });
mount(<SelectedWorkspace reference={source.getSnapshot().workspaces[0]!.ref} />);
act(waitForInput);
expect(renders.get("selected")).toBe(1);
});
it("refreshes translated labels through the subscribed source when language changes", async () => {
seedHost("host-1");
i18n.addResourceBundle("fr", "translation", fr);
const source = mount(<Workspaces />);
const before = source.getSnapshot().workspaces[0]!;
const previousLanguage = i18n.language;
try {
await act(async () => {
await i18n.changeLanguage("fr");
});
const after = source.getSnapshot().workspaces[0]!;
expect(after).not.toBe(before);
expect(after.indicatorLabel).toBe("En cours");
expect(after.ref).toBe(before.ref);
expect(renders.get(before.ref.key)).toBe(2);
expect(renders.get("workspaces")).toBe(1);
} finally {
await act(async () => {
await i18n.changeLanguage(previousLanguage);
});
}
});
it("adds a newly connected host and exposes projects with no workspaces", () => {
seedHost("host-1");
seedHost("host-2");
const hosts = new ConnectedHosts();
const source = mount(<Workspaces />, hosts);
expect(source.getSnapshot().workspaces).toHaveLength(2);
expect(source.getSnapshot().projects[0]?.displayName).toBe("Empty project");
act(() => hosts.connect("host-2"));
expect(source.getSnapshot().workspaces).toHaveLength(4);
expect(source.getSnapshot().projects).toHaveLength(2);
expect(new Set(source.getSnapshot().workspaces.map((item) => item.ref.key)).size).toBe(4);
});
});
it("observes terminals only while a terminal reader is subscribed", () => {
let active = 0;
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => {
active++;
return () => {
active--;
};
},
},
});
const releaseWorkspace = source.subscribe(() => {});
expect(active).toBe(0);
const releaseTerminal = source.subscribe(() => {}, { terminals: true });
const releaseSecond = source.subscribe(() => {}, { terminals: true });
expect(active).toBe(1);
releaseTerminal();
expect(active).toBe(1);
releaseSecond();
expect(active).toBe(0);
releaseWorkspace();
});
it("terminal row reads propagate only the referenced host's observation error", () => {
const error = new Error("Update the host");
const source = createPluginClientStateSource(undefined, {
hosts: { getSnapshot: () => [], subscribe: () => () => {} },
terminals: {
getSnapshot: () => [],
subscribe: () => () => {},
getErrors: () => new Map([["unsupported", error]]),
},
});
const refs = createEntityRefs();
expect(() => source.getTerminal(refs.terminal("unsupported", "retained"))).toThrow(error);
expect(source.getTerminal(refs.terminal("supported", "missing"))).toBeNull();
});
+242 -15
View File
@@ -1,21 +1,248 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import { useSessionStore } from "@/stores/session-store";
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
import { i18n } from "@/i18n/i18next";
import type { PluginClientSnapshot, PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
import { connectedPluginHosts } from "./connected-hosts";
import { pluginTerminals } from "./terminals";
import { entityRefs } from "./refs";
import {
createPluginAgentSnapshot,
createPluginProjectSnapshot,
createPluginWorkspaceSnapshot,
} from "./snapshots";
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
return {
subscribe: useSessionStore.subscribe,
getWorkspace(workspaceId) {
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
export interface PluginConnectedHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly string[];
}
export interface PluginTerminalsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly PluginTerminalSnapshot[];
getErrors?(): ReadonlyMap<string, Error>;
}
const emptyTerminals: readonly PluginTerminalSnapshot[] = Object.freeze([]);
const listeners = new Set<() => void>();
let releaseStores: (() => void) | null = null;
// Every plugin host shares one subscription to each app store.
function subscribeStores(listener: () => void): () => void {
if (!releaseStores) {
const notify = () => {
for (const subscriber of listeners) subscriber();
};
i18n.on("languageChanged", notify);
const releaseSession = useSessionStore.subscribe((state) => {
for (const hostId of hostProjections.keys()) {
if (!state.sessions[hostId]) hostProjections.delete(hostId);
}
notify();
});
const releaseCreate = useCreateFlowStore.subscribe(notify);
releaseStores = () => {
i18n.off("languageChanged", notify);
releaseSession();
releaseCreate();
};
}
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0 && releaseStores) {
releaseStores();
releaseStores = null;
hostProjections.clear();
}
};
}
interface HostProjection {
language: string;
workspaces: SessionState["workspaces"];
agents: SessionState["agents"];
projects: SessionState["projects"];
activity: SessionState["workspaceAgentActivity"];
pending: ReturnType<typeof useCreateFlowStore.getState>["pendingByDraftId"];
snapshot: Omit<PluginClientSnapshot, "terminals">;
workspaceById: Map<string, PluginClientSnapshot["workspaces"][number]>;
agentById: Map<string, PluginClientSnapshot["agents"][number]>;
projectById: Map<string, PluginClientSnapshot["projects"][number]>;
}
const hostProjections = new Map<string, HostProjection>();
function projectHost(hostId: string): HostProjection | null {
const session = useSessionStore.getState().sessions[hostId];
if (!session) {
hostProjections.delete(hostId);
return null;
}
const pending = useCreateFlowStore.getState().pendingByDraftId;
const previous = hostProjections.get(hostId);
const unchanged =
previous &&
previous.language === i18n.language &&
previous.workspaces === session.workspaces &&
previous.agents === session.agents &&
previous.projects === session.projects &&
previous.activity === session.workspaceAgentActivity &&
previous.pending === pending;
if (unchanged) return previous;
const workspaces = Array.from(session.workspaces.values(), (workspace) => {
const activity = session.workspaceAgentActivity.get(workspace.id);
const root = activity ? session.agents.get(activity.agentId) : undefined;
return createPluginWorkspaceSnapshot(workspace, hostId, {
pendingCreateAttempts: pending,
workspaceAgentActivity: session.workspaceAgentActivity,
parentAgent: root ? createPluginAgentSnapshot(root, workspace.id).parentAgent : null,
});
});
const agents: PluginClientSnapshot["agents"][number][] = [];
for (const agent of session.agents.values()) {
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (!workspaceId || agent.archivedAt) continue;
agents.push(createPluginAgentSnapshot(agent, workspaceId));
}
const projects = Array.from(session.projects.values(), (project) =>
createPluginProjectSnapshot(project, hostId),
);
const projection: HostProjection = {
language: i18n.language,
workspaces: session.workspaces,
agents: session.agents,
projects: session.projects,
activity: session.workspaceAgentActivity,
pending,
snapshot: {
workspaces: Object.freeze(workspaces),
agents: Object.freeze(agents),
projects: Object.freeze(projects),
},
getAgent(agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId);
return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null;
workspaceById: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
agentById: new Map(agents.map((agent) => [agent.id, agent])),
projectById: new Map(projects.map((project) => [project.ref.id, project])),
};
hostProjections.set(hostId, projection);
return projection;
}
function sameSequence<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/** A host scope only resolves legacy string ids. Ref lookups and collections span hosts. */
export function createPluginClientStateSource(
serverId?: string,
options: { hosts?: PluginConnectedHostsSource; terminals?: PluginTerminalsSource } = {},
): PluginClientStateSource {
const terminals = options.terminals ?? pluginTerminals;
const hosts = options.hosts ?? connectedPluginHosts;
const subscribers = new Set<() => void>();
const terminalSubscribers = new Set<() => void>();
let releaseTerminals: (() => void) | null = null;
let release: (() => void) | null = null;
let previousProjections: readonly HostProjection[] = [];
let previousTerminals = emptyTerminals;
let previousTerminalErrors: ReadonlyMap<string, Error> | undefined;
let snapshot: PluginClientSnapshot = {
workspaces: [],
agents: [],
projects: [],
terminals: emptyTerminals,
};
function getSnapshot(): PluginClientSnapshot {
const ids = hosts.getSnapshot();
const projections: HostProjection[] = [];
for (const id of ids) {
const projection = projectHost(id);
if (projection) projections.push(projection);
}
const terminalSnapshots = terminals.getSnapshot();
const terminalErrors = terminals.getErrors?.();
if (
sameSequence(previousProjections, projections) &&
previousTerminals === terminalSnapshots &&
previousTerminalErrors === terminalErrors
)
return snapshot;
previousProjections = projections;
previousTerminals = terminalSnapshots;
previousTerminalErrors = terminalErrors;
snapshot = Object.freeze({
workspaces: Object.freeze(projections.flatMap((host) => host.snapshot.workspaces)),
agents: Object.freeze(projections.flatMap((host) => host.snapshot.agents)),
projects: Object.freeze(projections.flatMap((host) => host.snapshot.projects)),
terminals: terminalSnapshots,
terminalErrors,
});
return snapshot;
}
return {
subscribe(listener, subscriptionOptions) {
subscribers.add(listener);
if (subscriptionOptions?.terminals) {
terminalSubscribers.add(listener);
releaseTerminals ??= terminals.subscribe(() => {
for (const subscriber of terminalSubscribers) subscriber();
});
}
if (!release) {
const notify = () => {
for (const subscriber of subscribers) subscriber();
};
const releaseStore = subscribeStores(notify);
const releaseHosts = hosts.subscribe(notify);
release = () => {
releaseStore();
releaseHosts();
};
}
return () => {
subscribers.delete(listener);
terminalSubscribers.delete(listener);
if (terminalSubscribers.size === 0 && releaseTerminals) {
releaseTerminals();
releaseTerminals = null;
}
if (subscribers.size === 0 && release) {
release();
release = null;
previousProjections = [];
previousTerminals = emptyTerminals;
previousTerminalErrors = undefined;
snapshot = { workspaces: [], agents: [], projects: [], terminals: emptyTerminals };
}
};
},
getSnapshot,
getWorkspace(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.workspace(serverId, reference)
: reference;
if (typeof ref === "string") return null;
return projectHost(ref.hostId)?.workspaceById.get(ref.id) ?? null;
},
getAgent(reference) {
const ref =
typeof reference === "string" && serverId
? entityRefs.agent(serverId, reference)
: reference;
if (typeof ref === "string") return null;
const projected = projectHost(ref.hostId)?.agentById.get(ref.id);
if (projected) return projected;
const session = useSessionStore.getState().sessions[ref.hostId];
const detail = session?.agents.get(ref.id) ?? session?.agentDetails.get(ref.id);
const workspaceId = normalizeWorkspaceOpaqueId(detail?.workspaceId);
return detail && workspaceId ? createPluginAgentSnapshot(detail, workspaceId) : null;
},
getProject: (ref) => projectHost(ref.hostId)?.projectById.get(ref.id) ?? null,
getTerminal(ref) {
const error = terminals.getErrors?.().get(ref.hostId);
if (error) throw error;
return terminals.getSnapshot().find((terminal) => terminal.ref === ref) ?? null;
},
};
}
@@ -0,0 +1,154 @@
import type { PluginTerminalSnapshot } from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TerminalsChanged } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { entityRefs } from "./refs";
import type { PluginTerminalsSource } from "./source";
interface TerminalHost {
serverId: string;
client: DaemonClient;
epoch: number;
}
export interface TerminalHostsSource {
subscribe(listener: () => void): () => void;
getSnapshot(): readonly TerminalHost[];
}
interface Observation {
client: DaemonClient;
epoch: number;
items: readonly PluginTerminalSnapshot[];
release(): void;
}
/** One owned observation per connected host, shared by every plugin surface. */
export function createPluginTerminalsSource(hosts: TerminalHostsSource): PluginTerminalsSource {
const observations = new Map<string, Observation>();
const listeners = new Set<() => void>();
let snapshot: readonly PluginTerminalSnapshot[] = Object.freeze([]);
let errors: ReadonlyMap<string, Error> = new Map();
let releaseHosts: (() => void) | null = null;
function publish() {
const next = [...observations.values()].flatMap((item) => item.items);
if (next.length !== snapshot.length || next.some((item, index) => item !== snapshot[index]))
snapshot = Object.freeze(next);
for (const listener of listeners) listener();
}
function receive(hostId: string, observation: Observation, payload: TerminalsChanged["payload"]) {
if (observations.get(hostId) !== observation) return;
const previous = new Map(observation.items.map((item) => [item.ref.id, item]));
observation.items = Object.freeze(
payload.terminals.flatMap((terminal) => {
// Host-wide observation includes unowned legacy terminals. Sidebar navigation needs a workspace.
if (!terminal.workspaceId) return [];
const ref = entityRefs.terminal(hostId, terminal.id);
const workspace = entityRefs.workspace(hostId, terminal.workspaceId);
const title = terminal.title ?? terminal.name;
const createdAt = terminal.createdAt ?? null;
const isScript = terminal.isScript === true;
const cached = previous.get(terminal.id);
if (
cached &&
cached.workspace === workspace &&
cached.title === title &&
cached.createdAt === createdAt &&
cached.isScript === isScript
)
return [cached];
return [Object.freeze({ ref, hostId, workspace, title, createdAt, isScript })];
}),
);
errors = new Map([...errors].filter(([id]) => id !== hostId));
publish();
}
function reconcile() {
const active = new Map(hosts.getSnapshot().map((host) => [host.serverId, host]));
for (const [id, observation] of observations) {
if (
active.get(id)?.client !== observation.client ||
active.get(id)?.epoch !== observation.epoch
) {
observations.delete(id);
observation.release();
}
}
errors = new Map([...errors].filter(([id]) => active.has(id)));
for (const [hostId, { client, epoch }] of active) {
if (observations.has(hostId)) continue;
const observation: Observation = { client, epoch, items: [], release() {} };
observations.set(hostId, observation);
try {
// The SDK owns the capability gate; a failed host is reported only to terminal readers.
const subscription = client.observeTerminals({});
const unsubscribe = subscription.subscribe({
snapshot: (payload) => receive(hostId, observation, payload),
update: (message) => {
if (message.type === "terminals_changed") receive(hostId, observation, message.payload);
},
error: (error) => {
if (observations.get(hostId) !== observation) return;
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
publish();
},
});
void subscription.ready.catch(() => {}); // Observer owns error delivery.
observation.release = () => {
unsubscribe();
void subscription.release().catch(() => {});
};
} catch (error) {
errors = new Map(errors).set(
hostId,
error instanceof Error ? error : new Error(String(error)),
);
}
}
publish();
}
return {
getSnapshot: () => snapshot,
getErrors: () => errors,
subscribe(listener) {
listeners.add(listener);
if (!releaseHosts) {
releaseHosts = hosts.subscribe(reconcile);
reconcile();
}
return () => {
listeners.delete(listener);
if (!listeners.size && releaseHosts) {
releaseHosts();
releaseHosts = null;
for (const observation of observations.values()) observation.release();
observations.clear();
snapshot = Object.freeze([]);
errors = new Map();
}
};
},
};
}
export const pluginTerminals = createPluginTerminalsSource({
getSnapshot() {
const runtime = getHostRuntimeStore();
return runtime.getHosts().flatMap((host) => {
const state = runtime.getSnapshot(host.serverId);
return state?.connectionStatus === "online" && state.client
? [{ serverId: host.serverId, client: state.client, epoch: state.connectionEpoch }]
: [];
});
},
subscribe(listener) {
const runtime = getHostRuntimeStore();
const releaseRuntime = runtime.subscribeAll(listener);
const releaseHosts = runtime.subscribeHostList(listener);
return () => {
releaseRuntime();
releaseHosts();
};
},
});
@@ -13,7 +13,20 @@ import { z } from "zod";
import type { InstalledPlugin } from "../types";
import { buildPluginCommandCenterContributions } from "./contributions";
import { entityRefs } from "../client-state/refs";
const workspace: PluginWorkspaceSnapshot = {
ref: entityRefs.workspace("host-1", "workspace-1"),
hostId: "host-1",
project: entityRefs.project("host-1", "project-1"),
pinnedAt: null,
labels: [],
background: false,
branch: null,
createdAt: null,
lastPromptAt: null,
parentAgent: null,
indicator: "running",
indicatorLabel: "Running",
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
@@ -30,6 +43,10 @@ const workspace: PluginWorkspaceSnapshot = {
};
const agent: PluginAgentSnapshot = {
ref: entityRefs.agent("host-1", "agent-1"),
hostId: "host-1",
workspace: workspace.ref,
parentAgent: null,
id: "agent-1",
workspaceId: workspace.id,
provider: "codex",
@@ -82,6 +99,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [{ id: "main", title: "Main", Component: () => null }],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -131,6 +149,15 @@ function stateSource() {
subscribe() {
return () => {};
},
getSnapshot() {
return { workspaces: [workspace], agents: [agent], projects: [], terminals: [] };
},
getProject() {
return null;
},
getTerminal() {
return null;
},
getWorkspace(id: string) {
return id === workspace.id ? workspace : null;
},
+23
View File
@@ -55,6 +55,29 @@ describe("evaluatePluginClientBundle", () => {
expect(active).toBe(0);
});
it("registers sidebar views without selecting one and removes them on cleanup", async () => {
const plugin = evaluatePluginClientBundle(
"sidebar",
bundle(`
plugin.addSidebarView({ id: "workspaces", title: "Workspaces", Component() { return null; } });
`),
);
expect(plugin.sidebarViews.map((view) => view.id)).toEqual(["workspaces"]);
await plugin.cleanup();
expect(plugin.sidebarViews).toEqual([]);
});
it("rejects duplicate sidebar view ids", () => {
expect(() =>
evaluatePluginClientBundle(
"sidebar",
bundle(`
const view = { id: "workspaces", title: "Workspaces", Component() { return null; } };
plugin.addSidebarView(view); plugin.addSidebarView(view);
`),
),
).toThrow("Duplicate sidebar view");
});
it("accepts memoized settings screens", () => {
const plugin = evaluatePluginClientBundle(
"settings",
+17 -1
View File
@@ -129,6 +129,7 @@ export function runPluginClientBundle(
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -141,6 +142,7 @@ export function runPluginClientBundle(
timelineRenderers: [],
};
const surfaceIds = new Set<string>();
const sidebarViewIds = new Set<string>();
const settingsScreenIds = new Set<string>();
const sidebarItemIds: Record<PluginSidebarSection, Set<string>> = {
header: new Set(),
@@ -270,6 +272,18 @@ export function runPluginClientBundle(
() => surfaceIds.delete(normalizedId),
);
},
addSidebarView(contribution) {
const normalizedId = requireId(contribution.id, "sidebar view id");
if (sidebarViewIds.has(normalizedId))
throw new Error(`Duplicate sidebar view: ${normalizedId}`);
if (!isComponentType(contribution.Component))
throw new Error(`Sidebar view ${normalizedId} is not a component`);
const title = requirePluginText(contribution.title);
sidebarViewIds.add(normalizedId);
return register(collector.sidebarViews, { ...contribution, id: normalizedId, title }, () =>
sidebarViewIds.delete(normalizedId),
);
},
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("header", contribution);
},
@@ -518,7 +532,8 @@ export function runPluginClientBundle(
if (name === "@getpaseo/plugin/client")
return {
...pluginClientRuntime,
useSettings,
useSettings: (definition: import("@getpaseo/plugin").SettingsDefinition) =>
useSettings(definition, id),
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
useHosts: () =>
@@ -583,6 +598,7 @@ export function runPluginClientBundle(
cleanup,
surfaces: collector.surfaces,
settingsScreens: collector.settingsScreens,
sidebarViews: collector.sidebarViews,
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
filePanels: collector.filePanels,
@@ -1,7 +1,8 @@
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { usePaseo, useRpc } from "@getpaseo/plugin/client";
import { defineRpc } from "@getpaseo/plugin";
import type { PaseoApi } from "@getpaseo/client";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { QueryClient } from "@tanstack/react-query";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin {
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
paseo: { dispose: async () => undefined } as PaseoApi,
paseo: createPaseoApi(
new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "installation-provider",
reconnect: { enabled: false },
}),
),
invoke: async (method) => {
invoked.push(method);
return { state: "green" };
@@ -36,6 +43,7 @@ function installation(invoked: string[]): InstalledPlugin {
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -75,4 +83,6 @@ it("gives every surface the installation's client and RPCs on its first frame",
expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true);
await expect(rpc!({})).resolves.toEqual({ state: "green" });
expect(invoked).toEqual(["deploys.status"]);
plugin.lifetime.abort();
await plugin.paseo.dispose();
});
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { QueryClientProvider } from "@tanstack/react-query";
import {
PaseoApiProvider,
@@ -17,7 +18,7 @@ export function PluginInstallationProvider({
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PaseoApiProvider paseo={getPluginClientApi(plugin).paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { filePanelTitle } from "@/file-panels/choice/presentation";
import { i18n } from "@/i18n/i18next";
import { createPluginHosts } from "./hosts";
@@ -50,7 +51,7 @@ function registry(
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("Unexpected plugin RPC");
@@ -1,3 +1,4 @@
import { getPluginClientApi } from "./client-state/api";
import { createPluginHosts } from "./hosts";
import { afterEach, expect, it } from "vitest";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -25,7 +26,7 @@ function registry(version: string) {
},
installation.lifetime.signal,
),
paseo: installation.paseo,
paseo: getPluginClientApi(installation).paseo,
i18n: installation.i18n,
rpc: async () => {
throw new Error("No RPC in this plugin");
+1
View File
@@ -122,6 +122,7 @@ export class PluginRegistry {
cleanup: () => undefined,
surfaces: [],
settingsScreens: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import { createDeviceSettings } from "./device";
function device(records = new Map<string, string>()) {
return createDeviceSettings({
getItem: async (key) => records.get(key) ?? null,
setItem: async (key, value) => {
records.set(key, value);
},
});
}
const definition = defineSettings({
id: "layout",
scope: "device",
version: 1,
schema: z.object({ collapsed: z.array(z.string()).default([]) }),
});
describe("device plugin settings", () => {
it("shares one installation across hosts and keeps other installations separate", async () => {
const settings = device();
const first = settings("plugin", definition);
const second = settings("plugin", definition);
const before = await first.read();
expect(before).toEqual({ status: "ready", revision: "missing", values: { collapsed: [] } });
const saved = await first.write({ revision: before.revision, values: { collapsed: ["work"] } });
expect(saved.status).toBe("saved");
expect(await second.read()).toMatchObject({ status: "ready", values: { collapsed: ["work"] } });
expect(await settings("other", definition).read()).toEqual(before);
});
it("serializes competing writes and resets through the same revision contract", async () => {
const settings = device()("plugin", definition);
const initial = await settings.read();
const results = await Promise.all([
settings.write({ revision: initial.revision, values: { collapsed: ["a"] } }),
settings.write({ revision: initial.revision, values: { collapsed: ["b"] } }),
]);
expect(results.map((result) => result.status)).toEqual(["saved", "conflict"]);
const current = await settings.read();
expect(await settings.reset({ revision: current.revision })).toMatchObject({
status: "saved",
values: { collapsed: [] },
});
});
it("validates writes and migrates saved layout values", async () => {
const settings = device();
const first = settings("plugin", definition);
expect(await first.write({ revision: "missing", values: { collapsed: 3 } })).toMatchObject({
status: "invalid",
});
await first.write({ revision: "missing", values: { collapsed: ["a"] } });
const upgraded = settings(
"plugin",
defineSettings({
...definition,
version: 2,
migrate: (_values, from) => ({ collapsed: [`v${from}`] }),
}),
);
expect(await upgraded.read()).toMatchObject({ status: "ready", values: { collapsed: ["v1"] } });
});
});
it("persists a validated migration once and returns the persisted revision across restarts", async () => {
const records = new Map<string, string>();
const settings = device(records);
await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } });
let migrations = 0;
const upgraded = defineSettings({
...definition,
version: 2,
migrate: () => {
migrations++;
return { collapsed: ["migrated"] };
},
});
const first = await settings("plugin", upgraded).read();
const restarted = device(records)("plugin", upgraded);
expect(await restarted.read()).toEqual(first);
expect(migrations).toBe(1);
expect(first).toEqual({
status: "ready",
revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }),
values: { collapsed: ["migrated"] },
});
expect(
await restarted.write({
revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }),
values: { collapsed: [] },
}),
).toMatchObject({ status: "conflict" });
expect(
await restarted.write({ revision: first.revision, values: { collapsed: [] } }),
).toMatchObject({ status: "saved" });
});
@@ -0,0 +1,80 @@
import {
settingsRpc,
type SettingsDefinition,
type RpcOutput,
type RpcInput,
} from "@getpaseo/plugin";
import { z } from "zod";
interface DeviceStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
type ReadResult = RpcOutput<ReturnType<typeof settingsRpc>["read"]>;
type WriteResult = RpcOutput<ReturnType<typeof settingsRpc>["write"]>;
type WriteInput = RpcInput<ReturnType<typeof settingsRpc>["write"]>;
const envelope = z.object({ version: z.number().int().positive(), values: z.json() });
/** One writer per device; installations with the same id share their layout settings across hosts. */
export function createDeviceSettings(storage: DeviceStorage) {
let queue: Promise<unknown> = Promise.resolve();
function serial<T>(work: () => Promise<T>): Promise<T> {
const result = queue.then(work);
queue = result.catch(() => undefined);
return result;
}
return function settings(installationId: string, definition: SettingsDefinition) {
const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`;
async function read(): Promise<ReadResult> {
const raw = await storage.getItem(key);
let revision = raw ?? "missing";
try {
let values: unknown = {};
let migrated = false;
if (raw !== null) {
const stored = envelope.parse(JSON.parse(raw));
if (stored.version > definition.version)
throw new Error("Update this plugin to read its newer settings");
values = stored.values;
if (stored.version < definition.version) {
if (!definition.migrate)
throw new Error("This plugin has no migration for its saved settings");
values = await definition.migrate(values, stored.version);
migrated = true;
}
}
const parsed = z.json().parse(await definition.schema.parseAsync(values));
if (migrated) {
const serialized = JSON.stringify({ version: definition.version, values: parsed });
await storage.setItem(key, serialized);
revision = serialized;
}
return { status: "ready", revision, values: parsed };
} catch (error) {
return {
status: "invalid",
revision,
error: error instanceof Error ? error.message : String(error),
};
}
}
function write(input: WriteInput): Promise<WriteResult> {
return serial(async () => {
const raw = await storage.getItem(key);
if ((raw ?? "missing") !== input.revision)
return { status: "conflict", error: "Settings changed. Reload before saving." };
const parsed = await definition.schema.safeParseAsync(input.values);
if (!parsed.success) return { status: "invalid", error: parsed.error.message };
const values = z.json().parse(parsed.data);
const serialized = JSON.stringify({ version: definition.version, values });
await storage.setItem(key, serialized);
return { status: "saved", revision: serialized, values };
});
}
return {
read: () => serial(read),
write,
reset: (input: { revision: string }) => write({ revision: input.revision, values: {} }),
};
};
}
@@ -1,3 +1,6 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { queryClient as appQueryClient } from "@/data/query-client";
import { createDeviceSettings } from "./device";
import type { SettingsState } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -8,6 +11,8 @@ import { useReplicaQuery } from "@/data/query";
import { z, type ZodType } from "zod";
import { pluginSettingsKey } from "./query";
const deviceSettings = createDeviceSettings(AsyncStorage);
function message(error: unknown): string {
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
return error instanceof Error ? error.message : String(error);
@@ -15,34 +20,49 @@ function message(error: unknown): string {
export function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
installationId: string,
): SettingsState<Schema> {
const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]);
const read = useRpc(rpc.read);
const write = useRpc(rpc.write);
const reset = useRpc(rpc.reset);
const client = useQueryClient();
const key = pluginSettingsKey(definition.id);
const query = useReplicaQuery({
queryKey: key,
pushEvent: "plugin_settings_changed",
queryFn: () => read({}),
retry: false,
});
const mutation = useMutation({
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await write({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await reset(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
const installationClient = useQueryClient();
const device = definition.scope === "device";
const client = device ? appQueryClient : installationClient;
const key = device
? ["plugin-device-settings", installationId, definition.id, definition.version]
: pluginSettingsKey(definition.id);
const local = useMemo(
() => deviceSettings(installationId, definition),
[installationId, definition],
);
const query = useReplicaQuery(
{
queryKey: key,
pushEvent: device ? "plugin_device_settings_saved" : "plugin_settings_changed",
queryFn: () => (device ? local.read() : read({})),
retry: false,
},
});
client,
);
const mutation = useMutation(
{
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await (device ? local.write : write)({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await (device ? local.reset : reset)(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
},
},
client,
);
async function save(values: z.output<Schema>, revision: string) {
try {
await mutation.mutateAsync({ values, revision });
@@ -31,6 +31,7 @@ function installed(
},
settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarViews: [],
sidebarItems: {
header: [{ id: contributionId, title: "Example", Component: () => null }],
footer: [{ id: "status", title: "Status", Component: () => null }],
@@ -0,0 +1,25 @@
import type { InstalledPlugin } from "../types";
export interface SidebarViewEntry {
id: string;
title: string;
plugin: InstalledPlugin;
view: InstalledPlugin["sidebarViews"][number];
}
export function sidebarViews(plugins: readonly InstalledPlugin[]): SidebarViewEntry[] {
const entries = new Map<string, SidebarViewEntry>();
for (const plugin of plugins) {
for (const view of plugin.sidebarViews) {
const id = `${plugin.id}/${view.id}`;
if (!entries.has(id))
entries.set(id, {
id,
title: plugin.i18n.resolve(view.title, plugin.locale),
plugin,
view,
});
}
}
return [...entries.values()];
}
@@ -0,0 +1,69 @@
import React, { act, useMemo } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it } from "vitest";
import { SidebarViewFrame } from "./frame";
import { useSidebarViewSelection, type SidebarViewStorage } from "./selection";
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let defaultRenders = 0;
function DefaultView() {
defaultRenders++;
return <div>Default sidebar</div>;
}
function PluginView() {
return <div>Stored plugin sidebar</div>;
}
function Sidebar({ storage }: { storage: SidebarViewStorage }) {
const selection = useSidebarViewSelection(storage);
const fallback = useMemo(() => <DefaultView />, []);
const selected =
selection.state.status === "ready" && selection.state.selection === "example/workspaces";
const view = selected ? <PluginView /> : null;
return (
<SidebarViewFrame state={selection.state} fallback={fallback} reload={selection.reload}>
{view}
</SidebarViewFrame>
);
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
defaultRenders = 0;
client.clear();
});
function deferredStorage() {
let finishRead: (value: string) => void = () => {
throw new Error("Read not started");
};
const read = new Promise<string>((resolve) => {
finishRead = resolve;
});
const storage: SidebarViewStorage = { getItem: () => read, setItem: async () => {} };
return { storage, finishRead };
}
it("never renders the default before a stored plugin selection is known", async () => {
const { storage, finishRead } = deferredStorage();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () =>
root?.render(
<QueryClientProvider client={client}>
<Sidebar storage={storage} />
</QueryClientProvider>,
),
);
expect(defaultRenders).toBe(0);
await act(async () => {
finishRead("example/workspaces");
});
await expect.poll(() => container?.textContent).toContain("Stored plugin sidebar");
expect(defaultRenders).toBe(0);
});
@@ -0,0 +1,34 @@
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import type { SidebarViewSelectionState } from "./selection";
export function SidebarViewFrame({
state,
fallback,
children,
reload,
}: {
state: SidebarViewSelectionState;
fallback: ReactNode;
children: ReactNode;
reload(): void;
}) {
const { t } = useTranslation();
if (state.status === "loading") return <SidebarAgentListSkeleton />;
if (state.status === "error")
return (
<View style={styles.failure}>
<Text style={styles.text}>{t("sidebar.view.readFailed")}</Text>
<Button onPress={reload}>{t("common.actions.retry")}</Button>
</View>
);
return children ?? fallback;
}
const styles = StyleSheet.create((theme) => ({
failure: { padding: theme.spacing[4], gap: theme.spacing[3] },
text: { color: theme.colors.foreground },
}));
@@ -0,0 +1,173 @@
import { useTranslation } from "react-i18next";
import { SidebarViewFrame } from "./frame";
import type { PluginSidebarViewProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { router } from "expo-router";
import { useMemo, useCallback, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { entityRefs } from "../client-state/refs";
import { PluginEnvironmentProvider } from "../popover";
import { resolvePluginPlatform } from "../platform";
import { useInstalledPlugins } from "../registry";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import { sidebarViews, type SidebarViewEntry } from "./catalog";
import { useSidebarViewSelection } from "./selection";
const source = createPluginClientStateSource();
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function SidebarViewSlot({ children, dismiss }: { children: ReactNode; dismiss(): void }) {
const { t } = useTranslation();
const installed = useInstalledPlugins();
const { state, reload, setSelection, pending, error } = useSidebarViewSelection();
const restoreDefault = useCallback(() => setSelection("default"), [setSelection]);
const renderError = useCallback(
() => (
<View style={styles.placeholder}>
<Text style={styles.text}>{t("sidebar.view.failed")}</Text>
<Button onPress={restoreDefault} disabled={pending}>
{t("sidebar.view.useDefault")}
</Button>
{error ? <Text style={styles.text}>{error.message}</Text> : null}
</View>
),
[restoreDefault, pending, error, t],
);
const entry =
state.status === "ready"
? sidebarViews(installed).find((view) => view.id === state.selection)
: undefined;
const view = entry ? (
<View style={styles.content} testID="plugin-sidebar-view">
<SurfaceErrorBoundary
installation={entry.plugin}
Surface={entry.view.Component}
renderError={renderError}
>
<ThemedSidebarView entry={entry} dismiss={dismiss} uniProps={themeMapping} />
</SurfaceErrorBoundary>
</View>
) : null;
return (
<SidebarViewFrame state={state} fallback={children} reload={reload}>
{view}
</SidebarViewFrame>
);
}
function SidebarView({
entry,
dismiss,
theme,
}: {
entry: SidebarViewEntry;
dismiss(): void;
theme: PluginTheme;
}) {
const { t } = useTranslation();
const toast = useToast();
const compact = useIsCompactFormFactor();
const selection = useActiveWorkspaceSelection();
const focusedAgentId = useSessionStore((state) =>
selection ? (state.sessions[selection.serverId]?.focusedAgentId ?? null) : null,
);
const activeWorkspace = selection
? entityRefs.workspace(selection.serverId, selection.workspaceId)
: null;
const activeAgent =
selection && focusedAgentId ? entityRefs.agent(selection.serverId, focusedAgentId) : null;
const environment = useMemo(
() => ({ installation: entry.plugin, state: source, toast }),
[entry.plugin, toast],
);
const navigation = useMemo<PluginSidebarViewProps["navigation"]>(
() => ({
openWorkspace(ref) {
navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id });
dismiss();
},
openAgent(ref) {
const agent = source.getAgent(ref);
if (!agent) {
toast.error(t("sidebar.view.agentUnavailable"));
return;
}
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: agent.workspace.id,
target: { kind: "agent", agentId: ref.id },
});
dismiss();
},
openTerminal(ref) {
const terminal = source.getTerminal(ref);
if (!terminal) {
toast.error(t("sidebar.view.terminalUnavailable"));
return;
}
navigateToWorkspace({
serverId: ref.hostId,
workspaceId: terminal.workspace.id,
target: { kind: "terminal", terminalId: ref.id },
});
dismiss();
},
openProject(ref) {
const project = source.getProject(ref);
if (!project) {
toast.error(t("sidebar.view.projectUnavailable"));
return;
}
router.push(
buildNewWorkspaceRoute({
serverId: ref.hostId,
projectId: ref.id,
sourceDirectory: project.rootPath,
displayName: project.displayName,
}),
);
dismiss();
},
}),
[dismiss, toast, t],
);
const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]);
const active = useMemo(
() => ({ workspace: activeWorkspace, agent: activeAgent }),
[activeWorkspace, activeAgent],
);
const Component = entry.view.Component;
return (
<PluginEnvironmentProvider environment={environment}>
<Component
theme={theme}
layout={layout}
active={active}
navigation={navigation}
dismiss={dismiss}
/>
</PluginEnvironmentProvider>
);
}
const ThemedSidebarView = withUnistyles(SidebarView);
const styles = StyleSheet.create((theme) => ({
content: { flex: 1 },
placeholder: { padding: theme.spacing[4], gap: theme.spacing[3] },
text: { color: theme.colors.foreground },
}));
export { SidebarViewPicker } from "./picker";
@@ -0,0 +1,58 @@
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsRow, SettingsSection, SettingsSelect } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { useInstalledPlugins } from "../registry";
import { sidebarViews } from "./catalog";
import { useSidebarViewSelection } from "./selection";
export function SidebarViewPicker() {
const { t } = useTranslation();
const views = sidebarViews(useInstalledPlugins());
const { state, reload, setSelection, pending, error } = useSidebarViewSelection();
let content;
if (state.status === "loading") {
content = (
<SettingsRow label={t("settings.appearance.sidebar.view.label")}>
<Text style={styles.message}>{t("common.loading")}</Text>
</SettingsRow>
);
} else if (state.status === "error") {
content = (
<SettingsRow
label={t("settings.appearance.sidebar.view.label")}
hint={t("sidebar.view.readFailed")}
>
<Button onPress={reload}>{t("common.actions.retry")}</Button>
</SettingsRow>
);
} else {
const missing =
state.selection !== "default" && !views.some((view) => view.id === state.selection);
const value = missing ? "default" : state.selection;
let hint: string | undefined;
if (missing) hint = t("settings.appearance.sidebar.view.unavailable");
if (error) hint = error.message;
const options = [
{ value: "default", label: t("settings.appearance.sidebar.view.default") },
...views.map((view) => ({ value: view.id, label: view.title })),
];
content = (
<SettingsSelect
label={t("settings.appearance.sidebar.view.label")}
value={value}
options={options}
onValueChange={setSelection}
disabled={pending}
hint={hint}
/>
);
}
return (
<SettingsSection title={t("settings.appearance.sidebar.view.title")}>
<SettingsCard>{content}</SettingsCard>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -0,0 +1,47 @@
import { useCallback } from "react";
import { useFetchQuery } from "@/data/query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQueryClient } from "@tanstack/react-query";
const key = "sidebar.view";
const queryKey = [key];
export interface SidebarViewStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
export type SidebarViewSelectionState =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "ready"; selection: string };
export function useSidebarViewSelection(storage: SidebarViewStorage = AsyncStorage) {
const client = useQueryClient();
const query = useFetchQuery({
queryKey,
queryFn: async () => (await storage.getItem(key)) ?? "default",
dataShape: "value",
immutableWhen: () => true,
});
const save = useMutation({
mutationFn: (selection: string) => storage.setItem(key, selection),
onSuccess: (_result, selection) => {
client.setQueryData(queryKey, selection);
},
});
let state: SidebarViewSelectionState;
if (query.isPending) state = { status: "loading" };
else if (query.isError) state = { status: "error", error: query.error };
else state = { status: "ready", selection: query.data };
const { refetch } = query;
const reload = useCallback(() => {
void refetch();
}, [refetch]);
return {
state,
reload,
setSelection: save.mutate,
pending: save.isPending,
error: save.error,
};
}
@@ -49,6 +49,7 @@ function installation(
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: sidebarItems.map((item) => ({
...item,
+1
View File
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -24,6 +24,7 @@ function plugin(input: {
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -76,6 +76,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "plugin":
case "turn_summary":
return null;
}
}
@@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null }));
const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null }));
vi.mock("@/runtime/host-runtime", () => ({
vi.mock("@/runtime/host-runtime", async (importOriginal) => ({
...(await importOriginal<typeof import("@/runtime/host-runtime")>()),
useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient),
useHosts: () => [{ serverId: "host-1", label: "Local" }],
}));
+2
View File
@@ -13,6 +13,7 @@ import type {
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
PluginComposerPillContribution as SDKPluginComposerPillContribution,
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarViewContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
PluginScreenContribution as SDKPluginScreenContribution,
PluginSettingsScreenContribution as SDKPluginSettingsScreenContribution,
@@ -48,6 +49,7 @@ export interface EvaluatedPlugin {
cleanup: PluginCleanup;
surfaces: PluginScreenContribution[];
settingsScreens: PluginSettingsScreenContribution[];
sidebarViews: PluginSidebarViewContribution<PluginText>[];
sidebarItems: Record<PluginSidebarSection, PluginSidebarItemContribution[]>;
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarViews: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
@@ -131,6 +131,34 @@ describe("resolveDefaultModel", () => {
});
});
describe("remembered model availability", () => {
it("uses the selected host's default when the remembered model belongs to another host", () => {
const preferences = {
provider: "codex" as const,
providerPreferences: { codex: { model: "remote/model" } },
};
const models: AgentModelDefinition[] = [
{ provider: "codex", id: "local/first", label: "First" },
{ provider: "codex", id: "local/default", label: "Default", isDefault: true },
];
const resolve = (availableModels: AgentModelDefinition[] | null) =>
resolveFormState(
undefined,
preferences,
availableModels,
INITIAL_USER_MODIFIED,
makeState().form,
codexProviderMap,
).model;
expect(resolve(null)).toBe("remote/model");
expect(resolve(models)).toBe("local/default");
expect(resolve([{ provider: "codex", id: "remote/model", label: "Remote" }])).toBe(
"remote/model",
);
});
});
describe("model aliases", () => {
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
const resolved = resolveFormState(
@@ -791,17 +819,16 @@ describe("resolveFormState", () => {
});
});
it("keeps the explicit model when a refreshed catalogue no longer lists it", () => {
it("keeps a user-selected model when a refreshed catalog no longer lists it", () => {
const resolved = resolveFormState(
undefined,
{ provider: "codex", providerPreferences: { codex: { model: "gpt-6-astra" } } },
{ provider: "codex" },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState().form,
{ ...INITIAL_USER_MODIFIED, model: true },
makeState({ provider: "codex", model: "gpt-6-astra" }).form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-6-astra");
// Label/persistence lookup must not reinterpret the submitted ID as another model.
expect(resolveEffectiveModel(CODEX_MODELS, resolved.model)).toBeNull();
});
@@ -1269,3 +1296,197 @@ it("owns input readiness, reopening and user edits in the reducer", () => {
state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true });
expect(state.form).toMatchObject({ provider: "codex", model: "astra" });
});
it("resolves a remembered model against the new host after its catalog finishes loading", () => {
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "second-host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: undefined,
preferences: {
provider: "codex",
providerPreferences: { codex: { model: "remote/model" } },
},
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const firstHost = resolveAgentForm(makeState(), {
...inputs,
serverId: "first-host",
providerModelsByProvider: makeProviderModelsByProvider([
["codex", [{ provider: "codex", id: "remote/model", label: "Remote" }]],
]),
});
const opening = resolveAgentForm(firstHost, inputs);
expect(opening.form.model).toBe("remote/model");
expect(opening.resolution.status).toBe("pending");
const ready = resolveAgentForm(opening, {
...inputs,
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
});
expect(ready.form.model).toBe("gpt-5.3-codex");
expect(ready.form.thinkingOptionId).toBe("xhigh");
expect(ready.resolution.status).toBe("completed");
});
it("retains user input while the opening catalog is pending", () => {
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: undefined,
preferences: { provider: "codex", providerPreferences: { codex: { model: "remote/model" } } },
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const opening = resolveAgentForm(makeState(), inputs);
const edited = resolveAgentForm(opening, {
type: "SET_MODEL_FROM_USER",
modelId: "manual/model",
availableModels: null,
providerPrefs: undefined,
});
const ready = resolveAgentForm(edited, {
...inputs,
providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]),
});
expect(ready.form.model).toBe("manual/model");
expect(ready.resolution.status).toBe("completed");
});
it("resolves modeless providers once their empty catalog is ready", () => {
const ready = resolveAgentForm(makeState(), {
type: "COMPLETE_RESOLUTION",
initialValues: undefined,
preferences: { provider: "pi" },
providerModelsByProvider: makeProviderModelsByProvider([["pi", []]]),
allowedProviderMap: buildProviderDefinitionMap([TEST_PI_DEFINITION]),
});
expect(ready.form).toMatchObject({ provider: "pi", model: "" });
expect(ready.resolution.status).toBe("completed");
});
it("ignores equivalent pending inputs with freshly built fork defaults", () => {
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: { provider: "codex", model: "gpt-5.3-codex", modeId: "full-access" },
preferences: null,
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const pending = resolveAgentForm(makeState(), inputs);
expect(resolveAgentForm(pending, { ...inputs, initialValues: { ...inputs.initialValues } })).toBe(
pending,
);
});
it("keeps the new provider's mode and thinking when switching away from pending fork defaults", () => {
const claudeModels: AgentModelDefinition[] = [
{
provider: "claude",
id: "sonnet",
label: "Sonnet",
defaultThinkingOptionId: "low",
thinkingOptions: [
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
],
},
];
const claudePrefs = { mode: "bypassPermissions", thinkingByModel: { sonnet: "high" } };
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: {
provider: "codex",
model: "gpt-5.3-codex",
modeId: "full-access",
thinkingOptionId: "low",
},
preferences: { providerPreferences: { claude: claudePrefs } },
allowedProviderMap: bothProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([
["codex", null],
["claude", claudeModels],
]),
};
const pending = resolveAgentForm(makeState(), inputs);
const switched = resolveAgentForm(pending, {
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider: "claude",
modelId: "sonnet",
providerDef: TEST_CLAUDE_DEFINITION,
providerModels: claudeModels,
providerPrefs: claudePrefs,
});
expect(switched.form).toMatchObject({
provider: "claude",
model: "sonnet",
modeId: "bypassPermissions",
thinkingOptionId: "high",
});
expect(resolveAgentForm(switched, inputs).form).toEqual(switched.form);
});
it("keeps a new model's thinking preference when pending fork defaults finish resolving", () => {
const models: AgentModelDefinition[] = [
...CODEX_MODELS,
{ ...CODEX_MODELS[0], id: "next-model", defaultThinkingOptionId: "low" },
];
const preferences = {
providerPreferences: { codex: { thinkingByModel: { "next-model": "xhigh" } } },
};
const inputs = {
type: "INPUTS_CHANGED" as const,
serverId: "host",
isVisible: true,
isCreateFlow: true,
isPreferencesLoading: false,
hasSnapshot: true,
initialValues: { provider: "codex", model: "gpt-5.3-codex", thinkingOptionId: "low" },
preferences,
allowedProviderMap: codexProviderMap,
providerModelsByProvider: makeProviderModelsByProvider([["codex", null]]),
};
const pending = resolveAgentForm(makeState(), inputs);
const edited = resolveAgentForm(pending, {
type: "SET_MODEL_FROM_USER",
modelId: "next-model",
availableModels: models,
providerPrefs: preferences.providerPreferences.codex,
});
expect(edited.form.thinkingOptionId).toBe("xhigh");
const ready = resolveAgentForm(edited, {
...inputs,
providerModelsByProvider: makeProviderModelsByProvider([["codex", models]]),
});
expect(ready.form).toEqual(edited.form);
});
it("preserves saved thinking when a legacy default-model fork resolves", () => {
const resolved = resolveFormState(
{ provider: "codex", model: "default", thinkingOptionId: "low" },
{ providerPreferences: { codex: { thinkingByModel: { "gpt-5.3-codex": "xhigh" } } } },
CODEX_MODELS,
INITIAL_USER_MODIFIED,
makeState().form,
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
expect(resolved.thinkingOptionId).toBe("low");
});
@@ -141,7 +141,11 @@ function resolveCanonicalModelId(
): string {
const normalizedModelId = normalizeSelectedModelId(modelId);
if (!normalizedModelId || !availableModels) return normalizedModelId;
return findModelByReference(availableModels, normalizedModelId)?.id ?? "";
const model = findModelByReference(availableModels, normalizedModelId);
if (model) return model.id;
// COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06.
// Older drafts used "default" before providers exposed concrete model IDs.
return normalizedModelId === "default" ? resolveDefaultModelId(availableModels) : "";
}
export function resolveEffectiveModel(
@@ -304,22 +308,16 @@ function resolveModelField(input: {
if (!provider) return "";
const initialModel = normalizeSelectedModelId(initialValues?.model);
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
// COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06.
// Older drafts used "default" before providers exposed concrete model IDs.
if ((initialModel || preferredModel) === "default" && availableModels?.length) {
return (
findModelByReference(availableModels, "default")?.id || resolveDefaultModelId(availableModels)
);
}
if (initialModel) {
return !availableModels
? initialModel
: resolveCanonicalModelId(availableModels, initialModel) || initialModel;
}
if (preferredModel) {
return !availableModels
return !availableModels?.length
? preferredModel
: resolveCanonicalModelId(availableModels, preferredModel) || preferredModel;
: resolveCanonicalModelId(availableModels, preferredModel) ||
resolveDefaultModelId(availableModels);
}
return "";
}
@@ -344,8 +342,11 @@ function resolveThinkingOption(input: {
} = input;
if (!provider) return "";
if (userModified) return currentThinkingOptionId;
const initialModel = normalizeSelectedModelId(initialValues?.model);
const matchesInitialModel =
!initialModel || resolveCanonicalModelId(availableModels, initialModel) === modelId;
const initialThinkingOptionId =
typeof initialValues?.thinkingOptionId === "string"
matchesInitialModel && typeof initialValues?.thinkingOptionId === "string"
? initialValues.thinkingOptionId.trim()
: "";
const preferredThinking = resolvePreferredThinkingOptionId({
@@ -375,6 +376,10 @@ export function resolveFormState(
preferences,
});
const providerInitialValues =
initialValues?.provider && initialValues.provider !== result.provider
? undefined
: initialValues;
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
const providerPrefs = result.provider
? preferences?.providerPreferences?.[result.provider]
@@ -384,7 +389,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.modeId,
currentModeId: result.modeId,
initialValues,
initialValues: providerInitialValues,
providerDef,
providerPrefs,
});
@@ -393,7 +398,7 @@ export function resolveFormState(
provider: result.provider,
userModified: userModified.model,
currentModel: result.model,
initialValues,
initialValues: providerInitialValues,
providerPrefs,
availableModels,
});
@@ -403,7 +408,7 @@ export function resolveFormState(
userModified: userModified.thinkingOptionId,
currentThinkingOptionId: result.thinkingOptionId,
modelId: result.model,
initialValues,
initialValues: providerInitialValues,
providerPrefs,
availableModels,
});
@@ -532,9 +537,14 @@ function completeResolution(
state.form,
action.allowedProviderMap,
);
const nextState = { ...state, resolution: { status: "completed" } as const };
if (!hasFormStateChanged(state.form, resolved)) return nextState;
return { ...nextState, form: resolved };
const waitingForModels =
resolved.provider !== null && action.providerModelsByProvider.get(resolved.provider) === null;
const resolution = waitingForModels
? PENDING_AGENT_FORM_RESOLUTION
: ({ status: "completed" } as const);
const form = hasFormStateChanged(state.form, resolved) ? resolved : state.form;
if (form === state.form && resolution.status === state.resolution.status) return state;
return { ...state, resolution, form };
}
function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) {
@@ -0,0 +1,72 @@
import { expect, it } from "vitest";
import { createHostLogger } from "./host-logger";
it("preserves the original transport cause and tolerates a cyclic cause chain", () => {
const lines: string[] = [];
const capture = (...values: unknown[]) => lines.push(values.map(String).join(" "));
const logger = createHostLogger(() => "test-host", {
log: capture,
warn: capture,
error: capture,
});
const transport = new Error("socket closed");
const wrapper = new Error("Subscription failed", { cause: transport });
Object.defineProperty(transport, "cause", { value: wrapper });
logger.error({ err: wrapper }, "subscription_error");
const record = JSON.parse(lines[0]!.slice("subscription_error ".length));
expect(record.err.cause.message).toBe("socket closed");
expect(record.err.cause.stack).toBe(transport.stack);
expect(record.err.cause.cause).toBe("[Circular]");
});
it("retains host and error details in the desktop's text-only console capture", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "offline-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
logger.warn(
{ err: { name: "Error", message: "malformed ready envelope" } },
"relay_e2ee_channel_error",
);
expect(lines).toEqual([
{
level: "warn",
text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}',
},
]);
});
it("keeps native Error messages and severity without failing on circular diagnostics", () => {
const lines: { level: string; text: string }[] = [];
const capture =
(level: string) =>
(...values: unknown[]) => {
lines.push({ level, text: values.map(String).join(" ") });
};
const logger = createHostLogger(() => "test-host", {
log: capture("info"),
warn: capture("warn"),
error: capture("error"),
});
const record: { err: Error; self?: object } = { err: new Error("connection lost") };
record.self = record;
logger.error(record, "Connection failed");
expect(lines[0]?.level).toBe("error");
expect(lines[0]?.text).toContain('"message":"connection lost"');
expect(lines[0]?.text).toContain('"serverId":"test-host"');
expect(lines[0]?.text).toContain('"[Circular]"');
logger.info({ status: "offline" }, "Host status");
expect(lines[1]).toEqual({
level: "info",
text: 'Host status {"serverId":"test-host","status":"offline"}',
});
logger.debug({ status: "offline" }, "Host status");
expect(lines).toHaveLength(2);
});
+36
View File
@@ -0,0 +1,36 @@
import type { Logger } from "@getpaseo/client/internal/daemon-client";
export function createHostLogger(
readServerId: () => string | undefined,
output: Pick<Console, "log" | "warn" | "error"> = console,
): Logger {
const format = (record: object, message?: string): string => {
const seen = new WeakSet<object>();
const details = JSON.stringify(
{ serverId: readServerId(), ...record },
(_key, value: unknown) => {
if (value && typeof value === "object") {
if (seen.has(value)) return "[Circular]";
seen.add(value);
if (value instanceof Error) {
return {
...value,
name: value.name,
message: value.message,
stack: value.stack,
cause: value.cause,
};
}
}
return value;
},
);
return message ? `${message} ${details}` : details;
};
return {
debug: () => {},
info: (record, message) => output.log(format(record, message)),
warn: (record, message) => output.warn(format(record, message)),
error: (record, message) => output.error(format(record, message)),
};
}
+3
View File
@@ -1,5 +1,6 @@
import { createHostPluginClientBundleStore } from "@/plugins/client-bundles";
import { watchdog } from "@/watchdog";
import { createHostLogger } from "./host-logger";
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
@@ -589,6 +590,7 @@ function createDefaultDeps(
runtimeGeneration,
capabilities: appCapabilities,
trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }),
logger: createHostLogger(() => host.serverId),
providerSnapshots: "wire",
createPluginClientBundleStore: createHostPluginClientBundleStore,
...(host.password ? { password: host.password } : {}),
@@ -669,6 +671,7 @@ function createDefaultDeps(
null,
),
reconnect: { enabled: false },
logger: createHostLogger(() => host.serverId),
appVersion: resolveAppVersion() ?? undefined,
createPluginClientBundleStore: createHostPluginClientBundleStore,
});

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