mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0da5fce5 | ||
|
|
02ad59908f | ||
|
|
b509d499f7 | ||
|
|
f539ccf1c0 | ||
|
|
ac08077f7a | ||
|
|
79717e2bd3 | ||
|
|
22f50d4dfa | ||
|
|
2d7a115614 | ||
|
|
680e6077a8 | ||
|
|
835e339516 | ||
|
|
1927d859bc | ||
|
|
fcde2072ab | ||
|
|
2071bfda7f | ||
|
|
ed4657bdf8 | ||
|
|
71439e51a5 | ||
|
|
d92495487f | ||
|
|
19848899d0 | ||
|
|
249c35891b | ||
|
|
b7a308ca00 | ||
|
|
063b402d7c | ||
|
|
c908eddce6 | ||
|
|
8a14d7b2c3 | ||
|
|
ab8e1e61ff | ||
|
|
9131505810 | ||
|
|
c6f0c24bf6 | ||
|
|
ef7de3d066 | ||
|
|
b7b38412a0 | ||
|
|
a48bc3aa6f | ||
|
|
28c01ea1ce |
@@ -1,8 +1,8 @@
|
||||
# Agent stream disclosures
|
||||
|
||||
Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group,
|
||||
a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the
|
||||
first painted frame. No animation.
|
||||
a tool call's inline details, a todo card, a long user message's show more, a plan card) keeps the
|
||||
tapped trigger at the same screen y in the first painted frame. No animation.
|
||||
|
||||
## The contract
|
||||
|
||||
@@ -14,12 +14,20 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||
```
|
||||
|
||||
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||
`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row,
|
||||
`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
`holdRow(rowId, apply, triggerEdge)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a
|
||||
timeline row, `apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place.
|
||||
A toggle that calls `setState` directly skips the hold: on Android the expanded content paints
|
||||
over the next row for a frame before the list learns the new size.
|
||||
|
||||
`triggerEdge` names the edge of the row the trigger sits on, and the strategy keeps that edge in
|
||||
place. Header triggers sit on the top edge, the default. A long user message's show more sits under
|
||||
the text it opens, so `MessagePreview` passes `{ triggerEdge: "bottom" }`: the text grows and shrinks
|
||||
above the trigger, and the rows below stay put.
|
||||
|
||||
Following output:
|
||||
|
||||
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||
- A hold detaches the bottom anchor controller (`holdForDisclosure`), except a bottom-edge hold on
|
||||
native while following: there the end pin already keeps the row's bottom in place.
|
||||
- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll
|
||||
owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves
|
||||
the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes
|
||||
@@ -33,13 +41,16 @@ ends at its end and the trigger moves by the difference.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores
|
||||
the row's top after layout, before paint (`strategy-web.tsx`).
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
reconcile restores that edge after layout, before paint (`strategy-web.tsx`). The pin records the
|
||||
scroll offset it was taken at, because the browser clamps `scrollTop` when a collapse shrinks the
|
||||
content past the offset, before the reconcile reads it. Restoring from the clamped offset would
|
||||
apply the shrink twice.
|
||||
|
||||
## Native
|
||||
|
||||
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||
not hold. Summary lines and todo cards expand inline.
|
||||
not hold. Summary lines, todo cards, plan cards and show more expand inline.
|
||||
|
||||
The timeline is a LegendList in forward order: one `data` array of history rows followed by the
|
||||
live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size
|
||||
@@ -51,14 +62,14 @@ toggle; its applied commit took up to 3.5 s of JS on a long turn.
|
||||
|
||||
One owner keeps the scroll offset at any moment:
|
||||
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`). |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
| State | Owner |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. |
|
||||
| Following the end | The end pin (`native-end-pin.tsx`), also for a bottom-edge hold. |
|
||||
| Disclosure hold | LegendList's `maintainVisibleContentPosition` on the hold's anchor row, or the end pin for the last row's bottom. |
|
||||
| User scroll | The finger. The controller detaches at drag start. |
|
||||
| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. |
|
||||
| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. |
|
||||
|
||||
The end pin is a zero-height view at the bottom of the content and the content's first native
|
||||
child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The
|
||||
@@ -78,15 +89,38 @@ jumps by the keyboard height after the keyboard settles.
|
||||
|
||||
### The hold
|
||||
|
||||
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
|
||||
|
||||
1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and
|
||||
LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row
|
||||
(`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible
|
||||
one down, and a size change with no data change keeps the first row in view whatever
|
||||
1. Pick the anchor and detach the controller (`holdForDisclosure`). LegendList restores row
|
||||
tops only, so a top-edge hold anchors the toggled row and a bottom-edge hold anchors the row
|
||||
below it, whose top is the toggled row's bottom (`disclosureAnchor`). The pin goes away
|
||||
and LegendList's `maintainVisibleContentPosition` takes over, restoring only the anchor row
|
||||
(`shouldRestorePosition`). A bottom-edge hold while following skips this step: the end pin
|
||||
keeps the content end, and the row's bottom with it, in the frame the new height mounts.
|
||||
|
||||
A bottom-edge hold on the last row, such as a prompt the agent has not answered yet, has no
|
||||
row below it: its bottom is the content end (the running indicator is the list footer, not a
|
||||
row). The hold mounts the end pin while detached, and `apply` runs a frame later. The pin
|
||||
anchors only once it is mounted, and a pin that mounts in the same commit as the new height
|
||||
anchors nothing. The pin's commit mounts before that frame's JS callbacks run, so `apply`'s
|
||||
commit mounts with the pin already in place. The pin keeps the distance from the end, and each
|
||||
content size change clamps it to the scroll room left, so the hold re-sticks if the list was
|
||||
at its end when tapped or a collapse used up the room. The release tracks that distance
|
||||
instead of reading the offset: the new content size reaches JS before the pin's scroll does.
|
||||
A reply landing below the row ends the pin in the commit that mounts the reply, so the reply
|
||||
grows below the row and the trigger stays where it was. A reply that lands before `apply` runs
|
||||
moves the hold to the reply's row.
|
||||
|
||||
Upstream LegendList anchors only rows from the first fully visible one down to the last fully
|
||||
visible one, and a size change with no data change keeps the first row in view whatever
|
||||
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||
instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off
|
||||
at the top as in view and applies `shouldRestorePosition` to size changes too.
|
||||
instead, and a bottom-edge anchor just under the viewport would not hold at all. The LegendList
|
||||
patch counts the rows cut off at the top, and the row just past the bottom, as in view and
|
||||
applies `shouldRestorePosition` to size changes too. It also drops LegendList's Android clamp
|
||||
prediction: the scroll view clamps after the adjustment
|
||||
([scroll view corrections](#scroll-view-corrections)), so the prediction moved a collapse near
|
||||
the end to the end.
|
||||
|
||||
2. Run `apply`.
|
||||
3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so
|
||||
LegendList's container does not re-render and would learn the new height from `onLayout` a task
|
||||
@@ -130,7 +164,7 @@ With every turn collapsed, flinging from a cold open into history did not blank
|
||||
|
||||
### LegendList patch
|
||||
|
||||
`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists
|
||||
`patches/@legendapp+list+3.7.1.patch` carries twelve changes for the timeline. Its header lists
|
||||
them by purpose, and
|
||||
[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream.
|
||||
Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
@@ -276,7 +276,7 @@ its teardown; reconnect creates new server IDs for surviving client handles. See
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting; a window with no validated inbound frame is a miss, and two misses in a row tear the socket down. When the app returns to the foreground the client runs a fresh cycle of two 5-second windows back to back, then reconnects without backoff, because a socket that died while the OS had JS suspended stays `OPEN` and accepts sends: only inbound frames prove it. Misses and probes from before the suspension do not count toward that cycle, and a window whose timer fires long after its deadline spanned another suspension, so it is discarded and the cycle starts over. Any complete frame inside a window keeps the connection, so a slow multi-frame transfer survives; a single frame that takes longer than the two windows to arrive after foreground does not, and the fix for that is smaller daemon-to-client frames, not a longer window. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect.
|
||||
|
||||
Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id.
|
||||
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ Heuristic: if your hover state is going to be `useState`'d and read by anything
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
For a card whose content the user can point at or press, use `HoverCard` (`packages/app/src/components/ui/hover-card.tsx`). It is built on `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`), which computes a rectangular "bridge" across the gap between the trigger and the content, whether the card sits beside, above, or below the trigger. While the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. Only one card per overlay layer is open: pointing at another trigger replaces the open card at once, so a sweep down a list never shows two cards or leaves one behind. `Tooltip` takes no pointer events and closes as soon as the pointer leaves the trigger. If the trigger has no focusable child, give `HoverCardTrigger` `focusable` and an accessible label so keyboard users can open the same content.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,130 @@ 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);
|
||||
});
|
||||
|
||||
async function visibleFooterLeftEdges(page: Page): Promise<number[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
|
||||
).find((candidate) => candidate.checkVisibility());
|
||||
return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
|
||||
.filter((footer) => footer.checkVisibility())
|
||||
.map((footer) => Math.round(footer.getBoundingClientRect().left));
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFootersInOneColumn(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const edges = await visibleFooterLeftEdges(page);
|
||||
return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges;
|
||||
})
|
||||
.toBe("one column");
|
||||
}
|
||||
|
||||
test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
// Wider than the chat's max width, so the column is centered with space on both sides.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
gate.holdNextAgentStreamEvent("turn_completed");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", true);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates");
|
||||
await Promise.all([
|
||||
gate.waitForHeldAgentStreamEvent("turn_completed"),
|
||||
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
|
||||
]);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
await expectFootersInOneColumn(page);
|
||||
|
||||
gate.releaseHeldAgentStreamEvent("turn_completed");
|
||||
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
|
||||
gate.setServerMessageSuppressed("agent_update", false);
|
||||
await expectAgentIdle(page);
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expectFootersInOneColumn(page);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
closeActiveAgentWithShortcut,
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
@@ -20,8 +21,12 @@ import {
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
unarchiveAgentFromConversation,
|
||||
reloadWorkspace,
|
||||
} from "../support/helpers/archive-tab";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectComposerDraft, fillComposerDraft, submitMessage } from "../support/helpers/composer";
|
||||
import { awaitAssistantMessage, expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
@@ -51,6 +56,34 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("restoring a closed agent retains its unsent composer draft after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "archived-composer-",
|
||||
title: "Restore unsent draft",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await submitMessage(page, "A completed conversation before closing");
|
||||
await awaitAssistantMessage(page);
|
||||
await expectAgentIdle(page);
|
||||
await fillComposerDraft(page, "Keep this unsent message after closing");
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
await closeActiveAgentWithShortcut(page, agent.agentId);
|
||||
await expect.poll(() => fetchAgentArchivedAt(agent.client, agent.agentId)).not.toBeNull();
|
||||
await page.reload();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, "Restore unsent draft");
|
||||
await clickSessionRow(page, "Restore unsent draft");
|
||||
await expectArchivedAgentFocused(page, agent.agentId);
|
||||
await unarchiveAgentFromConversation(page);
|
||||
await expectComposerDraft(page, "Keep this unsent message after closing");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
|
||||
@@ -27,15 +27,22 @@ async function holdBranchSuggestions(page: Page) {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
|
||||
requestCount++;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage(async (message) => {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
|
||||
await gate;
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "branch_suggestions_response"
|
||||
) {
|
||||
await gate;
|
||||
}
|
||||
}
|
||||
browser.send(message);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
openChangesPanel,
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
@@ -262,6 +266,126 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes without a comparison base explains how to view pending changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2, "dev");
|
||||
await openWorkspaceChangesTree(page, workspace);
|
||||
await expect(
|
||||
page.getByText("Comparison base is unavailable. Select Uncommitted to view pending changes."),
|
||||
).toBeVisible();
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expect(changesTree(page).getByText("file-0000.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
async function openWorkspaceChangesTree(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesTreePanel(page);
|
||||
}
|
||||
|
||||
test("streamed Changes shows the tree before completion and applies a one-file refresh", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await createLargeStreamedWorkspace();
|
||||
await addOversizedSibling(workspace);
|
||||
const stream = await holdDiffCompletion(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
await expectAnnouncedTreeAndLoader(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tree-loading.png") });
|
||||
stream.finish();
|
||||
await expect.poll(() => stream.completed, { timeout: 60_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
const connections = stream.connections;
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +90, -1");
|
||||
await addOneLineToFirstFile(workspace);
|
||||
await expect.poll(() => stream.changedBodies.at(-1), { timeout: 30_000 }).toBe(1);
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("src/file-0000.ts, +91, -1");
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." })).toHaveCount(0);
|
||||
expect(stream.connections).toBe(connections);
|
||||
await page.screenshot({ path: testInfo.outputPath("refreshed.png") });
|
||||
});
|
||||
|
||||
async function createLargeStreamedWorkspace(): Promise<DirtyWorkspace> {
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(252);
|
||||
await Promise.all(
|
||||
Array.from({ length: 252 }, (_, file) =>
|
||||
writeFile(
|
||||
path.join(workspace.repoPath, `src/file-${String(file).padStart(4, "0")}.ts`),
|
||||
Array.from(
|
||||
{ length: 90 },
|
||||
(_unused, line) =>
|
||||
`export const fn${line} = (param: string) => ({ key: param, value: ${file} });`,
|
||||
).join("\n") + "\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
return workspace;
|
||||
}
|
||||
async function addOversizedSibling(workspace: DirtyWorkspace): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, "zz-too-large.txt"), "x".repeat(1024 * 1024 + 1));
|
||||
}
|
||||
async function addOneLineToFirstFile(workspace: DirtyWorkspace): Promise<void> {
|
||||
const file = path.join(workspace.repoPath, "src/file-0000.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")) + "export const refreshed = true;\n");
|
||||
}
|
||||
async function holdDiffCompletion(page: Page) {
|
||||
const { decodeDiffFrame, DiffOpcode } = await import("@getpaseo/protocol/binary-frames/index");
|
||||
let holding = true;
|
||||
let connections = 0;
|
||||
let completed = 0;
|
||||
const completions: Array<() => void> = [];
|
||||
const changedBodies: number[] = [];
|
||||
const bodyCounts = new Map<number, number>();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
connections++;
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (
|
||||
typeof message !== "string" &&
|
||||
message[0] >= DiffOpcode.Begin &&
|
||||
message[0] <= DiffOpcode.End
|
||||
) {
|
||||
const frame = decodeDiffFrame(message);
|
||||
if (frame.opcode === DiffOpcode.Begin) bodyCounts.set(frame.slot, 0);
|
||||
else if (frame.opcode === DiffOpcode.Bodies)
|
||||
bodyCounts.set(frame.slot, (bodyCounts.get(frame.slot) ?? 0) + frame.files.length);
|
||||
else {
|
||||
completed++;
|
||||
changedBodies.push(bodyCounts.get(frame.slot) ?? 0);
|
||||
}
|
||||
}
|
||||
if (holding && typeof message !== "string" && message[0] === DiffOpcode.End) {
|
||||
completions.push(() => browserSocket.send(message));
|
||||
return;
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
changedBodies,
|
||||
get connections() {
|
||||
return connections;
|
||||
},
|
||||
get completed() {
|
||||
return completed;
|
||||
},
|
||||
finish: () => {
|
||||
holding = false;
|
||||
completions.splice(0).forEach((send) => send());
|
||||
},
|
||||
};
|
||||
}
|
||||
async function expectWorkingDiffLoaded(page: Page, timeout: number): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("diff-file-0")).toBeAttached({ timeout });
|
||||
}
|
||||
async function expectAnnouncedTreeAndLoader(page: Page): Promise<void> {
|
||||
await expect(changesTree(page).first()).toBeVisible();
|
||||
await expect(page.getByRole("progressbar", { name: "Loading..." }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("Changes opens All changes for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
@@ -499,6 +623,35 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0");
|
||||
});
|
||||
|
||||
test("legacy diff hosts keep All, Committed and Uncommitted comparisons", async ({ page }) => {
|
||||
await useLegacyDiffHost(page);
|
||||
const workspace = await createWorkspaceWithScrollableComparisons();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
await expect(changesTree(page).getByText("committed", { exact: true })).toBeVisible();
|
||||
await expect(changesTree(page).getByText("uncommitted", { exact: true })).toBeVisible();
|
||||
await selectChangesComparison(page, "Committed");
|
||||
await expectWorkingComparisonFiles(page, "committed");
|
||||
await selectChangesComparison(page, "Uncommitted");
|
||||
await expectWorkingComparisonFiles(page, "uncommitted");
|
||||
});
|
||||
async function useLegacyDiffHost(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||
const serverSocket = browserSocket.connectToServer();
|
||||
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||
serverSocket.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const envelope = JSON.parse(message);
|
||||
if (envelope.message?.payload?.status === "server_info") {
|
||||
delete envelope.message.payload.features.diffSubscription;
|
||||
browserSocket.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
}
|
||||
browserSocket.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes comparison controls the working diff and tree selection focuses its file", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1311,11 +1464,13 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
test.setTimeout(300_000);
|
||||
const workspace = await createWorkspaceWithManyTinyDiffs(2_000);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace, 90_000);
|
||||
|
||||
// Measure document bounds after delivery, independently of the cold Git read.
|
||||
await expectWorkingDiffLoaded(page, 240_000);
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
@@ -1894,12 +2049,16 @@ async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||
async function createWorkspaceWithManyTinyDiffs(
|
||||
fileCount: number,
|
||||
branch = "main",
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
content: "export const value = 0;\n",
|
||||
}));
|
||||
const repo = await createTempGitRepo("changes-canvas-many-files-", { files });
|
||||
if (branch !== "main") execFileSync("git", ["branch", "-m", branch], { cwd: repo.path });
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
expectWorkspaceHeaderVisible,
|
||||
resizeComposerViewport,
|
||||
submitMessage,
|
||||
submitMessageWithButton,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -146,6 +147,14 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
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);
|
||||
|
||||
@@ -173,7 +182,7 @@ test("submitting a message leaves the composer ready for the next message", asyn
|
||||
"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 })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Copy turn", exact: true }).last()).toBeVisible();
|
||||
await connection.drop();
|
||||
await connection.waitForBlockedConnection();
|
||||
await pasteRichTextIntoComposer({
|
||||
@@ -313,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();
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
async function prepareNarrowSplit(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
@@ -74,33 +74,38 @@ async function prepareUnfocusedCompactPane(page: Page, workspaceId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function launchTerminalFromCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-tab-switcher-trigger").click();
|
||||
await page.getByTestId("workspace-tab-switcher-new-terminal").click();
|
||||
async function expectPanesKeepTabRails(page: Page, panes: Locator[]) {
|
||||
for (const pane of panes) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function launchTerminalFromPaneRail(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-terminal").filter({ visible: true }).click();
|
||||
await expect(pane.getByTestId("terminal-surface")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function maximizeAndRestoreCompactPane(page: Page, pane: Locator) {
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await expect(page.getByRole("menuitem", { name: "Split pane right", exact: true })).toBeVisible();
|
||||
await page.getByRole("menuitem", { name: "Maximize pane", exact: true }).click();
|
||||
await pane.getByTestId("workspace-header-menu-trigger").click();
|
||||
await page.getByRole("menuitem", { name: "Restore pane", exact: true }).click();
|
||||
async function maximizeAndRestorePane(pane: Locator) {
|
||||
await pane.getByTestId("workspace-maximize-pane").click();
|
||||
await pane.getByTestId("workspace-restore-pane").click();
|
||||
}
|
||||
|
||||
test("compact pane launch stays in its owning pane and pane controls remain reachable", async ({
|
||||
test("narrow split panes keep their tab rails and launches stay in the owning pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-launch-" });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-launch-" });
|
||||
try {
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
await launchTerminalFromCompactPane(page, originalPane);
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await launchTerminalFromPaneRail(page, originalPane);
|
||||
await expect(otherPane.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await maximizeAndRestoreCompactPane(page, originalPane);
|
||||
await maximizeAndRestorePane(originalPane);
|
||||
await expect(otherPane).toBeVisible();
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -118,17 +123,15 @@ async function expectPaneHasWindowDragSurface(pane: Locator) {
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("compact desktop panes retain window dragging with both sidebars closed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "compact-pane-window-drag-" });
|
||||
test("narrow desktop split panes retain window dragging with both sidebars closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "narrow-split-window-drag-" });
|
||||
try {
|
||||
await installLocalDesktopHost(page, workspace.repoPath);
|
||||
const { originalPane, otherPane } = await prepareUnfocusedCompactPane(
|
||||
page,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const { originalPane, otherPane } = await prepareNarrowSplit(page, workspace.workspaceId);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Toggle left sidebar");
|
||||
await expect(originalPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expect(otherPane.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
|
||||
await expectPanesKeepTabRails(page, [originalPane, otherPane]);
|
||||
await expectPaneHasWindowDragSurface(originalPane);
|
||||
await expectPaneHasWindowDragSurface(otherPane);
|
||||
} finally {
|
||||
|
||||
@@ -79,6 +79,12 @@ async function previewExplorerResize(page: Page, distance: number) {
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectCleanChangesLoaded(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("changes-tree-panel").getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
@@ -100,6 +106,7 @@ test("Explorer drag previews without rerendering panes or rewriting the shared s
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expectCleanChangesLoaded(page);
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -95,66 +95,63 @@ test.describe("Settings sidebar scroll position", () => {
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
test("keeps sidebar and detail scrolling independent through section changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
await test.step("scrolls the sidebar and detail pane independently", async () => {
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
await test.step("keeps the sidebar position after rapid consecutive selections", async () => {
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
await test.step("keeps the sidebar position when switching between app and host sections", async () => {
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
expectOnlyWorkspaceHoverCardFor,
|
||||
expectWorkspaceHoverCardClosed,
|
||||
expectWorkspaceHoverCardOpen,
|
||||
expectWorkspaceHoverCardStaysOpen,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
sweepPointerAcrossWorkspaceRows,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -80,6 +82,22 @@ async function openWorkspaceHoverCard(page: import("@playwright/test").Page, wor
|
||||
return hoverCard;
|
||||
}
|
||||
|
||||
async function sortWorkspacesBySidebarOrder<T extends { workspaceId: string }>(
|
||||
page: Page,
|
||||
workspaces: T[],
|
||||
): Promise<T[]> {
|
||||
const positioned = await Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const bounds = await page
|
||||
.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))
|
||||
.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspace.workspaceId}`);
|
||||
return { workspace, y: bounds.y };
|
||||
}),
|
||||
);
|
||||
return positioned.sort((left, right) => left.y - right.y).map(({ workspace }) => workspace);
|
||||
}
|
||||
|
||||
interface PaseoOwnedWorktree {
|
||||
projectName: string;
|
||||
workspaceId: string;
|
||||
@@ -430,6 +448,38 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("scanning up the workspace rows leaves only the resting row's hover card open", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspaces = await Promise.all(
|
||||
[1, 2, 3, 4, 5].map((index) =>
|
||||
seedWorkspace({
|
||||
repoPrefix: `sidebar-hover-scan-${index}-`,
|
||||
title: `Hover scan ${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
for (const workspace of workspaces) {
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
}
|
||||
const rowsTopToBottom = await sortWorkspacesBySidebarOrder(page, workspaces);
|
||||
const top = rowsTopToBottom[0]!;
|
||||
|
||||
for (let scan = 0; scan < 3; scan += 1) {
|
||||
await sweepPointerAcrossWorkspaceRows(
|
||||
page,
|
||||
rowsTopToBottom.map((workspace) => workspace.workspaceId).toReversed(),
|
||||
);
|
||||
await expectOnlyWorkspaceHoverCardFor(page, top.workspaceName);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function panes(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return visible(page, "workspace-split-resize-handle");
|
||||
}
|
||||
|
||||
async function openSplitWorkspace(
|
||||
page: Page,
|
||||
repoPrefix: string,
|
||||
split: "Split pane right" | "Split pane down",
|
||||
): Promise<SeededWorkspace> {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await runWorkspaceActionFromCommandCenter(page, split);
|
||||
await expect(splitDividers(page)).toHaveCount(1);
|
||||
await expect(panes(page)).toHaveCount(2);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async function expectOneSharedWorkspaceHeader(page: Page): Promise<void> {
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-menu-trigger")).toHaveCount(1);
|
||||
await expect(panes(page).getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
await expect(panes(page).getByTestId("workspace-header-menu-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The tab counter belongs to the single-pane layout: split panes always show their rails. */
|
||||
async function expectPanesKeepTabRails(page: Page): Promise<void> {
|
||||
for (const pane of await panes(page).all()) {
|
||||
await expect(pane.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
}
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX, y, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Workspace header with split panes", () => {
|
||||
test("wide side-by-side panes share one header and keep their tab rails", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-wide-", "Split pane right");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stacked panes share one header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-stacked-", "Split pane down");
|
||||
try {
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
await expectPanesKeepTabRails(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow workspace shows one compact header and every pane keeps its tab rail", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-narrow-", "Split pane right");
|
||||
try {
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a narrow pane beside a wide one keeps its tab rail", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-mixed-", "Split pane right");
|
||||
try {
|
||||
await dragDivider(splitDividers(page), -300);
|
||||
const [left, right] = await panes(page).all();
|
||||
const leftBox = await left.boundingBox();
|
||||
const rightBox = await right.boundingBox();
|
||||
expect(leftBox!.width).toBeLessThan(500);
|
||||
expect(rightBox!.width).toBeGreaterThan(500);
|
||||
await expectPanesKeepTabRails(page);
|
||||
await expectOneSharedWorkspaceHeader(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing back to one pane returns the merged compact header", async ({ page }) => {
|
||||
const workspace = await openSplitWorkspace(page, "split-header-close-", "Split pane right");
|
||||
try {
|
||||
const [, emptyPane] = await panes(page).all();
|
||||
const emptyPaneRail = emptyPane.getByTestId("workspace-tabs-row");
|
||||
await emptyPaneRail.locator('[data-testid^="workspace-tab-"]').first().hover();
|
||||
await emptyPaneRail.locator('[data-testid*="-close-"]').first().click();
|
||||
await expect(splitDividers(page)).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(0);
|
||||
await expect(visible(page, "workspace-tab-switcher-trigger")).toHaveCount(1);
|
||||
await expect(visible(page, "workspace-header-title")).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,11 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeActiveAgentWithShortcut(page: Page, agentId: string): Promise<void> {
|
||||
await page.keyboard.press("Alt+Shift+w");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||
visible: true,
|
||||
@@ -237,6 +242,10 @@ export async function expectArchivedAgentFocused(page: Page, agentId: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export async function unarchiveAgentFromConversation(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
|
||||
@@ -96,6 +96,35 @@ export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<voi
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
}
|
||||
|
||||
/** Scans the rows in the given order at about a user's pace, then rests on the last one. */
|
||||
export async function sweepPointerAcrossWorkspaceRows(
|
||||
page: Page,
|
||||
workspaceIds: string[],
|
||||
): Promise<void> {
|
||||
const centers: Array<{ x: number; y: number }> = [];
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const bounds = await row.boundingBox();
|
||||
if (!bounds) throw new Error(`Could not measure the row of ${workspaceId}`);
|
||||
centers.push({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 });
|
||||
}
|
||||
const [first, ...rest] = centers;
|
||||
await page.mouse.move(first!.x, first!.y);
|
||||
for (const center of rest) {
|
||||
await page.mouse.move(center.x, center.y, { steps: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceHoverCardFor(
|
||||
page: Page,
|
||||
workspaceName: string,
|
||||
): Promise<void> {
|
||||
const cards = page.getByTestId("workspace-hover-card");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards.getByTestId("hover-card-workspace-name")).toHaveText(workspaceName);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { act, createRef, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { MessagePreview } from "@/components/user-message";
|
||||
import { DisclosureRow } from "./disclosure";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
// Load translations so the toggles expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
// The plan body renders through the app's Markdown renderer, which reads through React Query.
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
interface Hold {
|
||||
rowId: string;
|
||||
triggerEdge: DisclosureTriggerEdge;
|
||||
/** Whether the expanded content was already on screen when the strategy received the hold. */
|
||||
appliedBefore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline row whose viewport records each hold. Like the strategies, it runs `apply` itself, so
|
||||
* a toggle that skips the hold still changes state but leaves no record.
|
||||
*/
|
||||
function mountRow(rowId: string, isApplied: () => boolean, children: ReactNode) {
|
||||
const holds: Hold[] = [];
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdRow: (heldRowId, apply, triggerEdge) => {
|
||||
holds.push({ rowId: heldRowId, triggerEdge, appliedBefore: isApplied() });
|
||||
apply();
|
||||
},
|
||||
};
|
||||
const viewportRef = createRef<StreamViewportHandle>();
|
||||
viewportRef.current = viewport;
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DisclosureRow rowId={rowId} viewportRef={viewportRef}>
|
||||
{children}
|
||||
</DisclosureRow>
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
return holds;
|
||||
}
|
||||
|
||||
const view = () => within(container);
|
||||
|
||||
const preLine = { whiteSpace: "pre-line" } as const;
|
||||
|
||||
async function waitForLayout() {
|
||||
await act(async () => {
|
||||
for (let frame = 0; frame < 2; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("timeline disclosures hold their row", () => {
|
||||
it("routes user message show more and show less through the row's hold", async () => {
|
||||
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
|
||||
const holds = mountRow(
|
||||
"message-1",
|
||||
() => view().queryByRole("button", { name: "Show less" }) !== null,
|
||||
<MessagePreview>
|
||||
<span style={preLine}>{longText}</span>
|
||||
</MessagePreview>,
|
||||
);
|
||||
// The preview learns it overflows from its first layout.
|
||||
await waitForLayout();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show more" }).click());
|
||||
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
|
||||
|
||||
act(() => view().getByRole("button", { name: "Show less" }).click());
|
||||
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
|
||||
|
||||
expect(holds).toEqual([
|
||||
// Show more sits under the text it opens.
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: false },
|
||||
{ rowId: "message-1", triggerEdge: "bottom", appliedBefore: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes plan card collapse and expand through the row's hold", () => {
|
||||
const header = () => view().getByRole("button", { name: "Approved plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
const holds = mountRow(
|
||||
"plan-1",
|
||||
isExpanded,
|
||||
<PlanCard title="Plan" text="Step one" outcome="approved" />,
|
||||
);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
|
||||
expect(holds).toEqual([
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: true },
|
||||
{ rowId: "plan-1", triggerEdge: "top", appliedBefore: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("toggles a plan card outside a timeline row, as the pending permission card renders", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PlanCard title="Plan" text="Step one" outcome="pending" />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
const header = () => view().getByRole("button", { name: "Plan" });
|
||||
const isExpanded = () => header().getAttribute("aria-expanded") === "true";
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(false);
|
||||
|
||||
act(() => header().click());
|
||||
expect(isExpanded()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
|
||||
import type { StreamViewportHandle } from "./strategy";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
|
||||
/**
|
||||
* Runs a disclosure's state change. Inside a timeline row, the row that owns the
|
||||
* trigger keeps its screen position through the change; elsewhere `apply` runs as is.
|
||||
* Runs a disclosure's state change. Inside a timeline row, the trigger keeps its screen
|
||||
* position through the change; elsewhere `apply` runs as is. A trigger below the content it
|
||||
* opens passes `triggerEdge: "bottom"`.
|
||||
*/
|
||||
export type DisclosureHold = (apply: () => void) => void;
|
||||
export type DisclosureHold = (
|
||||
apply: () => void,
|
||||
options?: { triggerEdge?: DisclosureTriggerEdge },
|
||||
) => void;
|
||||
|
||||
const applyInPlace: DisclosureHold = (apply) => apply();
|
||||
|
||||
@@ -21,10 +25,10 @@ export function DisclosureRow({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hold = useMemo<DisclosureHold>(
|
||||
() => (apply) => {
|
||||
() => (apply, options) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.holdRow(rowId, apply);
|
||||
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { act, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { View } from "react-native";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { disclosureAnchor } from "./native-disclosure-hold";
|
||||
|
||||
// The native chat timeline runs on the patched LegendList (patches/@legendapp+list+3.7.1.patch).
|
||||
// A disclosure hold keeps only the held row in place: `shouldRestorePosition` accepts no other row.
|
||||
@@ -130,7 +131,16 @@ function mountList(heldRowId: string) {
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
const rowBottom = (rowId: string) => {
|
||||
const state = listRef.current?.getState();
|
||||
const position = state?.positionByKey(rowId);
|
||||
const size = state?.sizes.get(rowId);
|
||||
if (state === undefined || position === undefined || size === undefined) {
|
||||
throw new Error(`Row ${rowId} is not laid out`);
|
||||
}
|
||||
return position + size - state.scroll;
|
||||
};
|
||||
return { render, scrollTo, heldRowTop, rowSize, rowBottom };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -171,6 +181,72 @@ it("anchors a size change on a held row whose top is above the viewport", async
|
||||
expect(list.heldRowTop()).toBe(-20);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row. The
|
||||
// strategy anchors the row below it, whose top is that bottom edge.
|
||||
const bottomTriggerRows: Row[] = Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `row-${index}`,
|
||||
height: index === 3 ? 60 : 40,
|
||||
}));
|
||||
|
||||
function mountBottomTriggerHold(rowId: string) {
|
||||
const anchor = disclosureAnchor(bottomTriggerRows, rowId, "bottom");
|
||||
if (anchor.kind !== "row") throw new Error(`Row ${rowId} has no row below it`);
|
||||
return mountList(anchor.rowId);
|
||||
}
|
||||
|
||||
it("keeps the bottom of a row whose trigger sits under its growing content", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(100);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(80);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row whose top is above the viewport", async () => {
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(140);
|
||||
const triggerBottom = list.rowBottom("row-3");
|
||||
expect(triggerBottom).toBe(40);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row that ends below the viewport", async () => {
|
||||
// In the app the row ends with the message's timestamp under the trigger, so a trigger near the
|
||||
// bottom of the viewport has its row's bottom, and the row below, out of view.
|
||||
const list = mountBottomTriggerHold("row-3");
|
||||
list.render(bottomTriggerRows);
|
||||
await wait(150);
|
||||
await list.scrollTo(40);
|
||||
const rowBottom = list.rowBottom("row-3");
|
||||
expect(rowBottom).toBe(140);
|
||||
|
||||
act(() => growRow("row-3", 260));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
|
||||
act(() => growRow("row-3", 60));
|
||||
await wait(150);
|
||||
expect(list.rowBottom("row-3")).toBe(rowBottom);
|
||||
});
|
||||
|
||||
it("keeps measured row sizes while the list is not laid out", async () => {
|
||||
// A hidden retained tab is display: none. Its rows still render, and a row measured there
|
||||
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type DisclosureAnchor,
|
||||
} from "./native-disclosure-hold";
|
||||
|
||||
function createFrames() {
|
||||
let nextId = 1;
|
||||
@@ -39,22 +44,22 @@ describe("native disclosure hold", () => {
|
||||
it("holds the row until two frames pass without an item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBe("turn-1");
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
|
||||
it("restarts the quiet frames on every item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
@@ -79,11 +84,11 @@ describe("native disclosure hold", () => {
|
||||
it("moves the hold to the latest toggled row and releases once", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
frames.runFrame();
|
||||
hold.hold("turn-2");
|
||||
hold.hold({ kind: "row", rowId: "turn-2" });
|
||||
|
||||
expect(hold.heldRowId()).toBe("turn-2");
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
@@ -94,12 +99,137 @@ describe("native disclosure hold", () => {
|
||||
it("drops the hold without releasing when cancelled", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold("turn-1");
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds the content end until two quiet frames pass, with no row to anchor on", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "end" });
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next-frame disclosure hold", () => {
|
||||
it("anchors the row a reply landed as while the toggle waited for its frame", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
let anchor: DisclosureAnchor = { kind: "end" };
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => anchor,
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
expect(applied).toEqual([]);
|
||||
|
||||
anchor = { kind: "row", rowId: "turn-2" };
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
expect(applied).toEqual([{ kind: "row", rowId: "turn-2" }]);
|
||||
});
|
||||
|
||||
it("still lands the toggle when the hold is cancelled before its frame", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(releases).toEqual([]);
|
||||
});
|
||||
|
||||
it("lands a waiting toggle before the next hold starts", () => {
|
||||
const { frames, hold } = createHarness();
|
||||
const applied: DisclosureAnchor[] = [];
|
||||
|
||||
hold.holdNextFrame(
|
||||
() => ({ kind: "end" }),
|
||||
(resolved) => applied.push(resolved),
|
||||
);
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
frames.runFrame();
|
||||
expect(applied).toEqual([{ kind: "end" }]);
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disclosure anchor", () => {
|
||||
const rows = [{ id: "message-1" }, { id: "turn-1" }, { id: "message-2" }];
|
||||
|
||||
it("keeps the top of a row whose trigger sits on its top edge", () => {
|
||||
expect(disclosureAnchor(rows, "turn-1", "top")).toEqual({ kind: "row", rowId: "turn-1" });
|
||||
});
|
||||
|
||||
it("keeps the top of the row below a row whose trigger sits on its bottom edge", () => {
|
||||
expect(disclosureAnchor(rows, "message-1", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-1",
|
||||
});
|
||||
});
|
||||
|
||||
// A prompt the agent has not answered yet is the last row; the running indicator is the footer.
|
||||
it("keeps the content end for a bottom-edge trigger on the last row", () => {
|
||||
expect(disclosureAnchor(rows, "message-2", "bottom")).toEqual({ kind: "end" });
|
||||
});
|
||||
|
||||
// The strategy re-derives this each render, so a reply lets go of the end in the commit it lands.
|
||||
it("stops keeping the content end once a reply lands below the row", () => {
|
||||
expect(disclosureAnchor([...rows, { id: "turn-2" }], "message-2", "bottom")).toEqual({
|
||||
kind: "row",
|
||||
rowId: "turn-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("end hold distance", () => {
|
||||
const viewportHeight = 600;
|
||||
|
||||
it("keeps the distance from the end the toggle started at", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 2000, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(20);
|
||||
});
|
||||
|
||||
it("shrinks to the scroll room a collapse leaves", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 610, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(10);
|
||||
});
|
||||
|
||||
it("stays at the end when output grows the content after a collapse reached the end", () => {
|
||||
const distance = createEndHoldDistance(20);
|
||||
|
||||
distance.noteContentSize({ contentHeight: 600, viewportHeight });
|
||||
distance.noteContentSize({ contentHeight: 700, viewportHeight });
|
||||
|
||||
expect(distance.current()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
/** What a disclosure hold keeps in place: the top of a row, or the end of the content. */
|
||||
export type DisclosureAnchor = { kind: "row"; rowId: string } | { kind: "end" };
|
||||
|
||||
/**
|
||||
* The native viewport's disclosure hold. While a row is held, it is the only row the list may
|
||||
* anchor on. The hold ends once item sizes stop changing for `quietFrames` frames: the list
|
||||
* measures the toggled row and any rows it inserted over one or more frames, and end following
|
||||
* must not resume between the change and the list's last layout correction.
|
||||
* The native viewport's disclosure hold. While it holds a row anchor, that row is the only row the
|
||||
* list may anchor on (`disclosureAnchor`). The hold ends once item sizes stop changing for
|
||||
* `quietFrames` frames: the list measures the toggled row and any rows it inserted over one or more
|
||||
* frames, and end following must not resume between the change and the list's last layout
|
||||
* correction.
|
||||
*/
|
||||
export interface NativeDisclosureHold {
|
||||
heldRowId(): string | null;
|
||||
hold(rowId: string): void;
|
||||
/** The row the list may anchor on; null outside a hold and while the hold keeps the end. */
|
||||
anchorRowId(): string | null;
|
||||
hold(anchor: DisclosureAnchor): void;
|
||||
/**
|
||||
* Holds on the next frame and runs `apply` there, for a toggle that has to land after a commit
|
||||
* already on its way. `resolveAnchor` runs in that frame, because rows may land while it waits.
|
||||
* A later hold or a cancel lands a waiting toggle right away.
|
||||
*/
|
||||
holdNextFrame(
|
||||
resolveAnchor: () => DisclosureAnchor,
|
||||
apply: (anchor: DisclosureAnchor) => void,
|
||||
): void;
|
||||
noteItemSizeChanged(): void;
|
||||
/** Drops the hold without releasing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
@@ -17,9 +34,10 @@ export function createNativeDisclosureHold(input: {
|
||||
cancelFrame: (id: number) => void;
|
||||
onRelease: () => void;
|
||||
}): NativeDisclosureHold {
|
||||
let heldRowId: string | null = null;
|
||||
let anchor: DisclosureAnchor | null = null;
|
||||
let frameId: number | null = null;
|
||||
let remainingFrames = 0;
|
||||
let waitingToggle: { frameId: number; land: () => void } | null = null;
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (frameId !== null) {
|
||||
@@ -35,7 +53,7 @@ export function createNativeDisclosureHold(input: {
|
||||
return;
|
||||
}
|
||||
frameId = null;
|
||||
heldRowId = null;
|
||||
anchor = null;
|
||||
input.onRelease();
|
||||
};
|
||||
|
||||
@@ -45,20 +63,80 @@ export function createNativeDisclosureHold(input: {
|
||||
frameId = input.requestFrame(tick);
|
||||
};
|
||||
|
||||
const landWaitingToggle = () => {
|
||||
if (!waitingToggle) return;
|
||||
const { frameId: waitingFrameId, land } = waitingToggle;
|
||||
waitingToggle = null;
|
||||
input.cancelFrame(waitingFrameId);
|
||||
land();
|
||||
};
|
||||
|
||||
const startHold = (nextAnchor: DisclosureAnchor) => {
|
||||
anchor = nextAnchor;
|
||||
restartCountdown();
|
||||
};
|
||||
|
||||
return {
|
||||
heldRowId: () => heldRowId,
|
||||
hold(rowId) {
|
||||
heldRowId = rowId;
|
||||
restartCountdown();
|
||||
anchorRowId: () => (anchor?.kind === "row" ? anchor.rowId : null),
|
||||
hold(nextAnchor) {
|
||||
landWaitingToggle();
|
||||
startHold(nextAnchor);
|
||||
},
|
||||
holdNextFrame(resolveAnchor, apply) {
|
||||
landWaitingToggle();
|
||||
const waitingFrameId = input.requestFrame(() => {
|
||||
waitingToggle = null;
|
||||
const nextAnchor = resolveAnchor();
|
||||
startHold(nextAnchor);
|
||||
apply(nextAnchor);
|
||||
});
|
||||
waitingToggle = { frameId: waitingFrameId, land: () => apply(resolveAnchor()) };
|
||||
},
|
||||
noteItemSizeChanged() {
|
||||
if (heldRowId !== null) {
|
||||
if (anchor !== null) {
|
||||
restartCountdown();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
landWaitingToggle();
|
||||
stopCountdown();
|
||||
heldRowId = null;
|
||||
anchor = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the list keeps in place while a disclosure toggles `rowId`. LegendList restores row tops, so
|
||||
* a trigger on the bottom edge of its row anchors the row below: that row's top is the toggled
|
||||
* row's bottom. The last row's bottom is the end of the content.
|
||||
*/
|
||||
export function disclosureAnchor(
|
||||
rows: readonly { id: string }[],
|
||||
rowId: string,
|
||||
triggerEdge: DisclosureTriggerEdge,
|
||||
): DisclosureAnchor {
|
||||
if (triggerEdge === "top") return { kind: "row", rowId };
|
||||
const index = rows.findIndex((row) => row.id === rowId);
|
||||
const below = rows[index + 1];
|
||||
return below ? { kind: "row", rowId: below.id } : { kind: "end" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's distance from the end during an end hold. The end pin keeps the distance through every
|
||||
* content size change, and the scroll view clamps it to the scroll room the content has at that
|
||||
* moment. A clamp is permanent: content that grows afterwards does not bring the distance back.
|
||||
*/
|
||||
export function createEndHoldDistance(distanceAtToggle: number): EndHoldDistance {
|
||||
let distance = distanceAtToggle;
|
||||
return {
|
||||
noteContentSize({ contentHeight, viewportHeight }) {
|
||||
distance = Math.min(distance, Math.max(0, contentHeight - viewportHeight));
|
||||
},
|
||||
current: () => distance,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EndHoldDistance {
|
||||
noteContentSize(input: { contentHeight: number; viewportHeight: number }): void;
|
||||
current(): number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,7 @@ describe("reading anchor", () => {
|
||||
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
|
||||
const replacements = new Map([["tool", "prompt"]]);
|
||||
expect(anchor.getRowId(replacements)).toBe("prompt");
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048, height: 100 }, replacements)).toBe(1000);
|
||||
// Projection does not consume the correction; an unmounted host keeps the pin.
|
||||
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
|
||||
expect(anchor.getRowId()).toBe("tool");
|
||||
@@ -59,13 +59,32 @@ describe("reading anchor", () => {
|
||||
it("keeps a pinned row in place instead of the row under the reading line", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!);
|
||||
anchor.pin(rows[2]!, "top", 600);
|
||||
// The row being read grows; only the pinned row below it holds its position.
|
||||
expect(
|
||||
anchor.reconcile(600, [rows[0]!, { ...rows[1]!, height: 800 }, { ...rows[2]!, top: 1300 }]),
|
||||
).toBe(900);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a row pinned by a trigger below its content", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 600);
|
||||
// The pinned row grows downward; its bottom, where the trigger sits, holds its position.
|
||||
const grown = { ...rows[2]!, height: rows[2]!.height + 300 };
|
||||
expect(anchor.reconcile(600, [rows[0]!, rows[1]!, grown])).toBe(900);
|
||||
});
|
||||
|
||||
it("measures a pinned row's correction from the offset at the toggle", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(900, rows);
|
||||
anchor.pin(rows[2]!, "bottom", 900);
|
||||
// The row shrinks and the browser clamps the offset to the shorter content before the
|
||||
// reconcile reads it.
|
||||
const shrunk = { ...rows[2]!, height: rows[2]!.height - 300 };
|
||||
expect(anchor.reconcile(700, [rows[0]!, rows[1]!, shrunk])).toBe(600);
|
||||
});
|
||||
|
||||
it("leaves expansion of the row being read in place", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
@@ -109,8 +128,8 @@ describe("reading anchor", () => {
|
||||
it("projects a prepend range without consuming the correction before layout commits", () => {
|
||||
const anchor = createReadingAnchor();
|
||||
anchor.reconcile(600, rows);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { DisclosureTriggerEdge } from "./strategy";
|
||||
|
||||
interface RowGeometry {
|
||||
id: string;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const edgeOf = (row: RowGeometry, edge: DisclosureTriggerEdge) =>
|
||||
edge === "top" ? row.top : row.top + row.height;
|
||||
|
||||
// A row must clear the reading line before the next row takes ownership.
|
||||
const READING_POSITION_OFFSET_PX = 8;
|
||||
export interface ReadingRowReplacements {
|
||||
@@ -14,7 +19,16 @@ const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
|
||||
// Content coordinates survive user scrolling. Layout commits replace the geometry;
|
||||
// scroll events reuse it, without another DOM measurement pass.
|
||||
export function createReadingAnchor() {
|
||||
let anchor: { id: string; top: number } | null = null;
|
||||
// The row being read keeps its top. A disclosure pins the edge of its row the trigger sits on,
|
||||
// with the offset at the toggle: a toggle that shrinks the content lets the browser clamp the
|
||||
// offset before the next reconcile reads it.
|
||||
let anchor: {
|
||||
id: string;
|
||||
edge: DisclosureTriggerEdge;
|
||||
position: number;
|
||||
pinnedScrollTop?: number;
|
||||
} | null = null;
|
||||
const anchorTop = (row: RowGeometry) => ({ id: row.id, edge: "top" as const, position: row.top });
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
@@ -22,9 +36,12 @@ export function createReadingAnchor() {
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
row: RowGeometry | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
) =>
|
||||
anchor && row && row.id === getRowId(replacements)
|
||||
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
|
||||
: scrollTop;
|
||||
return {
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
@@ -33,14 +50,14 @@ export function createReadingAnchor() {
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
// A disclosure names the row that owns its trigger. That row keeps its top
|
||||
// through the toggle, even when another row sits under the reading line.
|
||||
pin(row: Pick<RowGeometry, "id" | "top">) {
|
||||
anchor = { id: row.id, top: row.top };
|
||||
// A disclosure names the row that owns its trigger and the edge the trigger sits on. That
|
||||
// edge stays in place through the toggle, even when another row sits under the reading line.
|
||||
pin(row: RowGeometry, edge: DisclosureTriggerEdge, scrollTop: number) {
|
||||
anchor = { id: row.id, edge, position: edgeOf(row, edge), pinnedScrollTop: scrollTop };
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
},
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
@@ -57,7 +74,7 @@ export function createReadingAnchor() {
|
||||
// A prepend can expose the bottom of an estimated row above the reader.
|
||||
// Do not transfer ownership to it until the user moves the reading position.
|
||||
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
@@ -32,21 +35,27 @@ import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type EndHoldDistance,
|
||||
} from "./native-disclosure-hold";
|
||||
import { EndPinnedScrollView } from "./native-end-pin";
|
||||
import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import type {
|
||||
DisclosureTriggerEdge,
|
||||
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 +117,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 +144,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
@@ -135,6 +156,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 +180,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 +210,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 +247,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;
|
||||
}
|
||||
@@ -315,8 +316,14 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const isAtEnd = useStableEvent(
|
||||
() => distanceFromEnd(streamViewportMetricsRef.current) <= AT_END_OFFSET_PX,
|
||||
);
|
||||
// A hold on the content end mounts the end pin while detached (`holdRow`). The release reads the
|
||||
// tracked distance instead of the offset: the content size reaches JS before the pin's scroll does.
|
||||
const [endHold, setEndHold] = useState<{ rowId: string; distance: EndHoldDistance } | null>(null);
|
||||
const releaseDisclosure = useStableEvent(() => {
|
||||
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
|
||||
setEndHold(null);
|
||||
bottomAnchorController.releaseDisclosure({
|
||||
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
|
||||
});
|
||||
});
|
||||
const [disclosureHold] = useState(() =>
|
||||
createNativeDisclosureHold({
|
||||
@@ -326,16 +333,54 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onRelease: releaseDisclosure,
|
||||
}),
|
||||
);
|
||||
useEffect(() => () => disclosureHold.cancel(), [disclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row. The row's measurement trigger
|
||||
// makes that same commit carry the row's new height.
|
||||
const holdRow = useStableEvent((rowId: string, apply: () => void) => {
|
||||
disclosureHold.hold(rowId);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
const cancelDisclosureHold = useStableEvent(() => {
|
||||
disclosureHold.cancel();
|
||||
setEndHold(null);
|
||||
});
|
||||
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
|
||||
// The hold detaches the controller in the same event as `apply`, so the toggle's commit already
|
||||
// has end following off and LegendList anchoring on the held row, or on the row below it for a
|
||||
// trigger on the row's bottom edge. The row's measurement trigger makes that same commit carry
|
||||
// the row's new height. While following, a bottom trigger keeps following: the end pin holds the
|
||||
// content end, and with it the bottom of the row, in the frame the new height mounts.
|
||||
// The last row's bottom is the content end, which LegendList cannot anchor. The end pin can, from
|
||||
// any offset, but only once it is mounted: a pin that mounts with the new height anchors nothing.
|
||||
// So the toggle lands a frame after the pin's commit, whose mount runs before the frame's timers.
|
||||
const latestDisclosureAnchor = useStableEvent(
|
||||
(rowId: string, triggerEdge: DisclosureTriggerEdge) =>
|
||||
disclosureAnchor(rows, rowId, triggerEdge),
|
||||
);
|
||||
const holdRow = useStableEvent(
|
||||
(rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => {
|
||||
const applyAndMeasure = () => {
|
||||
apply();
|
||||
rowLayoutSyncRegistry.get(rowId)?.();
|
||||
};
|
||||
if (triggerEdge === "bottom" && bottomAnchorController.mode === "sticky-bottom") {
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
const anchor = disclosureAnchor(rows, rowId, triggerEdge);
|
||||
bottomAnchorController.holdForDisclosure();
|
||||
if (anchor.kind === "row" || !isListPlacedRef.current) {
|
||||
setEndHold(null);
|
||||
disclosureHold.hold(anchor);
|
||||
applyAndMeasure();
|
||||
return;
|
||||
}
|
||||
setEndHold({
|
||||
rowId,
|
||||
distance: createEndHoldDistance(distanceFromEnd(streamViewportMetricsRef.current)),
|
||||
});
|
||||
disclosureHold.holdNextFrame(
|
||||
() => latestDisclosureAnchor(rowId, triggerEdge),
|
||||
(latestAnchor) => {
|
||||
if (latestAnchor.kind === "row") setEndHold(null);
|
||||
applyAndMeasure();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const handleItemSizeChanged = useStableEvent(
|
||||
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
|
||||
newRowEstimates.noteMeasured(info.itemData, info.size);
|
||||
@@ -343,8 +388,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
);
|
||||
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
|
||||
const heldRowId = disclosureHold.heldRowId();
|
||||
return heldRowId === null || item.id === heldRowId;
|
||||
const anchorRowId = disclosureHold.anchorRowId();
|
||||
return anchorRowId === null || item.id === anchorRowId;
|
||||
});
|
||||
// One owner keeps the scroll offset at any moment:
|
||||
// - Before the list has placed its rows for the agent: LegendList's initial scroll to the end. It
|
||||
@@ -358,16 +403,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// scrolls to the end its JS sizes predict, which runs ahead of the native mount, so it lands
|
||||
// short of the real end and the controller reads that as the user scrolling away.
|
||||
// - A disclosure hold: the held row. The hold detaches the controller, which removes the pin, and
|
||||
// LegendList keeps only the held row in place.
|
||||
// LegendList keeps only the held row in place, or the row below it when the trigger sits on the
|
||||
// held row's bottom edge. A bottom-edge hold while following leaves the end pin in charge, and
|
||||
// one on the last row mounts the pin for the hold: the row's bottom is the content end.
|
||||
// - A user scroll: the finger. The controller detaches for the gesture, so streamed lines grow below
|
||||
// the rows the user is dragging, and resumes following if the gesture ends at the end.
|
||||
// - Detached: the visible rows. LegendList keeps the first visible row in place, so history loading
|
||||
// above or rows growing below leave what the user is reading where it is.
|
||||
const isFollowingEnd = bottomAnchorController.mode === "sticky-bottom";
|
||||
const pinsEnd = isFollowingEnd && isListPlaced;
|
||||
// An end hold pins the end only while its row's bottom is the end. A reply landing below the row
|
||||
// drops the pin in the commit that mounts the reply, so the reply grows below the held row.
|
||||
const isEndHeld =
|
||||
endHold !== null && disclosureAnchor(rows, endHold.rowId, "bottom").kind === "end";
|
||||
const isEndPinned = isFollowingEnd || isEndHeld;
|
||||
const pinsEnd = isEndPinned && isListPlaced;
|
||||
const maintainVisibleContentPosition = useMemo(
|
||||
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isFollowingEnd, shouldRestorePosition],
|
||||
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isEndPinned, shouldRestorePosition],
|
||||
);
|
||||
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
|
||||
const renderScrollComponent = useCallback(
|
||||
@@ -390,10 +442,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,22 +559,22 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
finishInitialFill();
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
scrollKeyboardDismiss.onScrollBeginDrag(event);
|
||||
// The gesture takes the viewport from a disclosure hold. Size changes from a streaming row can
|
||||
// keep the hold's quiet frames from ever passing, and a held row that scrolls away would leave
|
||||
// no row LegendList may anchor a history page on. The gesture's end decides following.
|
||||
disclosureHold.cancel();
|
||||
cancelDisclosureHold();
|
||||
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
|
||||
@@ -589,6 +642,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentHeight: nextContentHeight,
|
||||
contentMeasuredForKey: isListPlacedRef.current ? "native-virtualized" : null,
|
||||
};
|
||||
endHold?.distance.noteContentSize(streamViewportMetricsRef.current);
|
||||
bottomAnchorController.handleContentSizeChange();
|
||||
// Content growing below a detached viewport moves the end away without a scroll event.
|
||||
if (
|
||||
@@ -598,7 +652,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
publishNearBottom();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
if (isHistoryStartSettling()) {
|
||||
scheduleHistoryStartSettle();
|
||||
}
|
||||
});
|
||||
@@ -611,19 +665,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 +716,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import type { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -67,7 +67,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
onNearHistoryStart: async () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
@@ -91,10 +91,15 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
|
||||
return {
|
||||
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
|
||||
hold: (rowId: string, apply: () => void) =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply)),
|
||||
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
|
||||
rowTop: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top,
|
||||
rowBottom: (id: string) =>
|
||||
rowElement(id).getBoundingClientRect().bottom - scrollContainer.getBoundingClientRect().top,
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
@@ -139,6 +144,53 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.rowTop("message-5")).toBe(triggerTop);
|
||||
});
|
||||
|
||||
// A long message's show more sits under the text it opens, on the bottom edge of its row.
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less while following output", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
const triggerBottom = timeline.rowBottom("message-5");
|
||||
|
||||
timeline.hold(
|
||||
"message-5",
|
||||
() => timeline.render({ historyMounted: [...history.slice(0, 4), row(5, 240), history[5]!] }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
|
||||
timeline.hold("message-5", () => timeline.render({ historyMounted: history }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom("message-5")).toBe(triggerBottom);
|
||||
});
|
||||
|
||||
it("keeps the bottom of a bottom-trigger row through show more and show less when scrolled up", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
await timeline.settle();
|
||||
timeline.scrollTo(60);
|
||||
await timeline.settle();
|
||||
// message-2 is cut off at the top; message-3 is fully in view.
|
||||
for (const index of [2, 3]) {
|
||||
const triggerBottom = timeline.rowBottom(`message-${index}`);
|
||||
const grown = history.map((item, position) =>
|
||||
position === index - 1 ? row(index, 240) : item,
|
||||
);
|
||||
|
||||
timeline.hold(`message-${index}`, () => timeline.render({ historyMounted: grown }), "bottom");
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
|
||||
timeline.hold(
|
||||
`message-${index}`,
|
||||
() => timeline.render({ historyMounted: history }),
|
||||
"bottom",
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(timeline.rowBottom(`message-${index}`)).toBe(triggerBottom);
|
||||
}
|
||||
});
|
||||
|
||||
it("resumes following when collapsing an anchored row lands back at the end", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +73,17 @@ 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",
|
||||
};
|
||||
|
||||
// Rows and the live auxiliary (turn footer, pending permissions) center their content with
|
||||
// `alignSelf`, which only applies inside a flex container.
|
||||
const streamRowStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -298,6 +304,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
isInitialHistoryLoading,
|
||||
revealInitialHistory,
|
||||
onNearBottomChange,
|
||||
onReadingPositionChange,
|
||||
onNearHistoryStart,
|
||||
@@ -318,6 +326,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 +362,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);
|
||||
@@ -411,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
height: measurement.size,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
@@ -481,60 +489,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 +526,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 +768,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
finishInitialFill();
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
@@ -801,6 +776,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 +825,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 +918,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 +968,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
lastObservedViewportGeometryRef.current = nextGeometry;
|
||||
}
|
||||
reconcileReadingPosition();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
if (isHistoryStartSettling()) {
|
||||
scheduleHistoryStartPrependSettle();
|
||||
}
|
||||
updateScrollMetrics();
|
||||
@@ -1020,6 +1002,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
isActive,
|
||||
reportReadingPosition,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
isHistoryStartSettling,
|
||||
forceStickToBottom,
|
||||
updateScrollMetrics,
|
||||
measureVirtualizedRows,
|
||||
@@ -1190,11 +1173,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdRow: (rowId, apply) => {
|
||||
holdRow: (rowId, apply, triggerEdge) => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
const row = measureRows()?.find((candidate) => candidate.id === rowId);
|
||||
if (row) readingAnchor.pin(row);
|
||||
const scrollTop = scrollContainerRef.current?.scrollTop;
|
||||
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
|
||||
disclosurePendingRef.current = true;
|
||||
apply();
|
||||
},
|
||||
@@ -1241,8 +1225,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 +1304,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 +1338,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 +1366,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...mountedRows,
|
||||
]}
|
||||
</div>
|
||||
{liveAuxiliary}
|
||||
<div ref={auxiliaryRef} style={streamRowStyle}>
|
||||
{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}
|
||||
|
||||
@@ -46,16 +46,23 @@ export interface ScrollToMessageOccurrence {
|
||||
targetTop(): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge of its row a disclosure's trigger sits on. A header trigger sits on the top: the content
|
||||
* it opens grows below it. A trigger under the content it opens, such as a long message's show
|
||||
* more, sits on the bottom.
|
||||
*/
|
||||
export type DisclosureTriggerEdge = "top" | "bottom";
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
/** Give keyboard navigation back to the scrolling surface on web. */
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/**
|
||||
* Run a disclosure's state change so the row that owns its trigger keeps its visual top.
|
||||
* Following output stops; it resumes if the change leaves the list at its end.
|
||||
* Run a disclosure's state change so the row that owns its trigger keeps the edge the trigger
|
||||
* sits on in place. Following output stops; it resumes if the change leaves the list at its end.
|
||||
*/
|
||||
holdRow: (rowId: string, apply: () => void) => void;
|
||||
holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
@@ -85,11 +92,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
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { createNativeStreamStrategy } from "./strategy-native";
|
||||
import { CompletedTurnFooterRow } from "./turn-footer";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// A Sunday, so a turn that ended on Friday shows a weekday timestamp.
|
||||
const NOW = new Date(2026, 10, 8, 5, 0);
|
||||
const FRIDAY_NIGHT = new Date(2026, 10, 6, 23, 11);
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
host = document.createElement("div");
|
||||
host.style.width = "880px";
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
vi.useRealTimers();
|
||||
await i18n.changeLanguage("en");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const reply: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "reply",
|
||||
text: "Done.",
|
||||
timestamp: FRIDAY_NIGHT,
|
||||
};
|
||||
|
||||
function renderCompletedFooter(timing: TurnTiming): void {
|
||||
act(() =>
|
||||
root.render(
|
||||
<CompletedTurnFooterRow
|
||||
strategy={createNativeStreamStrategy()}
|
||||
items={[reply]}
|
||||
timing={timing}
|
||||
startIndex={0}
|
||||
supportsTimelineCursor={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** How many painted lines each footer label takes. */
|
||||
function paintedLinesByLabel(element: HTMLElement): Record<string, number> {
|
||||
const lines: Record<string, number> = {};
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent ?? "";
|
||||
if (!text.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(walker.currentNode);
|
||||
lines[text] = new Set(Array.from(range.getClientRects(), (rect) => Math.round(rect.top))).size;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// The footer shows the duration and swaps to the end timestamp on hover or tap. Neither label
|
||||
// may be sized by the other: a timestamp with more characters can still paint narrower.
|
||||
it.each([
|
||||
{
|
||||
language: "en",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 26_000 },
|
||||
duration: "Worked for 26s",
|
||||
},
|
||||
// CJK glyphs make the shorter string paint wider in every font.
|
||||
{
|
||||
language: "ja",
|
||||
timing: { completedAt: FRIDAY_NIGHT, durationMs: 59 * 60_000 + 58_000 },
|
||||
duration: "作業時間 59m 58s",
|
||||
},
|
||||
])("keeps both timing labels on one line when the chat has room ($language)", async (input) => {
|
||||
await i18n.changeLanguage(input.language);
|
||||
|
||||
renderCompletedFooter(input.timing);
|
||||
|
||||
expect(paintedLinesByLabel(host)).toEqual({
|
||||
[input.duration]: 1,
|
||||
"Friday 11:11 PM": 1,
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -50,7 +51,8 @@ function PlanCardContent({
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
|
||||
const hold = useDisclosureHold();
|
||||
const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, disableOuterSpacing && styles.containerCompact],
|
||||
[disableOuterSpacing],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
type WorkspaceCompactHeaderTabs,
|
||||
} from "@/screens/workspace/adaptive-workspace-chrome";
|
||||
import { useWorkspaceTabDrag } from "./workspace-tab-drag-context";
|
||||
import {
|
||||
@@ -66,12 +66,13 @@ import { isNative } from "@/constants/platform";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
/** The full workspace header: title, project row and workspace actions. */
|
||||
renderMainHeader?: () => ReactNode;
|
||||
renderCompactHeader: (
|
||||
tabs: WorkspaceTabDescriptor[],
|
||||
activeTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => ReactNode;
|
||||
/**
|
||||
* The compact workspace header. With one presented pane it carries that pane's tab switcher in
|
||||
* place of its tab rail; above a split it carries workspace controls only.
|
||||
*/
|
||||
renderCompactHeader: (paneTabs: WorkspaceCompactHeaderTabs | null) => ReactNode;
|
||||
workspaceKey: string;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
@@ -128,6 +129,11 @@ interface SplitNodeViewProps extends Omit<
|
||||
windowChromeCorners: WindowChromeCorners;
|
||||
maximizedPaneId: string | null;
|
||||
workspaceHasMultiplePanes: boolean;
|
||||
/**
|
||||
* One presented pane draws the workspace header itself and may merge its tabs into the compact
|
||||
* header. Split panes share one header above them and always show their tab rails.
|
||||
*/
|
||||
ownsWorkspaceHeader: boolean;
|
||||
onTogglePaneMaximized: (paneId: string) => void;
|
||||
}
|
||||
|
||||
@@ -244,11 +250,27 @@ export function SplitContainer({
|
||||
: inheritedWindowChromeCorners;
|
||||
const mainColumnStyle = styles.mainColumn;
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
const panesShareWorkspaceHeader = hasMultipleVisiblePanes(splitRoot.root);
|
||||
const sharedCompactHeader = useMemo(
|
||||
() =>
|
||||
panesShareWorkspaceHeader ? (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(null)}
|
||||
</WindowChromeSafeArea>
|
||||
) : null,
|
||||
[panesShareWorkspaceHeader, renderCompactHeader],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id="SplitContainer">
|
||||
<View style={styles.workspaceShell}>
|
||||
<WindowChromeRegion corners={mainColumnWindowChromeCorners}>
|
||||
<View style={mainColumnStyle}>
|
||||
{panesShareWorkspaceHeader ? (
|
||||
<AdaptiveWorkspaceChrome compactHeader={sharedCompactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
</AdaptiveWorkspaceChrome>
|
||||
) : null}
|
||||
{splitRoot.usesFallbackStrip && <WindowChromeSafeArea placement="below" />}
|
||||
{renderRoot ? (
|
||||
<SplitNodeView
|
||||
@@ -284,7 +306,12 @@ export function SplitContainer({
|
||||
showDropZones={activeDragTabId !== null}
|
||||
dropPreview={dropPreview}
|
||||
tabDropPreview={tabDropPreview}
|
||||
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
|
||||
windowChromeCorners={
|
||||
splitRoot.usesFallbackStrip || panesShareWorkspaceHeader
|
||||
? "none"
|
||||
: windowChromeCorners
|
||||
}
|
||||
ownsWorkspaceHeader={!panesShareWorkspaceHeader}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
workspaceHasMultiplePanes={workspaceHasMultiplePanes}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
@@ -423,6 +450,7 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
ownsWorkspaceHeader,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitNodeViewProps) {
|
||||
@@ -511,6 +539,7 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
ownsWorkspaceHeader={ownsWorkspaceHeader}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
@@ -578,6 +607,7 @@ function SplitNodeView({
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
focusModeEnabled={focusModeEnabled}
|
||||
onExitFocusMode={onExitFocusMode}
|
||||
ownsWorkspaceHeader={ownsWorkspaceHeader}
|
||||
renderMainHeader={renderMainHeader}
|
||||
renderCompactHeader={renderCompactHeader}
|
||||
/>
|
||||
@@ -638,6 +668,7 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
onExitFocusMode,
|
||||
ownsWorkspaceHeader,
|
||||
renderMainHeader,
|
||||
renderCompactHeader,
|
||||
}: SplitPaneViewProps) {
|
||||
@@ -770,13 +801,24 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
createNewTab,
|
||||
});
|
||||
const compactHeader = useMemo(
|
||||
() => (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader(paneTabs, activeTabDescriptor?.key ?? "", compactHeaderActions)}
|
||||
</WindowChromeSafeArea>
|
||||
),
|
||||
[renderCompactHeader, paneTabs, activeTabDescriptor?.key, compactHeaderActions],
|
||||
() =>
|
||||
ownsWorkspaceHeader ? (
|
||||
<WindowChromeSafeArea placement="inline">
|
||||
<TitlebarDragRegion />
|
||||
{renderCompactHeader({
|
||||
tabs: paneTabs,
|
||||
activeTabKey: activeTabDescriptor?.key ?? "",
|
||||
actions: compactHeaderActions,
|
||||
})}
|
||||
</WindowChromeSafeArea>
|
||||
) : null,
|
||||
[
|
||||
ownsWorkspaceHeader,
|
||||
renderCompactHeader,
|
||||
paneTabs,
|
||||
activeTabDescriptor?.key,
|
||||
compactHeaderActions,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
@@ -787,7 +829,7 @@ const SplitPaneView = memo(function SplitPaneView({
|
||||
testID={`workspace-pane-${pane.id}`}
|
||||
>
|
||||
<AdaptiveWorkspaceChrome compactHeader={compactHeader}>
|
||||
{renderMainHeader?.()}
|
||||
{ownsWorkspaceHeader ? renderMainHeader?.() : null}
|
||||
<WindowChromeRegion corners={focusModeEnabled ? "both" : "none"}>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -17,23 +18,17 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||
import {
|
||||
computePosition,
|
||||
measureElement,
|
||||
type Alignment,
|
||||
type Placement,
|
||||
type Rect,
|
||||
type Size,
|
||||
} from "./anchor";
|
||||
import { computePosition, type Alignment, type Placement, type Rect } from "./anchor";
|
||||
|
||||
const CLOSE_GRACE_MS = 100;
|
||||
|
||||
// One card per layer: moving onto a sibling trigger replaces the open card instead of stacking
|
||||
// a second one beside it for the grace period. Nested cards open on a higher layer.
|
||||
const openCardCloseByLayer = new Map<number, () => void>();
|
||||
|
||||
interface HoverCardContextValue {
|
||||
open: boolean;
|
||||
layer: number;
|
||||
@@ -116,12 +111,26 @@ function WebHoverCard({
|
||||
}, CLOSE_GRACE_MS);
|
||||
}, [keyboardFocusInside]);
|
||||
|
||||
const closeNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const openNow = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, disabled]);
|
||||
if (disabled) return;
|
||||
const openSibling = openCardCloseByLayer.get(layer);
|
||||
if (openSibling && openSibling !== closeNow) openSibling();
|
||||
openCardCloseByLayer.set(layer, closeNow);
|
||||
setOpen(true);
|
||||
}, [clearGraceTimer, closeNow, disabled, layer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return () => {
|
||||
if (openCardCloseByLayer.get(layer) === closeNow) openCardCloseByLayer.delete(layer);
|
||||
};
|
||||
}, [closeNow, layer, open]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardInputEvent): boolean => {
|
||||
@@ -299,17 +308,29 @@ interface HoverCardContentProps {
|
||||
|
||||
export function HoverCardContent(props: HoverCardContentProps): ReactElement | null {
|
||||
const ctx = useContext(HoverCardContext);
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
if (!ctx?.open) return null;
|
||||
return (
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
<Portal hostName={portalHostName}>
|
||||
<HoverCardSurface
|
||||
{...props}
|
||||
layer={ctx.layer}
|
||||
triggerRef={ctx.triggerRef}
|
||||
contentRef={ctx.contentRef}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function readDomRect(view: View | null): Rect | null {
|
||||
const element = view as unknown as HTMLElement | null;
|
||||
if (!element) return null;
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
// Rendered inside the portal so the card is placed in the commit that mounts it: the first
|
||||
// painted frame already sits beside the trigger. Web-only, like the rest of the open card.
|
||||
function HoverCardSurface({
|
||||
layer,
|
||||
triggerRef,
|
||||
@@ -327,48 +348,29 @@ function HoverCardSurface({
|
||||
triggerRef: RefObject<View | null>;
|
||||
contentRef: RefObject<View | null>;
|
||||
}): ReactElement {
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const [anchor, setAnchor] = useState<{ triggerRect: Rect; hostRect: Rect } | null>(null);
|
||||
const [contentSize, setContentSize] = useState<Size | null>(null);
|
||||
const overlayRef = useRef<View>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
measureElement(triggerRef.current),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([triggerRect, hostRect]) => {
|
||||
if (!cancelled && hostRect) setAnchor({ triggerRect, hostRect });
|
||||
return undefined;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [portalHostName, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor || !contentSize) return;
|
||||
const place = useCallback(() => {
|
||||
const triggerRect = readDomRect(triggerRef.current);
|
||||
const overlayRect = readDomRect(overlayRef.current);
|
||||
const content = contentRef.current as unknown as HTMLElement | null;
|
||||
if (!triggerRect || !overlayRect || !content) return;
|
||||
const { width, height } = Dimensions.get("window");
|
||||
const { x, y } = computePosition({
|
||||
triggerRect: anchor.triggerRect,
|
||||
contentSize,
|
||||
triggerRect,
|
||||
contentSize: { width: content.offsetWidth, height: content.offsetHeight },
|
||||
displayArea: { x: 0, y: 0, width, height },
|
||||
placement,
|
||||
flipHorizontal: true,
|
||||
alignment,
|
||||
offset,
|
||||
});
|
||||
setPosition({ x: x - anchor.hostRect.x, y: y - anchor.hostRect.y });
|
||||
}, [alignment, anchor, contentSize, offset, placement]);
|
||||
const next = { x: x - overlayRect.x, y: y - overlayRect.y };
|
||||
setPosition((current) => (current?.x === next.x && current.y === next.y ? current : next));
|
||||
}, [alignment, contentRef, offset, placement, triggerRef]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
useLayoutEffect(place, [place]);
|
||||
|
||||
const frameStyle = useMemo(
|
||||
() => ({
|
||||
@@ -381,24 +383,26 @@ function HoverCardSurface({
|
||||
const surfaceStyle = useMemo(() => [styles.surface, style], [style]);
|
||||
|
||||
return (
|
||||
<Portal hostName={portalHostName}>
|
||||
<View pointerEvents="box-none" style={[styles.portalOverlay, { zIndex: layer }]}>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
<View
|
||||
ref={overlayRef}
|
||||
pointerEvents="box-none"
|
||||
style={[styles.portalOverlay, { zIndex: layer }]}
|
||||
>
|
||||
<FloatingSurface
|
||||
ref={contentRef}
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={place}
|
||||
role={role}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
|
||||
const PREVIEW_HEIGHT = 240;
|
||||
// Show more and show less sit under the text they open and close.
|
||||
const TRIGGER_BELOW_CONTENT = { triggerEdge: "bottom" } as const;
|
||||
// Native View still clips with "scroll", but Yoga measures its children without
|
||||
// the height cap. "hidden" caps the measured body too, hiding the expand control.
|
||||
const previewStyle: ViewStyle = {
|
||||
@@ -26,7 +29,11 @@ export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
|
||||
[],
|
||||
);
|
||||
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
|
||||
[hold],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1791,6 +1791,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
handleSendMessage,
|
||||
handleQueueMessage,
|
||||
});
|
||||
if (hasFinePointer()) {
|
||||
textInputRef.current?.focus();
|
||||
}
|
||||
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
|
||||
|
||||
const handleAlternateSendAction = useCallback(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import type { DiffState, OwnedSubscription, SubscriptionObserver } from "@getpaseo/client";
|
||||
import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
@@ -144,10 +144,21 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
["status", "providers_snapshot_update"],
|
||||
() => {},
|
||||
),
|
||||
observeCheckoutDiff(cwd, compare) {
|
||||
observeDiff(input) {
|
||||
const cwd = input.cwd;
|
||||
let mode: "uncommitted" | "base" | "all" = "uncommitted";
|
||||
if (input.head.kind === "rev") mode = "base";
|
||||
else if (input.mergeBase) mode = "all";
|
||||
const compare = {
|
||||
mode,
|
||||
...(input.base.kind === "rev" && input.base.rev !== "HEAD"
|
||||
? { baseRef: input.base.rev }
|
||||
: {}),
|
||||
ignoreWhitespace: input.ignoreWhitespace,
|
||||
};
|
||||
const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`;
|
||||
subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId });
|
||||
const handle = observe(
|
||||
const handle = observe<SubscribeCheckoutDiffResponseMessage["payload"]>(
|
||||
{ subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" },
|
||||
["checkout_diff_update", "subscribe_checkout_diff_response"],
|
||||
() => unsubscribeCheckoutDiffCalls.push(subscriptionId),
|
||||
@@ -155,9 +166,43 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
"subscriptionId" in message.payload &&
|
||||
message.payload.subscriptionId === subscriptionId,
|
||||
);
|
||||
if (!config.rejectCheckoutDiffSubscribe) return handle;
|
||||
return {
|
||||
const modern: OwnedSubscription<DiffState> = {
|
||||
...handle,
|
||||
ready: Promise.resolve({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: [],
|
||||
diffTooLarge: false,
|
||||
subscriptionId,
|
||||
}),
|
||||
subscribe: (observer) =>
|
||||
handle.subscribe({
|
||||
snapshot: (payload) =>
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: payload.files,
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
}),
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update")
|
||||
observer.snapshot({
|
||||
phase: "ready",
|
||||
revision: 2,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
files: message.payload.files,
|
||||
diffTooLarge: message.payload.diffTooLarge === true,
|
||||
subscriptionId,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (!config.rejectCheckoutDiffSubscribe) return modern;
|
||||
return {
|
||||
...modern,
|
||||
subscribe: (observer) => {
|
||||
queueMicrotask(() => observer.error?.(new Error("subscribe failed")));
|
||||
return () => {};
|
||||
@@ -328,7 +373,7 @@ describe("server data push router", () => {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("reopen");
|
||||
expect(same.requestId).toBe("diff:1:ready");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
@@ -430,7 +475,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: "diff-1",
|
||||
requestId: "diff:1:ready",
|
||||
});
|
||||
|
||||
fake.emit({
|
||||
@@ -449,7 +494,7 @@ describe("server data push router", () => {
|
||||
files: [],
|
||||
error: null,
|
||||
diffTooLarge: true,
|
||||
requestId: `subscription:${serverSubscriptionId}`,
|
||||
requestId: "diff:2:ready",
|
||||
});
|
||||
|
||||
unsubscribeObserver();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { checkoutDiffSnapshot, type CheckoutDiffSnapshot } from "@/git/diff-snapshot";
|
||||
import type { DiffState } from "@getpaseo/client";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
@@ -23,14 +25,9 @@ type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "providers_snapshot_update" }
|
||||
>;
|
||||
type SubscribeCheckoutDiffResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>;
|
||||
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
||||
type TerminalsChangedMessage = Extract<SessionOutboundMessage, { type: "terminals_changed" }>;
|
||||
type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
type CheckoutDiffCachePayload = Omit<CheckoutDiffResponsePayload, "subscriptionId">;
|
||||
type CheckoutDiffCachePayload = CheckoutDiffSnapshot;
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
interface CheckoutDiffCompare {
|
||||
@@ -49,7 +46,7 @@ interface CheckoutDiffRoute {
|
||||
}
|
||||
|
||||
interface CheckoutDiffRegistration extends CheckoutDiffRoute {
|
||||
subscription: OwnedSubscription<CheckoutDiffResponsePayload>;
|
||||
subscription: OwnedSubscription<DiffState>;
|
||||
}
|
||||
|
||||
interface WorkspaceTerminalsRoute {
|
||||
@@ -75,7 +72,7 @@ export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage;
|
||||
interface ServerDataPushClient {
|
||||
getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"];
|
||||
observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"];
|
||||
observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"];
|
||||
observeDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeDiff"];
|
||||
observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"];
|
||||
}
|
||||
|
||||
@@ -339,34 +336,34 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
}
|
||||
for (const [key, desired] of input.desired) {
|
||||
if (input.active.has(key)) continue;
|
||||
const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare);
|
||||
const { mode, baseRef, ignoreWhitespace } = desired.compare;
|
||||
const revision = mode === "uncommitted" ? "HEAD" : baseRef;
|
||||
if (!revision) continue;
|
||||
const subscription = input.client.observeDiff({
|
||||
cwd: desired.cwd,
|
||||
base: { kind: "rev", rev: revision },
|
||||
head: mode === "base" ? { kind: "rev", rev: "HEAD" } : { kind: "worktree" },
|
||||
mergeBase: mode !== "uncommitted",
|
||||
ignoreWhitespace,
|
||||
});
|
||||
const registration = { ...desired, subscription };
|
||||
input.active.set(key, registration);
|
||||
const apply = (
|
||||
payload: Omit<CheckoutDiffResponsePayload, "requestId"> & { requestId?: string },
|
||||
) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const { subscriptionId, ...snapshot } = payload;
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: {
|
||||
...snapshot,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
requestId: payload.requestId ?? `subscription:${subscriptionId}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
subscription.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
snapshot: (state) => {
|
||||
if (input.active.get(key) !== registration) return;
|
||||
const snapshot = checkoutDiffSnapshot(desired.cwd, state);
|
||||
setCheckoutDiffPayload({
|
||||
activeCheckoutDiffSubscriptions: input.active,
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: key,
|
||||
payload: { ...snapshot, files: orderCheckoutDiffFiles(snapshot.files) },
|
||||
});
|
||||
},
|
||||
update: () => {},
|
||||
error: (error) => {
|
||||
if (input.active.get(key) === registration) input.active.delete(key);
|
||||
console.error("[server-data] observeCheckoutDiff failed", {
|
||||
console.error("[server-data] observeDiff failed", {
|
||||
serverId: input.serverId,
|
||||
cwd: desired.cwd,
|
||||
error,
|
||||
|
||||
@@ -46,6 +46,31 @@ function makeHost(): HostProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(host: HostProfile): HostRuntimeSnapshot {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("reports whether the connected daemon is managed by Paseo Desktop", () => {
|
||||
const report = formatServerInfoSection({
|
||||
@@ -61,31 +86,11 @@ describe("app diagnostics report", () => {
|
||||
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
const snapshot = makeSnapshot(host);
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("Last online: 2026-06-25T00:00:00.000Z");
|
||||
expect(report).toContain("direct TCP");
|
||||
expect(report).toContain("relay");
|
||||
expect(report).toContain("local socket");
|
||||
@@ -97,6 +102,58 @@ describe("app diagnostics report", () => {
|
||||
expect(report).not.toContain("tcp-password");
|
||||
});
|
||||
|
||||
test.each(["connecting", "offline", "error"] as const)(
|
||||
"does not claim a previously connected host was never online while %s",
|
||||
(connectionStatus) => {
|
||||
const host = makeHost();
|
||||
const snapshot = makeSnapshot(host);
|
||||
const report = formatHostRuntimeSection({
|
||||
host,
|
||||
snapshot: { ...snapshot, connectionStatus, lastOnlineAt: null },
|
||||
});
|
||||
|
||||
expect(report).toContain("Last online: unknown");
|
||||
expect(report).not.toContain("Last online: never");
|
||||
},
|
||||
);
|
||||
|
||||
test("does not infer connection history when the runtime has not started", () => {
|
||||
const report = formatHostRuntimeSection({ host: makeHost(), snapshot: null });
|
||||
|
||||
expect(report).toContain("Last online: unknown");
|
||||
});
|
||||
|
||||
test.each(["online", "error"] as const)(
|
||||
"identifies a routed connection while %s",
|
||||
(connectionStatus) => {
|
||||
const host = makeHost();
|
||||
host.connections.push({ id: "routed-origin", type: "routed", originServerId: "srv-origin" });
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "routed-origin",
|
||||
activeConnection: { type: "routed", endpoint: "srv-origin", display: "via srv-origin" },
|
||||
connectionStatus,
|
||||
client: null,
|
||||
lastError: connectionStatus === "error" ? "Host disconnected" : null,
|
||||
lastOnlineAt: null,
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map(),
|
||||
clientGeneration: 1,
|
||||
connectionEpoch: 1,
|
||||
};
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("Active connection: routed");
|
||||
expect(report).toContain("Connection 5: routed, active, probe=unknown");
|
||||
expect(report).toContain(`Status: ${connectionStatus}`);
|
||||
expect(report).toContain(`Last error: ${snapshot.lastError ?? "none"}`);
|
||||
expect(report).not.toContain("srv-origin");
|
||||
},
|
||||
);
|
||||
|
||||
test("redacts saved connection secrets from collected daemon and desktop text", () => {
|
||||
const host = makeHost();
|
||||
const redacted = redactAppDiagnosticReport(
|
||||
|
||||
@@ -45,7 +45,7 @@ export function formatHostRuntimeSection(input: {
|
||||
? describeConnectionKind(snapshot.activeConnection.type)
|
||||
: "none",
|
||||
},
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "unknown" },
|
||||
{ label: "Last error", value: snapshot?.lastError ?? "none" },
|
||||
{
|
||||
label: "Agent directory",
|
||||
@@ -100,6 +100,8 @@ export function describeConnectionKind(type: HostConnection["type"] | string): s
|
||||
return "local pipe";
|
||||
case "relay":
|
||||
return "relay";
|
||||
case "routed":
|
||||
return "routed";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
type DiffTreeRow,
|
||||
} from "@/git/diff-tree";
|
||||
import { FileHeader } from "@/git/file-header";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
export interface ChangedFilesTreeProps {
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
collapsedFolderPaths: string[];
|
||||
|
||||
@@ -13,6 +13,6 @@ export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[
|
||||
// chains, so it is skipped; the collapsed set is empty because the flat list
|
||||
// always carries every file, whatever the rail has collapsed.
|
||||
return flattenDiffTree(buildDiffTree(files), new Set()).flatMap((row) =>
|
||||
row.kind === "file" ? [row.file] : [],
|
||||
row.kind === "file" ? [files[row.fileIndex]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
|
||||
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
@@ -1094,6 +1095,7 @@ interface DiffBodyContentProps {
|
||||
emptyMessage: string;
|
||||
emptyAction: ChangesEmptyAction | null;
|
||||
children: ReactElement;
|
||||
loadingLabel: string;
|
||||
checkingRepositoryLabel: string;
|
||||
notRepositoryLabel: string;
|
||||
}
|
||||
@@ -1110,11 +1112,16 @@ function DiffBodyContent({
|
||||
emptyAction,
|
||||
children,
|
||||
checkingRepositoryLabel,
|
||||
loadingLabel,
|
||||
notRepositoryLabel,
|
||||
}: DiffBodyContentProps) {
|
||||
if (isStatusLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||
</View>
|
||||
@@ -1136,12 +1143,16 @@ function DiffBodyContent({
|
||||
}
|
||||
if (isDiffLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View
|
||||
style={styles.loadingContainer}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={loadingLabel}
|
||||
>
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (diffTooLarge) {
|
||||
if (diffTooLarge && !hasChanges) {
|
||||
return <DiffTooLargeState />;
|
||||
}
|
||||
if (diffErrorMessage) {
|
||||
@@ -1320,7 +1331,7 @@ function ChangesTreeRail({
|
||||
}: {
|
||||
shown: boolean;
|
||||
children: ReactElement;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1360,7 +1371,7 @@ function ChangesBody({
|
||||
children: ReactElement;
|
||||
desktopTreeVisible: boolean;
|
||||
isMobile: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
files: DiffFileHeader[];
|
||||
mode: WorkingDiffMode;
|
||||
onSelectFile: (path: string) => void;
|
||||
treeWidth?: number;
|
||||
@@ -1585,6 +1596,7 @@ export function ChangesSurface({
|
||||
selectBase: handleSelectBase,
|
||||
selectAll: handleSelectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
@@ -1834,6 +1846,7 @@ export function ChangesSurface({
|
||||
hasChanges={hasChanges}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyAction={emptyAction}
|
||||
loadingLabel={t("common.loading")}
|
||||
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
||||
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
||||
>
|
||||
@@ -1864,7 +1877,7 @@ export function ChangesSurface({
|
||||
presentation={presentation}
|
||||
desktopTreeVisible={desktopTreeVisible}
|
||||
isMobile={isMobile}
|
||||
files={files}
|
||||
files={treeFiles}
|
||||
mode={workingMode}
|
||||
onSelectFile={handleSelectTreeFile}
|
||||
treeWidth={instanceState.treeWidth}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import type { CheckoutDiffSnapshot } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type DiffPayload = CheckoutDiffSnapshot;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
@@ -30,6 +31,7 @@ export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPay
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.entries === next.entries &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { DiffState, DiffEntry } from "@getpaseo/client";
|
||||
import type {
|
||||
DiffError,
|
||||
ParsedDiffFile,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
export type DiffFileHeader = Omit<ParsedDiffFile, "hunks">;
|
||||
export interface CheckoutDiffSnapshot {
|
||||
requestId: string;
|
||||
cwd: string;
|
||||
files: ParsedDiffFile[];
|
||||
entries?: DiffEntry[];
|
||||
error: DiffError | null;
|
||||
diffTooLarge?: boolean;
|
||||
}
|
||||
export function checkoutDiffSnapshot(cwd: string, state: DiffState): CheckoutDiffSnapshot {
|
||||
const common = { cwd, requestId: `diff:${state.revision}:${state.phase}` };
|
||||
if (state.phase === "loading")
|
||||
return { ...common, files: [], entries: state.entries, error: null };
|
||||
if (state.phase === "error") return { ...common, files: [], error: state.error };
|
||||
return { ...common, files: state.files, error: null, diffTooLarge: state.diffTooLarge };
|
||||
}
|
||||
export function diffEntryHeaders(entries: DiffEntry[]): DiffFileHeader[] {
|
||||
return entries.map((entry) => ({
|
||||
path: entry.path,
|
||||
...(entry.oldPath === null ? {} : { oldPath: entry.oldPath }),
|
||||
isNew: entry.change === "added",
|
||||
isDeleted: entry.change === "deleted",
|
||||
additions: entry.additions,
|
||||
deletions: entry.deletions,
|
||||
status: entry.content === "binary" ? "binary" : "ok",
|
||||
}));
|
||||
}
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
entries: DiffEntry[] | null;
|
||||
payloadError: CheckoutDiffSnapshot["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(payload: CheckoutDiffSnapshot | null): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
entries: payload?.entries ?? null,
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null || payload.entries !== undefined,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkoutDiffComparisonError(
|
||||
compare: Pick<SubscribeCheckoutDiffRequest["compare"], "mode" | "baseRef">,
|
||||
): DiffError | null {
|
||||
if (compare.mode === "uncommitted" || compare.baseRef?.trim()) return null;
|
||||
return {
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Comparison base is unavailable. Select Uncommitted to view pending changes.",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
|
||||
// Builds a directory hierarchy from the `ParsedDiffFile[]` the Changes view
|
||||
// Builds a directory hierarchy from the `DiffFileHeader[]` the Changes view
|
||||
// renders. The tree renders on every form factor, consistent with the Files
|
||||
// explorer.
|
||||
//
|
||||
@@ -17,7 +17,7 @@ import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
|
||||
export interface DiffTreeFileNode {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
/** basename, e.g. "diff-pane.tsx" */
|
||||
name: string;
|
||||
@@ -47,7 +47,7 @@ export interface DiffTreeFolderRow {
|
||||
|
||||
export interface DiffTreeFileRow {
|
||||
kind: "file";
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
fileIndex: number;
|
||||
depth: number;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ function sortTree(node: DiffTreeDirNode): void {
|
||||
}
|
||||
|
||||
/** Build the (uncompressed) directory tree. Returns the virtual root (dirPath ""). */
|
||||
export function buildDiffTree(files: ParsedDiffFile[]): DiffTreeDirNode {
|
||||
export function buildDiffTree(files: DiffFileHeader[]): DiffTreeDirNode {
|
||||
const root: DiffTreeDirNode = { kind: "dir", dirPath: "", name: "", children: [] };
|
||||
const dirByPath = new Map<string, DiffTreeDirNode>([["", root]]);
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
fileNameForPath,
|
||||
formatDiffCount,
|
||||
} from "@/git/file-header-presentation";
|
||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||
import type { DiffFileHeader } from "@/git/diff-snapshot";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
export interface FileHeaderProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeader;
|
||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||
bodyVisible: boolean;
|
||||
showsBodyState?: boolean;
|
||||
@@ -73,7 +73,7 @@ function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boole
|
||||
return showsBodyState ? bodyVisible : undefined;
|
||||
}
|
||||
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: ParsedDiffFile) {
|
||||
function fileHeaderAccessibilityLabel(canvasRendered: boolean, file: DiffFileHeader) {
|
||||
if (!canvasRendered) return undefined;
|
||||
return `${file.path}, +${formatDiffCount(file.additions)}, -${formatDiffCount(file.deletions)}`;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
||||
];
|
||||
}
|
||||
|
||||
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
||||
function fileChange(file: DiffFileHeader): "added" | "deleted" | "modified" {
|
||||
return diffFileChangeKind(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { deriveCheckoutDiffResult } from "./use-diff-query";
|
||||
import { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
|
||||
function createParsedDiffFile(path: string): ParsedDiffFile {
|
||||
return {
|
||||
@@ -59,6 +59,7 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
files: [],
|
||||
entries: null,
|
||||
payloadError: error,
|
||||
diffTooLarge: true,
|
||||
isLoading: false,
|
||||
@@ -68,3 +69,27 @@ describe("deriveCheckoutDiffResult", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the announced tree while file bodies are still loading", () => {
|
||||
const entries = [
|
||||
{
|
||||
path: "src/app.ts",
|
||||
oldPath: null,
|
||||
change: "modified",
|
||||
content: "text",
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
] as const;
|
||||
const result = deriveCheckoutDiffResult({ ...createPayload([]), entries: [...entries] });
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.entries).toEqual(entries);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles a comparison without a base and leaves Uncommitted available", async () => {
|
||||
const { checkoutDiffComparisonError } = await import("./diff-snapshot");
|
||||
expect(checkoutDiffComparisonError({ mode: "all" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "base" })).toMatchObject({ code: "INVALID_COMPARE" });
|
||||
expect(checkoutDiffComparisonError({ mode: "uncommitted" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
checkoutDiffComparisonError,
|
||||
deriveCheckoutDiffResult,
|
||||
type CheckoutDiffSnapshot,
|
||||
} from "./diff-snapshot";
|
||||
export { deriveCheckoutDiffResult } from "./diff-snapshot";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
@@ -18,7 +24,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
type CheckoutDiffQueryPayload = CheckoutDiffSnapshot;
|
||||
|
||||
// Re-export the canonical protocol type so all consumers share one definition.
|
||||
export type { ParsedDiffFile };
|
||||
@@ -77,7 +83,11 @@ export function useCheckoutDiffQuery({
|
||||
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && cwd && !requiresUpdate);
|
||||
const comparisonError =
|
||||
queryEnabled && isConnected ? checkoutDiffComparisonError(normalizedCompare) : null;
|
||||
const routeEnabled = Boolean(
|
||||
queryEnabled && isConnected && cwd && !requiresUpdate && !comparisonError,
|
||||
);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -97,46 +107,13 @@ export function useCheckoutDiffQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
return deriveCheckoutDiffResult(
|
||||
const boundaryError =
|
||||
requiresUpdate && isConnected
|
||||
? {
|
||||
requestId: "unsupported",
|
||||
cwd,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Update the host to view All changes" },
|
||||
}
|
||||
? { code: "UNKNOWN" as const, message: "Update the host to view All changes" }
|
||||
: comparisonError;
|
||||
return deriveCheckoutDiffResult(
|
||||
boundaryError
|
||||
? { requestId: "unavailable", cwd, files: [], error: boundaryError }
|
||||
: (query.data ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
files: ParsedDiffFile[];
|
||||
payloadError: CheckoutDiffQueryPayload["error"];
|
||||
diffTooLarge: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result derives from the payload alone. Until a payload arrives there is no
|
||||
* diff to describe, so the boundary reports loading rather than an empty diff —
|
||||
* whether the query is idle (an inactive retained panel), in flight, or the host
|
||||
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
|
||||
* and "No changes" for a diff nobody has fetched yet.
|
||||
*/
|
||||
export function deriveCheckoutDiffResult(
|
||||
payload: CheckoutDiffQueryPayload | null,
|
||||
): CheckoutDiffResult {
|
||||
const payloadError = payload?.error ?? null;
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
diffTooLarge: payload?.diffTooLarge === true,
|
||||
isLoading: payload === null,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useReviewAttachmentSnapshot,
|
||||
useReviewFeedback,
|
||||
} from "@/review";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { diffEntryHeaders } from "./diff-snapshot";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useWorkingDiffComparison } from "@/git/working-diff-comparison";
|
||||
@@ -44,7 +46,11 @@ export function useWorkingDiff({
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
const modernDiff = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.diffSubscription === true,
|
||||
);
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const baseRef = (modernDiff ? gitStatus?.comparisonBaseRef : gitStatus?.baseRef) ?? undefined;
|
||||
const currentBranchName =
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||
|
||||
@@ -59,6 +65,7 @@ export function useWorkingDiff({
|
||||
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
payloadError: diffPayloadError,
|
||||
diffTooLarge,
|
||||
isLoading: isDiffLoading,
|
||||
@@ -71,6 +78,7 @@ export function useWorkingDiff({
|
||||
enabled: enabled && isGit,
|
||||
queryScope,
|
||||
});
|
||||
const treeFiles = useMemo(() => (entries ? diffEntryHeaders(entries) : files), [entries, files]);
|
||||
const reviewDraftKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftKey({
|
||||
@@ -112,6 +120,7 @@ export function useWorkingDiff({
|
||||
selectBase,
|
||||
selectAll,
|
||||
files,
|
||||
treeFiles,
|
||||
diffPayloadError,
|
||||
diffTooLarge,
|
||||
isDiffLoading,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -76,6 +76,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
|
||||
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
|
||||
};
|
||||
case "plugin":
|
||||
case "turn_summary":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { AgentDirectoryReplica } from "./agent-replica";
|
||||
|
||||
function payload(title: string): AgentSnapshotPayload {
|
||||
@@ -58,7 +60,7 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
|
||||
|
||||
describe("AgentDirectoryReplica", () => {
|
||||
it.each(["delta", "snapshot"] as const)(
|
||||
"does not erase freshly fetched history when the active directory removes it by %s",
|
||||
"preserves history and unsent drafts when the active directory removes an agent by %s",
|
||||
(removal) => {
|
||||
const serverId = `agent-replica-history-${removal}`;
|
||||
const store = useSessionStore.getState();
|
||||
@@ -68,6 +70,18 @@ describe("AgentDirectoryReplica", () => {
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const draftKey = buildDraftStoreKey({ serverId, agentId: "agent" });
|
||||
const draft = {
|
||||
text: "Keep this unsent draft",
|
||||
attachments: [
|
||||
{
|
||||
kind: "workspace_file" as const,
|
||||
path: "README.md",
|
||||
selection: { kind: "whole_file" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
useDraftStore.getState().saveDraftInput({ draftKey, draft });
|
||||
const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" };
|
||||
replica.submitTimelineAgent(replica.captureTimeline("agent"), archived);
|
||||
store.applyAgentTimelineResponseState(serverId, "agent", {
|
||||
@@ -93,7 +107,9 @@ describe("AgentDirectoryReplica", () => {
|
||||
status: "synced",
|
||||
items: [{ text: "server history" }],
|
||||
});
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)).toEqual(draft);
|
||||
replica.remove("agent");
|
||||
expect(useDraftStore.getState().getDraftInput(draftKey)).toBeUndefined();
|
||||
expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({
|
||||
status: "cold",
|
||||
});
|
||||
|
||||
@@ -190,9 +190,6 @@ export class AgentStoreProjection {
|
||||
agentLastActivity.delete(agentId);
|
||||
return { ...state, agentLastActivity };
|
||||
});
|
||||
useDraftStore.getState().clearDraftInput({
|
||||
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
||||
});
|
||||
const initKey = getInitKey(this.serverId, agentId);
|
||||
if (getInitDeferred(initKey)) {
|
||||
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
|
||||
@@ -201,6 +198,9 @@ export class AgentStoreProjection {
|
||||
|
||||
remove(agentId: string): void {
|
||||
this.removeFromDirectory(agentId);
|
||||
useDraftStore.getState().clearDraftInput({
|
||||
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
||||
});
|
||||
// Only entity deletion destroys the transcript. A filtered active-list
|
||||
// response can arrive after a fresh history response for an archived agent.
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -505,6 +505,9 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
|
||||
version: item.version,
|
||||
data: item.data,
|
||||
};
|
||||
// A folded turn's summary row is presentation, not timeline.
|
||||
case "turn_summary":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@ import { useCallback, useState, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { selectWorkspaceChrome } from "./workspace-tab-layout";
|
||||
import { WorkspaceTabMeasurementScope } from "./workspace-desktop-tabs-row";
|
||||
import type { WorkspaceTabDescriptor } from "./workspace-tabs-types";
|
||||
|
||||
/**
|
||||
* Shows `children` (full header and tab rail) or `compactHeader` below the compact width.
|
||||
* Without a compact header it always shows `children`, keeping the same tree so they never remount.
|
||||
*/
|
||||
export function AdaptiveWorkspaceChrome({
|
||||
compactHeader,
|
||||
children,
|
||||
}: {
|
||||
compactHeader: ReactNode;
|
||||
compactHeader: ReactNode | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [width, setWidth] = useState<number | null>(null);
|
||||
@@ -18,12 +23,21 @@ export function AdaptiveWorkspaceChrome({
|
||||
return (
|
||||
<WorkspaceTabMeasurementScope>
|
||||
<View onLayout={measure}>
|
||||
{selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
|
||||
{compactHeader === null
|
||||
? children
|
||||
: selectWorkspaceChrome({ width, compact: compactHeader, full: children })}
|
||||
</View>
|
||||
</WorkspaceTabMeasurementScope>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pane's tabs, as its compact tab switcher and pane menu need them. */
|
||||
export interface WorkspaceCompactHeaderTabs {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
actions: WorkspaceCompactHeaderActions;
|
||||
}
|
||||
|
||||
export interface WorkspaceCompactHeaderActions {
|
||||
pane?: {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AdaptiveWorkspaceChrome,
|
||||
type WorkspaceCompactHeaderActions,
|
||||
type WorkspaceCompactHeaderTabs,
|
||||
} from "./adaptive-workspace-chrome";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
@@ -3389,11 +3389,11 @@ function WorkspaceScreenContent({
|
||||
],
|
||||
);
|
||||
const renderWorkspaceTabSwitcher = useCallback(
|
||||
(
|
||||
headerTabs: WorkspaceTabDescriptor[],
|
||||
headerActiveTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
) => {
|
||||
({
|
||||
tabs: headerTabs,
|
||||
activeTabKey: headerActiveTabKey,
|
||||
actions,
|
||||
}: WorkspaceCompactHeaderTabs) => {
|
||||
return (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
paneId={actions.pane?.id}
|
||||
@@ -3434,14 +3434,11 @@ function WorkspaceScreenContent({
|
||||
handleCloseTabById,
|
||||
],
|
||||
);
|
||||
// Without a pane's tabs (above a split) the header carries workspace controls only: split panes
|
||||
// keep their tab rails.
|
||||
const renderCompactWorkspaceHeader = useCallback(
|
||||
(
|
||||
headerTabs: WorkspaceTabDescriptor[],
|
||||
headerActiveTabKey: string,
|
||||
actions: WorkspaceCompactHeaderActions,
|
||||
actionsOnly = false,
|
||||
) => {
|
||||
const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions);
|
||||
(paneTabs: WorkspaceCompactHeaderTabs | null, actionsOnly = false) => {
|
||||
const tabSwitcher = paneTabs ? renderWorkspaceTabSwitcher(paneTabs) : null;
|
||||
return (
|
||||
<CompactWorkspaceHeader
|
||||
headerVariant={actionsOnly ? "full" : "compact"}
|
||||
@@ -3457,7 +3454,7 @@ function WorkspaceScreenContent({
|
||||
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
|
||||
>
|
||||
<WorkspaceHeaderMenuMobile
|
||||
pane={actions.pane}
|
||||
pane={paneTabs?.actions.pane}
|
||||
onExitFocusMode={isFocusModeEnabled ? toggleFocusMode : undefined}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -3509,21 +3506,17 @@ function WorkspaceScreenContent({
|
||||
workspacePanelActive,
|
||||
],
|
||||
);
|
||||
const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, {
|
||||
closeAbove: handleCloseTabsToLeft,
|
||||
closeBelow: handleCloseTabsToRight,
|
||||
closeOthers: handleCloseOtherTabs,
|
||||
});
|
||||
const fullWorkspaceActions = renderCompactWorkspaceHeader(
|
||||
const workspaceCompactHeaderTabs = {
|
||||
tabs,
|
||||
activeTabKey,
|
||||
{
|
||||
actions: {
|
||||
closeAbove: handleCloseTabsToLeft,
|
||||
closeBelow: handleCloseTabsToRight,
|
||||
closeOthers: handleCloseOtherTabs,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
const compactWorkspaceHeader = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs);
|
||||
const fullWorkspaceActions = renderCompactWorkspaceHeader(workspaceCompactHeaderTabs, true);
|
||||
const desktopSplitContent = useMemo(() => {
|
||||
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createHistoryStartPagination } from "@/agent-stream/history-start-pagination";
|
||||
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
loadOlderProviderSubagentTimeline,
|
||||
observeProviderSubagentTimeline,
|
||||
providerSubagentKey,
|
||||
refreshProviderSubagents,
|
||||
@@ -565,6 +567,95 @@ describe("projected child history", () => {
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction));
|
||||
}
|
||||
test("older child history completes only after the delayed page is applied", async () => {
|
||||
const tail = { ...response("B", 2, 2), hasOlder: true };
|
||||
useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, tail);
|
||||
let deliver!: (payload: Payload) => void;
|
||||
const client = {
|
||||
fetchProviderSubagentTimeline: () =>
|
||||
new Promise<Payload>((resolve) => {
|
||||
deliver = resolve;
|
||||
}),
|
||||
};
|
||||
let completed = false;
|
||||
const request = loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
}).then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
deliver({ ...response("A", 1, 1), direction: "before" });
|
||||
expect(await request).toBe(true);
|
||||
expect(current().cursor?.startSeq).toBe(1);
|
||||
});
|
||||
test("bounds an older child request so a timeout permits another attempt", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.replaceTimeline(SERVER_ID, { ...response("B", 2, 2), hasOlder: true });
|
||||
const timeouts: Array<number | undefined> = [];
|
||||
const client = {
|
||||
fetchProviderSubagentTimeline: (
|
||||
_parent: string,
|
||||
_child: string,
|
||||
options: { timeout?: number },
|
||||
) => {
|
||||
timeouts.push(options.timeout);
|
||||
return new Promise<Payload>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error("timeout")), options.timeout ?? 60_000),
|
||||
);
|
||||
},
|
||||
};
|
||||
let loading = false;
|
||||
const pagination = createHistoryStartPagination({
|
||||
initiallyVisible: true,
|
||||
read: () => ({
|
||||
distanceFromHistoryStart: 0,
|
||||
isUnderfilled: true,
|
||||
hasOlderHistory: true,
|
||||
isLoadingOlderHistory: loading,
|
||||
isReady: true,
|
||||
progressKey: "e:2",
|
||||
}),
|
||||
load: async () => {
|
||||
loading = true;
|
||||
try {
|
||||
return await loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
limit: 100,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
changed: () => {},
|
||||
scheduleSettle: () => {},
|
||||
});
|
||||
pagination.update();
|
||||
expect(timeouts).toEqual([10_000]);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(pagination.state.status).toBe("failed");
|
||||
pagination.rearm();
|
||||
expect(timeouts).toEqual([10_000, 10_000]);
|
||||
expect(pagination.state.status).toBe("loading");
|
||||
pagination.dispose();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
test("replaces overlapping fetched text and continues live streaming", () => {
|
||||
page("AB", 1, 2);
|
||||
stream(3, "C");
|
||||
|
||||
@@ -355,6 +355,35 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
}));
|
||||
|
||||
/** Owns child history bootstrap and recovery while a pane observes the child. */
|
||||
export async function loadOlderProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
parentAgentId,
|
||||
subagentId,
|
||||
limit,
|
||||
}: {
|
||||
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
subagentId: string;
|
||||
limit: number;
|
||||
}): Promise<boolean> {
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(serverId, parentAgentId, subagentId));
|
||||
const seq = timeline?.cursor?.startSeq;
|
||||
if (!timeline?.hasOlder || !timeline.epoch || seq == null) return false;
|
||||
const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq },
|
||||
limit,
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (payload.error) return false;
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function observeProviderSubagentTimeline({
|
||||
client,
|
||||
serverId,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||
|
||||
/**
|
||||
@@ -86,7 +87,8 @@ export type StreamItem =
|
||||
| TodoListItem
|
||||
| NotificationItem
|
||||
| CompactionItem
|
||||
| PluginTimelineStreamItem;
|
||||
| PluginTimelineStreamItem
|
||||
| TurnSummaryItem;
|
||||
|
||||
export type UserMessageImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -824,6 +826,25 @@ export interface PluginTimelineStreamItem {
|
||||
data: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row a folded turn leaves between its host and its reply, standing in for the work it
|
||||
* hides. Turn collapse produces it from the loaded rows; it is never stored.
|
||||
*/
|
||||
export interface TurnSummaryItem {
|
||||
kind: "turn_summary";
|
||||
id: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
turnId?: string;
|
||||
timestamp: Date;
|
||||
/**
|
||||
* The prompt the turn folds under, or its first loaded tool call when the prompt is above the
|
||||
* loaded history. Keys the reader's expanded turns.
|
||||
*/
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface TodoEntry {
|
||||
text: string;
|
||||
completed: boolean;
|
||||
|
||||
@@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
import { createHostLogger } from "@/runtime/host-logger";
|
||||
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
private connected = false;
|
||||
|
||||
constructor(
|
||||
private readonly probe: FakeDaemonProbe,
|
||||
@@ -17,9 +19,11 @@ class FakeDaemonClient implements DaemonProbeClient {
|
||||
if (this.probe.nextConnectError) {
|
||||
throw this.probe.nextConnectError;
|
||||
}
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
if (!this.connected) return null;
|
||||
return {
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
@@ -27,11 +31,13 @@ class FakeDaemonClient implements DaemonProbeClient {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.connected = false;
|
||||
this.probe.closedClients.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonProbe {
|
||||
diagnosticLines: string[] = [];
|
||||
createdClients: FakeDaemonClient[] = [];
|
||||
closedClients: FakeDaemonClient[] = [];
|
||||
clientIdsRequested = 0;
|
||||
@@ -39,6 +45,12 @@ class FakeDaemonProbe {
|
||||
nextLastError: string | null = null;
|
||||
|
||||
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
|
||||
createLogger: (readServerId) => {
|
||||
const capture = (...values: unknown[]) => {
|
||||
this.diagnosticLines.push(values.map(String).join(" "));
|
||||
};
|
||||
return createHostLogger(readServerId, { log: capture, warn: capture, error: capture });
|
||||
},
|
||||
getClientId: async () => {
|
||||
this.clientIdsRequested += 1;
|
||||
return "cid_shared_probe_test";
|
||||
@@ -76,6 +88,34 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it.each([undefined, "srv_probe_test"])(
|
||||
"logs learned host identity on an adopted probe initially given %s",
|
||||
async (serverId) => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{ id: "direct:test", type: "directTcp", endpoint: "localhost:6767" },
|
||||
{ serverId },
|
||||
probe.deps,
|
||||
);
|
||||
try {
|
||||
result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error");
|
||||
const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length));
|
||||
expect(record.serverId).toBe("srv_probe_test");
|
||||
expect(record.err.message).toBe("socket closed");
|
||||
await result.client.close();
|
||||
expect(result.client.getLastServerInfoMessage()).toBeNull();
|
||||
result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error");
|
||||
const closedRecord = JSON.parse(
|
||||
probe.diagnosticLines[1]!.slice("connection_error ".length),
|
||||
);
|
||||
expect(closedRecord.serverId).toBe("srv_probe_test");
|
||||
expect(closedRecord.err.message).toBe("disconnected");
|
||||
} finally {
|
||||
await result.client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
|
||||
import { createHostLogger } from "@/runtime/host-logger";
|
||||
import { getOrCreateClientId } from "./client-id";
|
||||
import { resolveAppVersion } from "./app-version";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ export interface DaemonProbeClient {
|
||||
}
|
||||
|
||||
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
||||
createLogger: typeof createHostLogger;
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
@@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
|
||||
}
|
||||
|
||||
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
||||
createLogger: createHostLogger,
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
|
||||
@@ -317,5 +320,9 @@ export async function connectToDaemon(
|
||||
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId, options, deps);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
let serverId = options?.serverId;
|
||||
config.logger = deps.createLogger(() => serverId);
|
||||
const result = await connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
serverId = result.serverId;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -117,3 +117,11 @@ export const Maximize2 = StubIcon;
|
||||
export const Workflow = StubIcon;
|
||||
export const ZoomIn = StubIcon;
|
||||
export const ZoomOut = StubIcon;
|
||||
export const CheckSquare = StubIcon;
|
||||
export const Circle = StubIcon;
|
||||
export const FileSymlink = StubIcon;
|
||||
export const MessageSquareCode = StubIcon;
|
||||
export const MicVocal = StubIcon;
|
||||
export const Scissors = StubIcon;
|
||||
export const Sparkles = StubIcon;
|
||||
export const TriangleAlertIcon = StubIcon;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// react-native-web has no ToastAndroid. The toast host imports it by name for its Android
|
||||
// branch; Metro and Node tolerate the missing binding, but browser tests load native ESM,
|
||||
// which rejects the import and fails every module that reaches the toast context.
|
||||
export * from "react-native-web/dist/index.js";
|
||||
|
||||
export const ToastAndroid = {
|
||||
SHORT: 0,
|
||||
LONG: 1,
|
||||
TOP: 0,
|
||||
BOTTOM: 1,
|
||||
CENTER: 2,
|
||||
show() {},
|
||||
showWithGravity() {},
|
||||
};
|
||||
@@ -253,12 +253,12 @@ export default defineConfig({
|
||||
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
|
||||
},
|
||||
// Point to the ESM build so Vite can transform its imports and apply the
|
||||
// Point to react-native-web's ESM build so Vite can transform its imports and apply the
|
||||
// react alias below (the CJS build uses require('react') which bypasses
|
||||
// Vite alias resolution).
|
||||
// Vite alias resolution). The stub re-exports it and adds what web lacks.
|
||||
{
|
||||
find: "react-native",
|
||||
replacement: path.resolve(rootNodeModules, "react-native-web/dist/index.js"),
|
||||
replacement: path.resolve(__dirname, "test-stubs/react-native.mjs"),
|
||||
},
|
||||
{ find: "react", replacement: resolvePackageEntry("react") },
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ const paseoHome = join(root, "home");
|
||||
const cliHome = join(root, "os-home");
|
||||
const workDir = join(root, "work");
|
||||
for (const dir of [paseoHome, cliHome, workDir]) mkdirSync(dir, { recursive: true });
|
||||
const currentProtocolVersion = 5;
|
||||
const currentProtocolVersion = 6;
|
||||
const zsh = ["/bin/zsh", "/usr/bin/zsh"].find((candidate) => existsSync(candidate)) ?? null;
|
||||
|
||||
interface Installation {
|
||||
@@ -456,8 +456,8 @@ try {
|
||||
});
|
||||
console.log(`Competing listener owns released port ${releasedPort}`);
|
||||
|
||||
// There is no supported previous wire at protocol 5. Future previous-version classification
|
||||
// is covered by terminal-worker-protocol.test.ts; do not simulate an unreleased drain adapter.
|
||||
// Protocols 0–4 have no draining adapter. Protocol 5 is now the supported previous wire;
|
||||
// its classification is covered by terminal-worker-protocol.test.ts.
|
||||
for (const protocolVersion of [0, 1, 2, 3, 4]) {
|
||||
console.log(`Test 4: obsolete protocol ${protocolVersion} is closed before serving terminals`);
|
||||
const obsolete = await leaveWorkerWithProtocol(
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"prepack": "npm run build:clean",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck:examples": "tsgo -p tsconfig.examples.json --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"measure:diff-materialization": "tsx scripts/measure-diff-materialization.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.15.3",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { decodeDiffFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
|
||||
import { DiffStream } from "../src/diff/stream.js";
|
||||
const artifacts = resolve(import.meta.dirname, "../../../.artifacts/diff-subscription");
|
||||
const results = [];
|
||||
for (const count of [1, 30]) {
|
||||
const corpus = JSON.parse(await readFile(resolve(artifacts, `corpus-${count}.json`), "utf8")) as {
|
||||
frames: string[];
|
||||
legacy: string;
|
||||
};
|
||||
const frames = corpus.frames.map((value) => Buffer.from(value, "base64"));
|
||||
const binary: number[] = [];
|
||||
const json: number[] = [];
|
||||
for (let iteration = 0; iteration < 15; iteration++) {
|
||||
let files = 0;
|
||||
const stream = new DiffStream((state) => {
|
||||
if (state.phase === "ready") files = state.files.length;
|
||||
});
|
||||
let start = performance.now();
|
||||
for (const frame of frames) stream.receive(decodeDiffFrame(frame));
|
||||
binary.push(performance.now() - start);
|
||||
start = performance.now();
|
||||
validateWSOutboundMessage(JSON.parse(corpus.legacy));
|
||||
json.push(performance.now() - start);
|
||||
if (!files) throw new Error("No materialized files");
|
||||
}
|
||||
const median = (values: number[]) =>
|
||||
values.slice(3).sort((a, b) => a - b)[Math.floor((values.length - 3) / 2)];
|
||||
const result = { count, materializationMs: median(binary), jsonParseValidationMs: median(json) };
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
await writeFile(resolve(artifacts, "client-metrics.json"), JSON.stringify(results, null, 2));
|
||||
if (results.some((result) => result.materializationMs > result.jsonParseValidationMs))
|
||||
process.exitCode = 1;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocketServer, type WebSocket as ServerSocket } from "ws";
|
||||
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay/e2ee";
|
||||
import { DaemonClient } from "./daemon-client.js";
|
||||
import { defaultWebSocketFactory } from "./daemon-client-websocket-transport.js";
|
||||
import {
|
||||
createEncryptedTransport,
|
||||
createWebSocketTransportFactory,
|
||||
@@ -190,3 +194,103 @@ test("native drain capability survives WebSocket and encryption wrappers", async
|
||||
});
|
||||
expect(encrypted.supportsDrain).toBe(false);
|
||||
});
|
||||
|
||||
interface ObservedSocket {
|
||||
closeFrame: { code: number; reason: string } | null;
|
||||
}
|
||||
|
||||
/** A daemon endpoint that accepts sockets and never answers, so every attempt times out. */
|
||||
async function silentDaemon() {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await new Promise<void>((resolve) => server.once("listening", () => resolve()));
|
||||
const sockets: ObservedSocket[] = [];
|
||||
server.on("connection", (socket: ServerSocket) => {
|
||||
const observed: ObservedSocket = { closeFrame: null };
|
||||
sockets.push(observed);
|
||||
socket.on("close", (code, reason) => {
|
||||
observed.closeFrame = { code, reason: reason.toString() };
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing port");
|
||||
return {
|
||||
url: `ws://127.0.0.1:${address.port}/ws`,
|
||||
sockets,
|
||||
openSockets: () => sockets.filter((entry) => entry.closeFrame === null),
|
||||
stop: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
for (const client of server.clients) client.terminate();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Node's global WebSocket applies the WHATWG close rules Chromium and Electron apply:
|
||||
// close() accepts only 1000 or 3000-4999 and a reason of at most 123 UTF-8 bytes.
|
||||
describe("abandoned connections close their WebSocket", () => {
|
||||
test.each([
|
||||
{ name: "direct", e2ee: false },
|
||||
{ name: "relay E2EE", e2ee: true },
|
||||
])(
|
||||
"$name: a timed-out attempt sends a close frame before the client reconnects",
|
||||
async ({ e2ee }) => {
|
||||
const daemon = await silentDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: daemon.url,
|
||||
clientId: "cid_abandoned_close_test",
|
||||
webSocketFactory: defaultWebSocketFactory,
|
||||
connectTimeoutMs: 1_500,
|
||||
reconnect: { enabled: true, baseDelayMs: 50, maxDelayMs: 50 },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
...(e2ee
|
||||
? {
|
||||
e2ee: {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: exportPublicKey(generateKeyPair().publicKey),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
try {
|
||||
void client.connect().catch(() => {});
|
||||
await vi.waitFor(() => expect(daemon.sockets.length).toBeGreaterThanOrEqual(2), {
|
||||
timeout: 4_000,
|
||||
});
|
||||
const [abandoned] = daemon.sockets;
|
||||
await vi.waitFor(() => expect(abandoned?.closeFrame).not.toBeNull(), { timeout: 500 });
|
||||
expect(abandoned?.closeFrame?.reason).toBe("Connection timed out");
|
||||
|
||||
// The newest attempt may still be connecting; aborting it closes without a frame.
|
||||
await client.close();
|
||||
await vi.waitFor(() => expect(daemon.openSockets()).toEqual([]), { timeout: 500 });
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
}
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
|
||||
test("a code or reason the WebSocket API rejects still closes the socket", async () => {
|
||||
const daemon = await silentDaemon();
|
||||
try {
|
||||
const transport = createWebSocketTransportFactory(defaultWebSocketFactory)({
|
||||
url: daemon.url,
|
||||
});
|
||||
await new Promise<void>((resolve) => transport.onOpen(() => resolve()));
|
||||
const reason = `Decryption failed: ${"é".repeat(100)}`;
|
||||
|
||||
expect(() => transport.close(1011, reason)).not.toThrow();
|
||||
|
||||
await vi.waitFor(() => expect(daemon.sockets[0]?.closeFrame).not.toBeNull(), {
|
||||
timeout: 500,
|
||||
});
|
||||
const frame = daemon.sockets[0]?.closeFrame;
|
||||
expect(frame?.code).toBe(1000);
|
||||
expect(reason.startsWith(frame?.reason ?? "")).toBe(true);
|
||||
expect(Buffer.byteLength(frame?.reason ?? "")).toBe(123);
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
// connect timeouts does not crash the CLI with an unhandled error event.
|
||||
const suppressEarlyCloseError = bindTemporaryEarlyCloseErrorHandler(ws);
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
ws.close(toSendableCloseCode(code), toSendableCloseReason(reason));
|
||||
} finally {
|
||||
if (typeof ws.on !== "function" && typeof ws.addEventListener !== "function") {
|
||||
suppressEarlyCloseError();
|
||||
@@ -97,6 +97,32 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
};
|
||||
}
|
||||
|
||||
// WHATWG WebSocket.close throws for a code other than 1000 or 3000-4999 and for a
|
||||
// reason over 123 UTF-8 bytes, and the throw leaves the socket open: the peer keeps
|
||||
// streaming into a connection nobody reads. Browsers, Electron and Node's global
|
||||
// WebSocket enforce this; Node's `ws` accepts more. Translate once here so callers
|
||||
// keep their reasons and every runtime sends the same close frame.
|
||||
const MAX_CLOSE_REASON_BYTES = 123;
|
||||
|
||||
function toSendableCloseCode(code: number | undefined): number {
|
||||
if (code === 1000 || (code !== undefined && code >= 3000 && code <= 4999)) return code;
|
||||
return 1000;
|
||||
}
|
||||
|
||||
function toSendableCloseReason(reason: string | undefined): string | undefined {
|
||||
if (reason === undefined) return undefined;
|
||||
const encoder = new TextEncoder();
|
||||
if (encoder.encode(reason).byteLength <= MAX_CLOSE_REASON_BYTES) return reason;
|
||||
let truncated = "";
|
||||
let bytes = 0;
|
||||
for (const character of reason) {
|
||||
bytes += encoder.encode(character).byteLength;
|
||||
if (bytes > MAX_CLOSE_REASON_BYTES) break;
|
||||
truncated += character;
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
function bindWsMessageHandler(
|
||||
ws: WebSocketLike,
|
||||
handler: (data: unknown, isBinary: boolean) => void,
|
||||
|
||||
@@ -728,6 +728,10 @@ class FakeDaemon {
|
||||
this.onClose({ reason });
|
||||
}
|
||||
|
||||
daemonSendsPong(): void {
|
||||
this.onMessage(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
|
||||
triggerError(event?: unknown): void {
|
||||
this.onError(event);
|
||||
}
|
||||
@@ -2605,7 +2609,7 @@ test("keeps default connect timeout shorter than session RPC waiters", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("foreground checks a silently broken socket through two normal heartbeat misses", async () => {
|
||||
test("foreground tears down a silently dead socket after two fresh five-second windows and reconnects at once", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
@@ -2623,14 +2627,23 @@ test("foreground checks a silently broken socket through two normal heartbeat mi
|
||||
await connection;
|
||||
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// The first silent window is followed by a second check at once, not ten seconds later.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
client.ensureConnected();
|
||||
// The reconnect is immediate: no 1.5s backoff after a foreground liveness teardown.
|
||||
expect(attempts).toBe(2);
|
||||
second.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
// The replacement runs the normal heartbeat cadence.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(second.pingTimestamps()).toEqual(["20s"]);
|
||||
expect(second.closesFromClient()).toEqual([]);
|
||||
});
|
||||
|
||||
test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => {
|
||||
@@ -2675,22 +2688,26 @@ test("chunks prevent heartbeat misses until silence causes two misses", async ()
|
||||
void client.measureLatency({ timeoutMs: 5_000 }).catch((error: unknown) => {
|
||||
measurementError = error;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// Chunks complete every four seconds for the first 36 seconds; no pong ever arrives.
|
||||
for (let index = 0; index < 9; index++) {
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
expect(measurementError).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Latency measurement timed out (5000ms)",
|
||||
}),
|
||||
);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(index === 0 ? 5_000 : 10_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
// No complete message or pong has arrived, but progress prevents misses.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
// Progress inside the foreground window returned the heartbeat to its normal cadence.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s"]);
|
||||
await vi.advanceTimersByTimeAsync(19_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s"]);
|
||||
await vi.advanceTimersByTimeAsync(24_999);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "15s", "40s", "65s"]);
|
||||
expect(daemon.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
});
|
||||
|
||||
@@ -2712,13 +2729,14 @@ test("foreground preserves a multi-MB transfer with its pong queued behind chunk
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(3_001);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
// No complete message or pong has arrived, but progress prevents misses.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]);
|
||||
// The first chunk landed seven seconds after foreground: the first window was silent, the
|
||||
// second saw progress, so the cycle ended without a teardown and the cadence is normal again.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "20s"]);
|
||||
daemon.finishTransfer();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
@@ -2742,13 +2760,14 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
for (let index = 0; index < 12; index++) {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
daemon.receiveChunk(index === 0);
|
||||
// Each completed window starts the next ping ten seconds later,
|
||||
// even though the response and all queued pongs are still in flight.
|
||||
const now = (index + 1) * 4;
|
||||
const expected = ["0s"];
|
||||
if (index >= 4) expected.push("25s");
|
||||
if (index >= 9) expected.push("50s");
|
||||
if (now >= 15) expected.push("15s");
|
||||
if (now >= 40) expected.push("40s");
|
||||
expect(daemon.pingTimestamps()).toEqual(expected);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
}
|
||||
@@ -2758,6 +2777,393 @@ test("chunk progress beyond the daemon lease keeps sending heartbeat pings", asy
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
});
|
||||
|
||||
function connectThroughReplacements(
|
||||
clientId: string,
|
||||
daemons: FakeDaemon[],
|
||||
options?: { reconnect?: { enabled: boolean } },
|
||||
): { client: DaemonClient; attempts: () => number } {
|
||||
let attempts = 0;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId,
|
||||
logger: noopLogger,
|
||||
...(options?.reconnect ? { reconnect: options.reconnect } : {}),
|
||||
transportFactory: () => {
|
||||
const daemon = daemons[Math.min(attempts, daemons.length - 1)]!;
|
||||
attempts += 1;
|
||||
return daemon.transport;
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
return { client, attempts: () => attempts };
|
||||
}
|
||||
|
||||
test("foreground supersedes a heartbeat that straddled the suspension without counting a miss", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-before-expiry", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
first.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(10_000); // heartbeat ping outstanding when the OS suspends JS
|
||||
await vi.advanceTimersByTimeAsync(14_990); // resume: AppState lands before the stale timer
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "24.99s"]);
|
||||
await vi.advanceTimersByTimeAsync(10); // the stale deadline passes without a miss
|
||||
await vi.advanceTimersByTimeAsync(4_990);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "24.99s", "29.99s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("foreground does not inherit a miss recorded before the app was suspended", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-after-expiry", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
first.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(25_000); // ping at 10s missed at 25s, before or during suspension
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "25s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// Two fresh windows: the old miss would have torn down here.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["10s", "25s", "30s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a slow but healthy socket survives foreground after a miss before the suspension", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
const { client, attempts } = connectThroughReplacements("resume-slow-pong", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
daemon.daemonGoesSilent();
|
||||
await vi.advanceTimersByTimeAsync(25_000); // one miss
|
||||
daemon.daemonAnswersPingsAfter("5.5s");
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a pong that arrives after the first foreground window keeps the connection and restores the cadence", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("5.5s");
|
||||
const { client, attempts } = connectThroughReplacements("slow-pong", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(55_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
// After the late pong the heartbeat runs ten seconds after each settled probe again.
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s", "15.5s", "31s", "46.5s"]);
|
||||
});
|
||||
|
||||
test("a latency measurement outstanding at foreground keeps its own deadline, then the cycle runs", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("latency-then-foreground", [
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
const measurement = client.measureLatency({ timeoutMs: 1_000 });
|
||||
const measurementError = measurement.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurementError).resolves.toEqual(
|
||||
expect.objectContaining({ message: "Latency measurement timed out (1000ms)" }),
|
||||
);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "1s", "6s"]);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a shared latency probe's activity from before foreground does not satisfy the fresh check", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("stale-activity", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
void client.measureLatency({ timeoutMs: 5_000 }).catch(() => {});
|
||||
first.receiveChunk(true); // a frame arrived while the measurement was outstanding, then the socket died
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.pingTimestamps()).toEqual(["0s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
// The measurement's own deadline passed; the foreground cycle starts its first fresh window now.
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("a latency measurement that joined a heartbeat probe survives the foreground check", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("2s");
|
||||
const { client, attempts } = connectThroughReplacements("latency-joins-heartbeat", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
|
||||
const measurement = client.measureLatency({ timeoutMs: 5_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s"]);
|
||||
// The heartbeat's pong lands at 12s and answers the measurement that joined it.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurement).resolves.toBe(2000);
|
||||
await vi.advanceTimersByTimeAsync(19_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "22s"]);
|
||||
});
|
||||
|
||||
test("a joined latency measurement outlives a silent foreground window until its own deadline", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonAnswersPingsAfter("7s");
|
||||
const { client, attempts } = connectThroughReplacements("latency-outlives-window", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
await vi.advanceTimersByTimeAsync(10_000); // the first heartbeat ping is outstanding
|
||||
const measurement = client.measureLatency({ timeoutMs: 10_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // the first window closes silent at 16s
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s"]);
|
||||
// The heartbeat's pong lands at 17s, inside the second window and before the 20s deadline.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(measurement).resolves.toBe(7000);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(daemon.pingTimestamps()).toEqual(["10s", "11s", "16s", "27s"]);
|
||||
});
|
||||
|
||||
// Fake timers cannot suspend: a suspension is modelled as the wall clock jumping forward while
|
||||
// the pending window timer stays queued, then firing late on resume.
|
||||
async function suspendFor(ms: number): Promise<void> {
|
||||
vi.setSystemTime(Date.now() + ms);
|
||||
}
|
||||
|
||||
test("a foreground window that spanned a second suspension is discarded when the timer fires first", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-timer", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 1 silent, window 2 outstanding
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await suspendFor(60_000);
|
||||
daemon.daemonAnswersPingsAfter("fast"); // the socket is healthy on resume
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
|
||||
client.ensureConnected({ verify: true }); // then AppState lands
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a foreground window that spanned a second suspension is discarded when AppState fires first", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-appstate", [daemon]);
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(daemon.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await suspendFor(60_000);
|
||||
daemon.daemonAnswersPingsAfter("fast");
|
||||
client.ensureConnected({ verify: true }); // AppState lands before the queued timer
|
||||
await vi.advanceTimersByTimeAsync(5_000); // window 2's timer fires late
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(daemon.closesFromClient()).toEqual([]);
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("a dead socket after a second suspension mid-cycle still gets two fresh windows", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("suspended-mid-cycle-dead", [
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
first.receiveChunk(true); // a frame before the second suspension is not evidence after it
|
||||
await suspendFor(60_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000); // the late timer discards window 2 and starts over
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s", "10s", "15s"]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
});
|
||||
|
||||
test("repeated foreground events share one cycle and do not restart its windows", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("repeated-foreground", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
for (let elapsedSeconds = 1; elapsedSeconds <= 9; elapsedSeconds++) {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(first.closesFromClient()).toEqual([]);
|
||||
}
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(attempts()).toBe(2);
|
||||
second.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
});
|
||||
|
||||
test("a late pong from the replaced socket does not count for the replacement", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
second.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("late-pong", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(2);
|
||||
second.openConnection();
|
||||
client.ensureConnected({ verify: true });
|
||||
expect(second.pingTimestamps()).toEqual(["10s"]);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
first.daemonSendsPong();
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
expect(second.pingTimestamps()).toEqual(["10s", "15s"]);
|
||||
expect(second.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]);
|
||||
});
|
||||
|
||||
test("closing the client in the middle of a foreground cycle stops it", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("close-mid-cycle", [first, second]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
await client.close();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(first.pingTimestamps()).toEqual(["0s", "5s"]);
|
||||
expect(first.closesFromClient()).toEqual([{ code: 1000, reason: "Client closed" }]);
|
||||
expect(attempts()).toBe(1);
|
||||
expect(client.getConnectionState()).toEqual({ status: "disposed" });
|
||||
});
|
||||
|
||||
test("a foreground teardown with reconnect disabled reports the timeout and stays down", async () => {
|
||||
useHeartbeatClock();
|
||||
const daemon = new FakeDaemon();
|
||||
daemon.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("no-reconnect", [daemon], {
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const connection = client.connect();
|
||||
daemon.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(client.getConnectionState()).toEqual({
|
||||
status: "disconnected",
|
||||
reason: "Liveness check timed out (5000ms)",
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(1);
|
||||
});
|
||||
|
||||
test("the immediate reconnect after a foreground teardown backs off normally if it fails", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
const second = new FakeDaemon();
|
||||
const third = new FakeDaemon();
|
||||
first.daemonGoesSilent();
|
||||
const { client, attempts } = connectThroughReplacements("no-storm", [first, second, third]);
|
||||
const connection = client.connect();
|
||||
first.openConnection();
|
||||
await connection;
|
||||
client.ensureConnected({ verify: true });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(attempts()).toBe(2);
|
||||
second.daemonClosesWith("connection refused");
|
||||
await vi.advanceTimersByTimeAsync(1_499);
|
||||
expect(attempts()).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(attempts()).toBe(3);
|
||||
third.openConnection();
|
||||
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
||||
});
|
||||
|
||||
test("an obsolete heartbeat cannot close a replacement connection", async () => {
|
||||
useHeartbeatClock();
|
||||
const first = new FakeDaemon();
|
||||
@@ -2887,6 +3293,18 @@ test("a heartbeat sharing a latency measurement still saves the RTT", async () =
|
||||
expect(session.lastLivenessRttMs()).toBe(2000);
|
||||
});
|
||||
|
||||
test("a measurement that joins an in-flight ping reports that ping's round trip", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
session.daemonAnswersPingsAfter("2s");
|
||||
await session.connect();
|
||||
await session.advance(11_000); // the heartbeat ping sent at 10s is still outstanding
|
||||
const measurement = session.measureLatency({ timeoutMs: 5_000 });
|
||||
await session.advance(1_000);
|
||||
await expect(measurement).resolves.toBe(2000);
|
||||
expect(session.lastLivenessRttMs()).toBe(2000);
|
||||
});
|
||||
|
||||
test("reports the round-trip time of the last successful heartbeat", async () => {
|
||||
useHeartbeatClock();
|
||||
const session = new DaemonClientSession();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
DiffStreamRouter,
|
||||
DiffSubscriptionError,
|
||||
type DiffCompare,
|
||||
type DiffState,
|
||||
} from "./diff/index.js";
|
||||
import {
|
||||
ChunkedTransport,
|
||||
ChunkedTransportProtocolError,
|
||||
@@ -1137,6 +1143,12 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
|
||||
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
|
||||
const LIVENESS_HEARTBEAT_TIMEOUT_MS = 15_000;
|
||||
// Window of each check after the app returns to the foreground. A socket that silently died
|
||||
// while the app was suspended stays OPEN and accepts sends, so only inbound frames prove it.
|
||||
const FOREGROUND_LIVENESS_TIMEOUT_MS = 5_000;
|
||||
// A foreground window whose timer fires this much later than scheduled (wall clock) spanned
|
||||
// another suspension or a blocked JS thread, so its silence is no evidence either way.
|
||||
const SUSPENDED_WINDOW_SLACK_MS = 2_000;
|
||||
const LIVENESS_FAILURE_RECONNECT_THRESHOLD = 2;
|
||||
|
||||
/** Default timeout for waiting for connection before sending queued messages */
|
||||
@@ -1299,13 +1311,27 @@ interface PingProbe {
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
startedAt: number;
|
||||
// Wall clock, which keeps running while the OS has JS suspended; perfNow may not.
|
||||
startedAtWallMs: number;
|
||||
receivedActivity: boolean;
|
||||
// Whether a timeout on this ping should be recorded as a liveness failure. Only the
|
||||
// heartbeat sets this; a latency measurement never drives teardown, even when a
|
||||
// heartbeat tick shares (dedupes onto) an in-flight measurement ping.
|
||||
drivesLivenessFailure: boolean;
|
||||
role: PingProbeRole;
|
||||
}
|
||||
|
||||
// A latency measurement waits for the next pong, not for a particular probe: the liveness
|
||||
// cycle may replace the probe the measurement joined, and a pong that answers an expired window
|
||||
// still proves the socket. It settles on a pong, a connection failure, or its own deadline.
|
||||
interface PongWaiter {
|
||||
startedAt: number;
|
||||
resolve: (rttMs: number) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
// Who started the probe. A "latency" probe never drives teardown, even when a heartbeat
|
||||
// tick shares (dedupes onto) it. A "heartbeat" probe runs the regular cadence. A
|
||||
// "foreground" probe belongs to the verification cycle that ensureConnected({ verify })
|
||||
// runs after the app resumes: short window, immediate second check, reconnect at once.
|
||||
type PingProbeRole = "latency" | "heartbeat" | "foreground";
|
||||
|
||||
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26.
|
||||
export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean {
|
||||
return features?.usageSources === true || features?.providerUsageList === true;
|
||||
@@ -1414,6 +1440,20 @@ export class DaemonClient {
|
||||
private connectionFailure: Error | undefined;
|
||||
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
|
||||
private connectionState: ConnectionState = { status: "idle" };
|
||||
private readonly diffStreams = new DiffStreamRouter({
|
||||
modern: (compare) =>
|
||||
this.observe(
|
||||
"diff.subscribe.response",
|
||||
{ type: "diff.subscribe.request", ...compare },
|
||||
{
|
||||
validateSnapshot: (payload) => {
|
||||
if (payload.error) throw new DiffSubscriptionError(payload.error);
|
||||
},
|
||||
},
|
||||
),
|
||||
legacy: (cwd, compare) => this.observeCheckoutDiff(cwd, compare),
|
||||
failed: (error) => this.logger.error({ err: error }, "Diff observer failed"),
|
||||
});
|
||||
private readonly terminalStreams = new TerminalStreamRouter();
|
||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||
private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>();
|
||||
@@ -1443,6 +1483,7 @@ export class DaemonClient {
|
||||
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private runtimeMetrics: DaemonClientRuntimeMetrics | null = null;
|
||||
private pingProbe: PingProbe | null = null;
|
||||
private readonly pongWaiters = new Set<PongWaiter>();
|
||||
private livenessHeartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastLivenessRttMs: number | null = null;
|
||||
private consecutiveLivenessFailures = 0;
|
||||
@@ -1763,10 +1804,12 @@ export class DaemonClient {
|
||||
this.disposeTransport(1000, "Client closed");
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.clearWaiters(new Error("Daemon client closed"));
|
||||
await this.diffStreams.close();
|
||||
await this.owned.close();
|
||||
this.creations.close();
|
||||
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
||||
this.rejectPingProbe(new Error("Daemon client closed"));
|
||||
this.rejectPongWaiters(new Error("Daemon client closed"));
|
||||
this.terminalStreams.clearSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
if (this.runtimeMetricsInterval) {
|
||||
@@ -1789,7 +1832,7 @@ export class DaemonClient {
|
||||
this.shouldReconnect = true;
|
||||
}
|
||||
if (this.connectionState.status === "connected") {
|
||||
if (options?.verify) this.tickLivenessHeartbeat();
|
||||
if (options?.verify) this.verifyConnectionOnForeground();
|
||||
return;
|
||||
}
|
||||
if (this.connectionState.status === "connecting") return;
|
||||
@@ -2540,43 +2583,41 @@ export class DaemonClient {
|
||||
|
||||
measureLatency(params?: { timeoutMs?: number }): Promise<number> {
|
||||
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
let deadline: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs);
|
||||
});
|
||||
return Promise.race([
|
||||
this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }),
|
||||
timeout,
|
||||
])
|
||||
.catch((error) => {
|
||||
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
||||
})
|
||||
.finally(() => clearTimeout(deadline));
|
||||
}
|
||||
|
||||
private async livenessPing(params?: { timeoutMs?: number }): Promise<number> {
|
||||
const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS);
|
||||
try {
|
||||
const rttMs = await this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: true });
|
||||
this.lastLivenessRttMs = rttMs;
|
||||
return rttMs;
|
||||
} catch (error) {
|
||||
throw toTimeoutError(error, "Liveness check", timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
private sendPingAwaitRtt(params: {
|
||||
timeoutMs: number;
|
||||
drivesLivenessFailure: boolean;
|
||||
}): Promise<number> {
|
||||
if (this.connectionState.status !== "connected" || !this.transport) {
|
||||
return Promise.reject(
|
||||
new Error(`Transport not connected (status: ${this.connectionState.status})`),
|
||||
);
|
||||
}
|
||||
const waiter: PongWaiter = { startedAt: perfNow(), resolve: () => {}, reject: () => {} };
|
||||
const pong = new Promise<number>((resolve, reject) => {
|
||||
waiter.resolve = resolve;
|
||||
waiter.reject = reject;
|
||||
});
|
||||
let deadline: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs);
|
||||
});
|
||||
this.pongWaiters.add(waiter);
|
||||
// The probe only puts a ping on the wire; its own outcome is the heartbeat's concern. A
|
||||
// measurement that joins an in-flight ping reports that ping's round trip, so the clock
|
||||
// starts at the send, not at the join.
|
||||
const probe = this.ensurePingProbe({ timeoutMs, role: "latency" });
|
||||
void probe.promise.catch(() => {});
|
||||
waiter.startedAt = probe.startedAt;
|
||||
return Promise.race([pong, timeout])
|
||||
.catch((error) => {
|
||||
throw toTimeoutError(error, "Latency measurement", timeoutMs);
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(deadline);
|
||||
this.pongWaiters.delete(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
// One ping is outstanding at a time; a caller that finds one shares it.
|
||||
private ensurePingProbe(params: { timeoutMs: number; role: PingProbeRole }): PingProbe {
|
||||
if (this.pingProbe) {
|
||||
return this.pingProbe.promise;
|
||||
return this.pingProbe;
|
||||
}
|
||||
|
||||
const startedAt = perfNow();
|
||||
@@ -2592,10 +2633,20 @@ export class DaemonClient {
|
||||
this.pingProbe = null;
|
||||
const error = new PingTimeoutError(timeoutMs);
|
||||
probe.reject(error);
|
||||
if (
|
||||
probe.role === "foreground" &&
|
||||
Date.now() - probe.startedAtWallMs > timeoutMs + SUSPENDED_WINDOW_SLACK_MS
|
||||
) {
|
||||
// The app was suspended again inside this window. Discard it, any frame it saw and
|
||||
// the misses before it; the heartbeat tick that owns this probe starts the cycle over.
|
||||
probe.receivedActivity = false;
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
return;
|
||||
}
|
||||
// Keep the deadline fixed so subsequent pings renew the daemon socket lease
|
||||
// during a long download. Progress proves life even when its pong is queued.
|
||||
if (probe.drivesLivenessFailure && !probe.receivedActivity) {
|
||||
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs));
|
||||
if (probe.role !== "latency" && !probe.receivedActivity) {
|
||||
this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs), probe.role);
|
||||
}
|
||||
};
|
||||
const probe: PingProbe = {
|
||||
@@ -2605,7 +2656,8 @@ export class DaemonClient {
|
||||
timeoutHandle: setTimeout(expire, timeoutMs),
|
||||
receivedActivity: false,
|
||||
startedAt,
|
||||
drivesLivenessFailure: params.drivesLivenessFailure,
|
||||
startedAtWallMs: Date.now(),
|
||||
role: params.role,
|
||||
};
|
||||
this.pingProbe = probe;
|
||||
|
||||
@@ -2613,7 +2665,8 @@ export class DaemonClient {
|
||||
if (this.pingProbe !== probe) return;
|
||||
const sendError = error instanceof Error ? error : new Error(String(error));
|
||||
this.rejectPingProbe(sendError);
|
||||
if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError);
|
||||
this.rejectPongWaiters(sendError);
|
||||
if (probe.role !== "latency") this.recordLivenessFailure(sendError, probe.role);
|
||||
};
|
||||
try {
|
||||
void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend);
|
||||
@@ -2621,7 +2674,7 @@ export class DaemonClient {
|
||||
rejectSend(error);
|
||||
}
|
||||
|
||||
return promise;
|
||||
return probe;
|
||||
}
|
||||
|
||||
private startLivenessHeartbeat(): void {
|
||||
@@ -2648,14 +2701,55 @@ export class DaemonClient {
|
||||
}, LIVENESS_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private tickLivenessHeartbeat(): void {
|
||||
/**
|
||||
* The app came back to the foreground. Start a fresh verification cycle: two
|
||||
* FOREGROUND_LIVENESS_TIMEOUT_MS windows back to back, then teardown and an immediate
|
||||
* reconnect. Misses recorded before the app was suspended do not count, and a heartbeat
|
||||
* probe that straddled the suspension measured the suspension, not the socket, so it is
|
||||
* dropped without a miss. Latency measurements wait for the next pong, so replacing the
|
||||
* probe they joined does not fail them.
|
||||
*/
|
||||
private verifyConnectionOnForeground(): void {
|
||||
// A cycle already running keeps its deadline and its window count.
|
||||
if (this.pingProbe?.role === "foreground") return;
|
||||
if (this.pingProbe?.role === "heartbeat") {
|
||||
this.rejectPingProbe(new Error("Heartbeat superseded by foreground check"));
|
||||
}
|
||||
// A shared latency probe keeps its deadline, but only frames that arrive from now on count
|
||||
// for this check; a latency probe never reads this flag for itself.
|
||||
if (this.pingProbe) this.pingProbe.receivedActivity = false;
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
this.tickLivenessHeartbeat("foreground");
|
||||
}
|
||||
|
||||
private tickLivenessHeartbeat(check: "heartbeat" | "foreground" = "heartbeat"): void {
|
||||
this.stopLivenessHeartbeat();
|
||||
const transport = this.transport;
|
||||
const tick = this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS });
|
||||
void tick
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.transport === transport) this.scheduleNextLivenessHeartbeat();
|
||||
if (this.connectionState.status !== "connected" || !transport) return;
|
||||
const probe = this.ensurePingProbe({
|
||||
timeoutMs:
|
||||
check === "foreground" ? FOREGROUND_LIVENESS_TIMEOUT_MS : LIVENESS_HEARTBEAT_TIMEOUT_MS,
|
||||
role: check,
|
||||
});
|
||||
void probe.promise
|
||||
.then(
|
||||
(rttMs) => {
|
||||
this.lastLivenessRttMs = rttMs;
|
||||
return true;
|
||||
},
|
||||
() => probe.receivedActivity,
|
||||
)
|
||||
.then((alive) => {
|
||||
// A replaced transport, or a probe that superseded this one, owns the next step.
|
||||
if (this.transport !== transport || this.pingProbe) return;
|
||||
if (!alive && check === "foreground") {
|
||||
// Zero inbound frames in a foreground window: check again now. The second silent
|
||||
// window reaches the threshold inside recordLivenessFailure.
|
||||
this.tickLivenessHeartbeat("foreground");
|
||||
return;
|
||||
}
|
||||
this.scheduleNextLivenessHeartbeat();
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2666,7 +2760,12 @@ export class DaemonClient {
|
||||
private observe<T extends CorrelatedResponseType>(
|
||||
responseType: T,
|
||||
message: { type: SessionInboundMessage["type"] } & Record<string, unknown>,
|
||||
options?: { requestId?: string; timeout?: number; signal?: AbortSignal },
|
||||
options?: {
|
||||
requestId?: string;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
validateSnapshot?: (payload: CorrelatedResponsePayload<T>) => void;
|
||||
},
|
||||
): OwnedSubscription<CorrelatedResponsePayload<T>> {
|
||||
let resetSource = false;
|
||||
return this.owned.observeRequest<CorrelatedResponsePayload<T>>(
|
||||
@@ -2692,12 +2791,16 @@ export class DaemonClient {
|
||||
requestType: message.type,
|
||||
error: payload.error,
|
||||
});
|
||||
options?.validateSnapshot?.(payload);
|
||||
accept(payload);
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
resetSource = !(error instanceof DaemonRpcError) && this.isConnected;
|
||||
resetSource =
|
||||
!(error instanceof DaemonRpcError) &&
|
||||
!(error instanceof DiffSubscriptionError) &&
|
||||
this.isConnected;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -4732,6 +4835,26 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
observeDiff(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
return this.diffStreams.observe(compare);
|
||||
}
|
||||
|
||||
async readDiff(compare: DiffCompare): Promise<DiffState> {
|
||||
const observation = this.observeDiff(compare);
|
||||
try {
|
||||
return await new Promise<DiffState>((resolve) => {
|
||||
observation.subscribe({
|
||||
snapshot: (state) => {
|
||||
if (state.phase !== "loading") resolve(state);
|
||||
},
|
||||
update: () => {},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await observation.release();
|
||||
}
|
||||
}
|
||||
|
||||
observeCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base" | "all"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
@@ -7014,6 +7137,7 @@ export class DaemonClient {
|
||||
this.chunkedTransport?.close();
|
||||
this.chunkedTransport = null;
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.stopLivenessHeartbeat();
|
||||
this.cleanupTransport();
|
||||
@@ -7146,6 +7270,7 @@ export class DaemonClient {
|
||||
messageType: "pong",
|
||||
});
|
||||
this.resolvePingProbe();
|
||||
this.resolvePongWaiters();
|
||||
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
|
||||
return;
|
||||
}
|
||||
@@ -7204,6 +7329,10 @@ export class DaemonClient {
|
||||
for (const handler of this.hostFrameListeners) handler(hostFrame);
|
||||
return true;
|
||||
}
|
||||
if (rawBytes[0] >= 0x40 && rawBytes[0] <= 0x4f) {
|
||||
this.diffStreams.receive(rawBytes);
|
||||
return true;
|
||||
}
|
||||
if (isPluginFrameOpcode(rawBytes[0])) {
|
||||
this.handlePluginFrame(rawBytes);
|
||||
return true;
|
||||
@@ -7391,6 +7520,8 @@ export class DaemonClient {
|
||||
reason?: string;
|
||||
event?: string;
|
||||
reasonCode?: string;
|
||||
// Attempt now instead of after the backoff delay. Failures of that attempt back off as usual.
|
||||
immediate?: boolean;
|
||||
}): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
@@ -7404,6 +7535,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
this.owned.disconnected();
|
||||
this.diffStreams.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
|
||||
// Clear all pending waiters and queued sends since the connection was lost
|
||||
@@ -7420,6 +7552,7 @@ export class DaemonClient {
|
||||
this.clearWaiters(connectionError, true);
|
||||
this.rejectPendingSendQueue(connectionError);
|
||||
this.rejectPingProbe(connectionError);
|
||||
this.rejectPongWaiters(connectionError);
|
||||
this.terminalStreams.clearSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
|
||||
@@ -7439,6 +7572,10 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input?.immediate) {
|
||||
void this.attemptConnect();
|
||||
return;
|
||||
}
|
||||
this.armReconnectTimer();
|
||||
}
|
||||
|
||||
@@ -7513,7 +7650,18 @@ export class DaemonClient {
|
||||
probe.reject(error);
|
||||
}
|
||||
|
||||
private recordLivenessFailure(error: Error): void {
|
||||
private resolvePongWaiters(): void {
|
||||
const now = perfNow();
|
||||
for (const waiter of this.pongWaiters) waiter.resolve(now - waiter.startedAt);
|
||||
this.pongWaiters.clear();
|
||||
}
|
||||
|
||||
private rejectPongWaiters(error: Error): void {
|
||||
for (const waiter of this.pongWaiters) waiter.reject(error);
|
||||
this.pongWaiters.clear();
|
||||
}
|
||||
|
||||
private recordLivenessFailure(error: Error, role: PingProbeRole): void {
|
||||
this.consecutiveLivenessFailures += 1;
|
||||
if (this.consecutiveLivenessFailures < LIVENESS_FAILURE_RECONNECT_THRESHOLD) {
|
||||
return;
|
||||
@@ -7525,6 +7673,8 @@ export class DaemonClient {
|
||||
reason: error.message,
|
||||
event: "LIVENESS_TIMEOUT",
|
||||
reasonCode: "liveness_timeout",
|
||||
// The foreground cycle already waited two windows; the user is looking at the app.
|
||||
immediate: role === "foreground",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7563,6 +7713,7 @@ export class DaemonClient {
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
||||
this.startLivenessHeartbeat();
|
||||
this.diffStreams.connected(serverInfo.features?.diffSubscription === true);
|
||||
this.owned.restore(serverInfo, {
|
||||
...DEFAULT_CLIENT_CAPABILITIES,
|
||||
...this.config.capabilities,
|
||||
@@ -7839,3 +7990,5 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export type { DiffCompare, DiffState, DiffEntry, ResolvedShas } from "./diff/index.js";
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { DaemonClient, type DaemonTransport } from "../daemon-client.js";
|
||||
import {
|
||||
encodeDiffFrame,
|
||||
DiffOpcode,
|
||||
type DiffFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { DiffState } from "./index.js";
|
||||
function socket() {
|
||||
let message: (data: unknown, binary?: boolean) => void = () => {};
|
||||
let open: () => void = () => {};
|
||||
let close: (event?: unknown) => void = () => {};
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (typeof data === "string") {
|
||||
const value = JSON.parse(data);
|
||||
if (value.type === "session") {
|
||||
sent.push(value.message);
|
||||
if (value.message.type === "subscription.release.request")
|
||||
queueMicrotask(() =>
|
||||
receive({
|
||||
type: "subscription.release.response",
|
||||
payload: {
|
||||
requestId: value.message.requestId,
|
||||
subscriptionId: value.message.subscriptionId,
|
||||
released: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
close: () => {},
|
||||
onMessage: (handler) => {
|
||||
message = handler;
|
||||
return () => {};
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
open = handler;
|
||||
return () => {};
|
||||
},
|
||||
onClose: (handler) => {
|
||||
close = handler;
|
||||
return () => {};
|
||||
},
|
||||
onError: () => () => {},
|
||||
};
|
||||
const receive = (value: unknown) =>
|
||||
message(JSON.stringify({ type: "session", message: value }), false);
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
receive,
|
||||
raw: (bytes: Uint8Array) => message(bytes, true),
|
||||
frame: (frame: DiffFrame) => message(encodeDiffFrame(frame), true),
|
||||
disconnect: () => close({ code: 1001 }),
|
||||
open: (modern = true) => {
|
||||
open();
|
||||
receive({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { ownedSubscriptions: true, diffSubscription: modern, checkoutDiffAll: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
const compare = {
|
||||
cwd: "/repo",
|
||||
base: { kind: "rev", rev: "HEAD" },
|
||||
head: { kind: "worktree" },
|
||||
} as const;
|
||||
const begin: DiffFrame = {
|
||||
opcode: DiffOpcode.Begin,
|
||||
slot: 3,
|
||||
revision: 1,
|
||||
baseRevision: 0,
|
||||
baseSha: "a",
|
||||
headSha: "b",
|
||||
mergeBaseSha: "",
|
||||
entries: [],
|
||||
};
|
||||
const end: DiffFrame = {
|
||||
opcode: DiffOpcode.End,
|
||||
slot: 3,
|
||||
revision: 1,
|
||||
diffTooLarge: false,
|
||||
omitted: [],
|
||||
error: null,
|
||||
};
|
||||
async function setup(modern = true) {
|
||||
const wire = socket();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "diff-test",
|
||||
transportFactory: () => wire.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const connecting = client.connect();
|
||||
wire.open(modern);
|
||||
await connecting;
|
||||
return { wire, client };
|
||||
}
|
||||
function acknowledge(wire: ReturnType<typeof socket>, ordinal = 1) {
|
||||
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
|
||||
wire.receive({
|
||||
type: "diff.subscribe.response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: `diff-${ordinal}`,
|
||||
slot: 3,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
test("attaches binary routing during handshake and resubscribes with a full state on reconnect", async () => {
|
||||
const { wire, client } = await setup();
|
||||
try {
|
||||
const states: DiffState[] = [];
|
||||
const observation = client.observeDiff(compare);
|
||||
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
|
||||
acknowledge(wire);
|
||||
wire.frame(begin);
|
||||
wire.frame(end);
|
||||
expect(states.map((state) => state.phase)).toEqual(["loading", "ready"]);
|
||||
wire.disconnect();
|
||||
const connected = client.connect();
|
||||
wire.open();
|
||||
await connected;
|
||||
acknowledge(wire, 2);
|
||||
wire.frame(begin);
|
||||
wire.frame(end);
|
||||
expect(states.map((state) => state.phase)).toEqual(["loading", "ready", "loading", "ready"]);
|
||||
await observation.release();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
test("an unknown version fails only its subscription and readDiff resolves typed subscribe errors", async () => {
|
||||
const { wire, client } = await setup();
|
||||
try {
|
||||
const observation = client.observeDiff(compare);
|
||||
const states: DiffState[] = [];
|
||||
observation.subscribe({ snapshot: (state) => states.push(state), update: () => {} });
|
||||
acknowledge(wire);
|
||||
const malformed = { ...begin, slot: 3 };
|
||||
const bytes = encodeDiffFrame(malformed);
|
||||
bytes[2] = 255;
|
||||
wire.raw(bytes);
|
||||
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "UNKNOWN" } });
|
||||
const next = client.readDiff(compare);
|
||||
const request = wire.sent.findLast((value) => value.type === "diff.subscribe.request");
|
||||
wire.receive({
|
||||
type: "diff.subscribe.response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: "",
|
||||
slot: 0,
|
||||
error: { code: "INVALID_COMPARE", message: "invalid" },
|
||||
},
|
||||
});
|
||||
expect(await next).toMatchObject({ phase: "error", error: { code: "INVALID_COMPARE" } });
|
||||
expect(client.isConnected).toBe(true);
|
||||
await observation.release();
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
test.each(["uncommitted", "base", "all"] as const)(
|
||||
"old hosts serve the %s comparison through the tagged compatibility route",
|
||||
async (mode) => {
|
||||
const { wire, client } = await setup(false);
|
||||
try {
|
||||
const comparison =
|
||||
mode === "uncommitted"
|
||||
? compare
|
||||
: {
|
||||
...compare,
|
||||
base: { kind: "rev", rev: "refs/tags/v1" } as const,
|
||||
head: mode === "base" ? ({ kind: "rev", rev: "HEAD" } as const) : compare.head,
|
||||
mergeBase: true,
|
||||
};
|
||||
const result = client.readDiff(comparison);
|
||||
const request = wire.sent.find((value) => value.type === "subscribe_checkout_diff_request");
|
||||
expect(request?.compare).toMatchObject({ mode });
|
||||
wire.receive({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: {
|
||||
requestId: request?.requestId,
|
||||
subscriptionId: "legacy",
|
||||
cwd: "/repo",
|
||||
files: [],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
wire.receive({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId: "legacy", cwd: "/repo", files: [], error: null },
|
||||
});
|
||||
expect(await result).toMatchObject({ phase: "ready", files: [] });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
decodeDiffFrame,
|
||||
type DiffEntry as WireEntry,
|
||||
type DiffBegin,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type {
|
||||
DiffSubscribeRequest,
|
||||
SubscribeCheckoutDiffRequest,
|
||||
DiffError,
|
||||
DiffSubscribeResponse,
|
||||
SubscribeCheckoutDiffResponse,
|
||||
ParsedDiffFile,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { OwnedSubscription, SubscriptionObserver } from "../connection/index.js";
|
||||
import { DiffStream } from "./stream.js";
|
||||
export type { DiffEndpoint } from "@getpaseo/protocol/messages";
|
||||
export type DiffCompare = Omit<DiffSubscribeRequest, "type" | "requestId">;
|
||||
|
||||
export type DiffEntry = Omit<WireEntry, "body">;
|
||||
export type ResolvedShas = Pick<DiffBegin, "baseSha" | "headSha" | "mergeBaseSha">;
|
||||
|
||||
export type DiffState =
|
||||
| { phase: "loading"; revision: number; entries: DiffEntry[] }
|
||||
| {
|
||||
phase: "ready";
|
||||
revision: number;
|
||||
resolved: ResolvedShas;
|
||||
diffTooLarge: boolean;
|
||||
files: ParsedDiffFile[];
|
||||
}
|
||||
| { phase: "error"; revision: number; error: DiffError };
|
||||
type LegacyCompare = SubscribeCheckoutDiffRequest["compare"];
|
||||
|
||||
interface Port {
|
||||
modern(compare: DiffCompare): OwnedSubscription<DiffSubscribeResponse["payload"]>;
|
||||
legacy(
|
||||
cwd: string,
|
||||
compare: LegacyCompare,
|
||||
): OwnedSubscription<SubscribeCheckoutDiffResponse["payload"]>;
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
export class DiffSubscriptionError extends Error {
|
||||
constructor(readonly detail: DiffError) {
|
||||
super(detail.message);
|
||||
}
|
||||
}
|
||||
interface Registration {
|
||||
start(modern: boolean): void;
|
||||
disconnected(): void;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
/** Capability selection, slot ownership and reconnect belong to this domain. */
|
||||
export class DiffStreamRouter {
|
||||
private registrations = new Set<Registration>();
|
||||
private slots = new Map<number, { receive(bytes: Uint8Array): void }>();
|
||||
private mode: boolean | null = null;
|
||||
constructor(private port: Port) {}
|
||||
connected(modern: boolean): void {
|
||||
this.mode = modern;
|
||||
for (const registration of this.registrations) registration.start(modern);
|
||||
}
|
||||
disconnected(): void {
|
||||
this.mode = null;
|
||||
this.slots.clear();
|
||||
for (const registration of this.registrations) registration.disconnected();
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
await Promise.all([...this.registrations].map((registration) => registration.release()));
|
||||
}
|
||||
receive(bytes: Uint8Array): void {
|
||||
const owner = this.slots.get(bytes[1]);
|
||||
owner?.receive(bytes);
|
||||
}
|
||||
private releaseBackend(backend: OwnedSubscription<unknown> | null): void {
|
||||
void backend?.release().catch(this.port.failed);
|
||||
}
|
||||
observe(compare: DiffCompare): OwnedSubscription<DiffState> {
|
||||
let backend: OwnedSubscription<unknown> | null = null;
|
||||
let state: (DiffState & { subscriptionId: string }) | null = null;
|
||||
let slot: number | null = null;
|
||||
let released = false;
|
||||
let generation = 0;
|
||||
let stream: DiffStream;
|
||||
const observers = new Set<SubscriptionObserver<DiffState & { subscriptionId: string }>>();
|
||||
let resolve!: (state: DiffState & { subscriptionId: string }) => void;
|
||||
const ready = new Promise<DiffState & { subscriptionId: string }>((accept) => {
|
||||
resolve = accept;
|
||||
});
|
||||
const publish = (value: DiffState) => {
|
||||
if (released) return;
|
||||
state = { ...value, subscriptionId: backend?.subscriptionId ?? "" };
|
||||
resolve(state);
|
||||
for (const observer of observers) {
|
||||
try {
|
||||
observer.snapshot(state);
|
||||
} catch (error) {
|
||||
this.port.failed(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const detach = () => {
|
||||
if (slot !== null) this.slots.delete(slot);
|
||||
slot = null;
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
detach();
|
||||
publish({
|
||||
phase: "error",
|
||||
revision: 0,
|
||||
error:
|
||||
error instanceof DiffSubscriptionError
|
||||
? error.detail
|
||||
: { code: "UNKNOWN", message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
this.releaseBackend(backend);
|
||||
};
|
||||
const registration: Registration = {
|
||||
start: (modern) => {
|
||||
if (released || backend) return;
|
||||
const current = ++generation;
|
||||
const failed = (error: unknown) => {
|
||||
if (current === generation) fail(error);
|
||||
};
|
||||
stream = new DiffStream(publish);
|
||||
if (modern) {
|
||||
const observation = this.port.modern(compare);
|
||||
backend = observation;
|
||||
observation.subscribe({
|
||||
snapshot: (payload) => {
|
||||
if (current !== generation) return;
|
||||
detach();
|
||||
if (payload.error) {
|
||||
fail(new DiffSubscriptionError(payload.error));
|
||||
return;
|
||||
}
|
||||
slot = payload.slot;
|
||||
const owner = {
|
||||
receive: (bytes: Uint8Array) => {
|
||||
try {
|
||||
stream.receive(decodeDiffFrame(bytes));
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
this.slots.set(slot, owner);
|
||||
},
|
||||
update: () => {},
|
||||
error: failed,
|
||||
});
|
||||
void observation.ready.catch(failed);
|
||||
} else {
|
||||
// COMPAT(diffSubscription): added in v0.15.0, remove after 2027-05-06.
|
||||
const legacy = legacyCompare(compare);
|
||||
if (!legacy) {
|
||||
fail(
|
||||
new DiffSubscriptionError({
|
||||
code: "INVALID_COMPARE",
|
||||
message: "Update the host to use this comparison",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const observation = this.port.legacy(compare.cwd, legacy);
|
||||
backend = observation;
|
||||
const apply = (
|
||||
payload: Pick<
|
||||
SubscribeCheckoutDiffResponse["payload"],
|
||||
"files" | "error" | "diffTooLarge"
|
||||
>,
|
||||
) => {
|
||||
if (current !== generation) return;
|
||||
if (payload.error)
|
||||
publish({
|
||||
phase: "error",
|
||||
revision: 0,
|
||||
error: {
|
||||
code: payload.error.code === "NOT_GIT_REPO" ? "NOT_GIT_REPO" : "UNKNOWN",
|
||||
message: payload.error.message,
|
||||
},
|
||||
});
|
||||
else
|
||||
publish({
|
||||
phase: "ready",
|
||||
revision: (state?.revision ?? 0) + 1,
|
||||
resolved: { baseSha: "", headSha: "", mergeBaseSha: "" },
|
||||
diffTooLarge: payload.diffTooLarge === true,
|
||||
files: payload.files,
|
||||
});
|
||||
};
|
||||
observation.subscribe({
|
||||
snapshot: apply,
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") apply(message.payload);
|
||||
},
|
||||
error: failed,
|
||||
});
|
||||
void observation.ready.catch(failed);
|
||||
}
|
||||
},
|
||||
disconnected: () => {
|
||||
generation++;
|
||||
detach();
|
||||
state = null;
|
||||
const old = backend;
|
||||
backend = null;
|
||||
void old?.release().catch(this.port.failed);
|
||||
},
|
||||
release: async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
generation++;
|
||||
detach();
|
||||
this.registrations.delete(registration);
|
||||
await backend?.release();
|
||||
observers.clear();
|
||||
},
|
||||
};
|
||||
this.registrations.add(registration);
|
||||
if (this.mode !== null) registration.start(this.mode);
|
||||
return {
|
||||
get subscriptionId() {
|
||||
return backend?.subscriptionId ?? null;
|
||||
},
|
||||
ready,
|
||||
subscribe: (observer) => {
|
||||
observers.add(observer);
|
||||
if (state) observer.snapshot(state);
|
||||
return () => observers.delete(observer);
|
||||
},
|
||||
release: () => registration.release(),
|
||||
};
|
||||
}
|
||||
}
|
||||
function legacyCompare(compare: DiffCompare): LegacyCompare | null {
|
||||
if (compare.highlight === false) return null;
|
||||
const { base, head } = compare;
|
||||
if (base.kind !== "rev") return null;
|
||||
if (base.rev === "HEAD" && head.kind === "worktree")
|
||||
return { mode: "uncommitted", ignoreWhitespace: compare.ignoreWhitespace };
|
||||
if (compare.mergeBase && head.kind === "rev" && head.rev === "HEAD")
|
||||
return { mode: "base", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
|
||||
if (compare.mergeBase && head.kind === "worktree")
|
||||
return { mode: "all", baseRef: base.rev, ignoreWhitespace: compare.ignoreWhitespace };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { DiffStream } from "./stream.js";
|
||||
import { DiffOpcode, type DiffBegin } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { DiffState } from "./index.js";
|
||||
const entry = {
|
||||
path: "a.ts",
|
||||
oldPath: null,
|
||||
change: "modified" as const,
|
||||
content: "text" as const,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
body: true,
|
||||
};
|
||||
const begin = (revision: number, baseRevision = 0): DiffBegin => ({
|
||||
opcode: DiffOpcode.Begin,
|
||||
slot: 0,
|
||||
revision,
|
||||
baseRevision,
|
||||
baseSha: "a",
|
||||
headSha: "b",
|
||||
mergeBaseSha: "",
|
||||
entries: [entry],
|
||||
});
|
||||
const end = (revision: number) =>
|
||||
({
|
||||
opcode: DiffOpcode.End,
|
||||
slot: 0,
|
||||
revision,
|
||||
diffTooLarge: false,
|
||||
omitted: [],
|
||||
error: null,
|
||||
}) as const;
|
||||
test("publishes the initial tree, commits on End and buffers refreshes with stable equal files", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((state) => states.push(state));
|
||||
stream.receive(begin(1));
|
||||
expect(states[0]).toMatchObject({ phase: "loading", entries: [{ path: "a.ts" }] });
|
||||
stream.receive({
|
||||
opcode: DiffOpcode.Bodies,
|
||||
slot: 0,
|
||||
revision: 1,
|
||||
files: [{ path: "a.ts", hunks: [] }],
|
||||
});
|
||||
expect(states).toHaveLength(1);
|
||||
stream.receive(end(1));
|
||||
const ready = states[1];
|
||||
if (ready.phase !== "ready") throw new Error("Expected ready");
|
||||
stream.receive(begin(2, 1));
|
||||
stream.receive({
|
||||
opcode: DiffOpcode.Bodies,
|
||||
slot: 0,
|
||||
revision: 2,
|
||||
files: [{ path: "a.ts", hunks: [] }],
|
||||
});
|
||||
expect(states).toHaveLength(2);
|
||||
stream.receive(end(2));
|
||||
const refreshed = states[2];
|
||||
if (refreshed.phase !== "ready") throw new Error("Expected ready");
|
||||
expect(refreshed.files[0]).toBe(ready.files[0]);
|
||||
});
|
||||
test("superseded and stale frames cannot leave the client loading; End is authoritative", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((s) => states.push(s));
|
||||
stream.receive(begin(1));
|
||||
stream.receive(begin(2));
|
||||
stream.receive(end(1));
|
||||
expect(() => stream.receive(end(2))).toThrow(/Missing/);
|
||||
stream.receive(begin(3));
|
||||
stream.receive({ ...end(3), error: { code: "REF_NOT_FOUND", message: "gone" } });
|
||||
expect(states.at(-1)).toMatchObject({ phase: "error", error: { code: "REF_NOT_FOUND" } });
|
||||
stream.receive({ ...begin(4), entries: [] });
|
||||
stream.receive(end(4));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
|
||||
});
|
||||
test("unchanged entries retain hunks, omissions override cache, removed paths disappear", () => {
|
||||
const states: DiffState[] = [];
|
||||
const stream = new DiffStream((s) => states.push(s));
|
||||
stream.receive(begin(1));
|
||||
stream.receive({ ...end(1), omitted: ["a.ts"], diffTooLarge: true });
|
||||
stream.receive({ ...begin(2, 1), entries: [{ ...entry, body: false }] });
|
||||
stream.receive(end(2));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [{ status: "too_large" }] });
|
||||
stream.receive({ ...begin(3, 2), entries: [] });
|
||||
stream.receive(end(3));
|
||||
expect(states.at(-1)).toMatchObject({ phase: "ready", files: [] });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DiffOpcode,
|
||||
materializeDiffFile,
|
||||
type DiffBegin,
|
||||
type DiffBody,
|
||||
type DiffFrame,
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import type { DiffState } from "./index.js";
|
||||
|
||||
/** Owns revision assembly. End, rather than a local body count, commits the snapshot. */
|
||||
export class DiffStream {
|
||||
private pending: { begin: DiffBegin; bodies: Map<string, DiffBody> } | null = null;
|
||||
private committed: Extract<DiffState, { phase: "ready" }> | null = null;
|
||||
constructor(private publish: (state: DiffState) => void) {}
|
||||
receive(frame: DiffFrame): void {
|
||||
if (frame.opcode === DiffOpcode.Begin) {
|
||||
if (frame.baseRevision !== 0 && frame.baseRevision !== this.committed?.revision)
|
||||
throw new Error("Unknown diff base revision");
|
||||
if (new Set(frame.entries.map((entry) => entry.path)).size !== frame.entries.length)
|
||||
throw new Error("Duplicate diff entry");
|
||||
this.pending = { begin: frame, bodies: new Map() };
|
||||
if (!this.committed)
|
||||
this.publish({
|
||||
phase: "loading",
|
||||
revision: frame.revision,
|
||||
entries: frame.entries.map(({ body: _body, ...entry }) => entry),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pending = this.pending;
|
||||
if (!pending || frame.revision !== pending.begin.revision) return;
|
||||
if (frame.opcode === DiffOpcode.Bodies) {
|
||||
for (const body of frame.files) {
|
||||
if (pending.bodies.has(body.path)) throw new Error("Duplicate diff body");
|
||||
const entry = pending.begin.entries.find((candidate) => candidate.path === body.path);
|
||||
if (!entry?.body) throw new Error("Unexpected diff body");
|
||||
pending.bodies.set(body.path, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.pending = null;
|
||||
if (frame.error) {
|
||||
this.committed = null;
|
||||
this.publish({ phase: "error", revision: frame.revision, error: frame.error });
|
||||
return;
|
||||
}
|
||||
const previous = new Map(this.committed?.files.map((file) => [file.path, file]));
|
||||
const omitted = new Set(frame.omitted);
|
||||
for (const path of omitted)
|
||||
if (!pending.begin.entries.some((entry) => entry.path === path))
|
||||
throw new Error("Unknown omitted diff path");
|
||||
const files = pending.begin.entries.map((entry) => {
|
||||
const old = previous.get(entry.path);
|
||||
const body = pending.bodies.get(entry.path);
|
||||
if (entry.content === "text" && !omitted.has(entry.path) && entry.body && !body)
|
||||
throw new Error(`Missing diff body: ${entry.path}`);
|
||||
if (
|
||||
entry.content === "text" &&
|
||||
!entry.body &&
|
||||
!omitted.has(entry.path) &&
|
||||
(!old || pending.begin.baseRevision === 0)
|
||||
)
|
||||
throw new Error(`Missing inherited diff: ${entry.path}`);
|
||||
let file = materializeDiffFile(entry, body ?? null, omitted.has(entry.path));
|
||||
if (entry.content === "text" && !entry.body && !omitted.has(entry.path) && old)
|
||||
file = { ...file, hunks: old.hunks, status: old.status };
|
||||
return old && sameFile(old, file) ? old : file;
|
||||
});
|
||||
const { revision, baseSha, headSha, mergeBaseSha } = pending.begin;
|
||||
this.committed = {
|
||||
phase: "ready",
|
||||
revision,
|
||||
resolved: { baseSha, headSha, mergeBaseSha },
|
||||
files,
|
||||
diffTooLarge: frame.diffTooLarge,
|
||||
};
|
||||
this.publish(this.committed);
|
||||
}
|
||||
}
|
||||
function sameFile(a: ParsedDiffFile, b: ParsedDiffFile): boolean {
|
||||
if (
|
||||
a.path !== b.path ||
|
||||
a.oldPath !== b.oldPath ||
|
||||
a.isNew !== b.isNew ||
|
||||
a.isDeleted !== b.isDeleted ||
|
||||
a.additions !== b.additions ||
|
||||
a.deletions !== b.deletions ||
|
||||
a.status !== b.status
|
||||
)
|
||||
return false;
|
||||
if (a.hunks === b.hunks) return true;
|
||||
return JSON.stringify(a.hunks) === JSON.stringify(b.hunks);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user