mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4847149428 | ||
|
|
27872a8e86 | ||
|
|
1b0da5fce5 | ||
|
|
02ad59908f | ||
|
|
b509d499f7 | ||
|
|
f539ccf1c0 | ||
|
|
ac08077f7a |
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
@@ -69,6 +70,8 @@ test.describe("New workspace live pin", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
// The composer appears before its provider catalog has hydrated.
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,10 +35,20 @@ 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 {
|
||||
isHistoryStartLoadingOperation,
|
||||
@@ -306,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({
|
||||
@@ -317,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);
|
||||
@@ -334,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
|
||||
@@ -349,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(
|
||||
@@ -505,7 +566,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// 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();
|
||||
historyStartPagination.rearm();
|
||||
});
|
||||
@@ -581,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 (
|
||||
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
|
||||
@@ -418,6 +418,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
height: measurement.size,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
@@ -1172,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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -430,6 +430,20 @@ function agentPermission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
|
||||
const relayPrimary: HostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test",
|
||||
};
|
||||
|
||||
const relayAlternate: HostConnection = {
|
||||
id: "relay:relay-next.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay-next.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test",
|
||||
};
|
||||
|
||||
function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
@@ -1566,6 +1580,233 @@ describe("HostRuntimeController", () => {
|
||||
expect(switched).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the live connection while one alternate sample is re-read at active ticks", async () => {
|
||||
// Replays the recorded Android sequence: both relays probed at startup, the alternate
|
||||
// measured once, then only the active connection is re-read every ten seconds.
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
connections: [relayPrimary, relayAlternate],
|
||||
preferredConnectionId: relayPrimary.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
[relayPrimary.id]: 1053,
|
||||
[relayAlternate.id]: 251,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
const clientsAfterStart = clients.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_500);
|
||||
activeClient.heartbeatReportsRtt(376);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(clients.length).toBe(clientsAfterStart);
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
expect(controller.getSnapshot().client).toBe(activeClient);
|
||||
expect(activeClient.isDisposed()).toBe(false);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("does not hold the previous socket's RTT against a reconnected active connection", async () => {
|
||||
// Replays the recorded foreground recovery: the active client reconnects on its own while an
|
||||
// alternate probe is in flight. The recovered socket has no heartbeat RTT yet.
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
connections: [relayPrimary, relayAlternate],
|
||||
preferredConnectionId: relayPrimary.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
[relayPrimary.id]: 300,
|
||||
[relayAlternate.id]: 392,
|
||||
};
|
||||
let connectGate: Promise<void> | null = null;
|
||||
const baseDeps = makeDeps(latencies, clients);
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: {
|
||||
...baseDeps,
|
||||
connectToDaemon: async (input) => {
|
||||
if (connectGate) await connectGate;
|
||||
return baseDeps.connectToDaemon(input);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.heartbeatReportsRtt(403);
|
||||
await vi.advanceTimersByTimeAsync(150_500);
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
|
||||
activeClient.setConnectionState({ status: "disconnected", reason: "liveness_timeout" });
|
||||
activeClient.setConnectionState({ status: "connecting", attempt: 2 });
|
||||
activeClient.heartbeatReportsRtt(null);
|
||||
latencies[relayAlternate.id] = 280;
|
||||
const alternateConnect = createDeferred<void>();
|
||||
connectGate = alternateConnect.promise;
|
||||
await vi.advanceTimersByTimeAsync(2_800);
|
||||
activeClient.setConnectionState({ status: "connected" });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
alternateConnect.resolve();
|
||||
connectGate = null;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(controller.getSnapshot().probeByConnectionId.get(relayAlternate.id)).toEqual({
|
||||
status: "available",
|
||||
latencyMs: 280,
|
||||
});
|
||||
const clientsAfterProbe = clients.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(clients.length).toBe(clientsAfterProbe);
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayPrimary.id);
|
||||
expect(controller.getSnapshot().client).toBe(activeClient);
|
||||
expect(activeClient.isDisposed()).toBe(false);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("starts the switch streak over when the active socket reconnects", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 60,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
}
|
||||
|
||||
activeClient.setConnectionState({ status: "disconnected", reason: "network changed" });
|
||||
activeClient.setConnectionState({ status: "connected" });
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
}
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("ends the switch streak when a fresh probe of the alternate fails", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({ preferredConnectionId: "direct:lan:6767" });
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
"direct:lan:6767": 15,
|
||||
"relay:relay.paseo.sh:443": 60,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
}
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = new Error("relay unreachable");
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().probeByConnectionId.get("relay:relay.paseo.sh:443")).toEqual({
|
||||
status: "unavailable",
|
||||
latencyMs: null,
|
||||
});
|
||||
|
||||
latencies["relay:relay.paseo.sh:443"] = 30;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("direct:lan:6767");
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
}
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe("relay:relay.paseo.sh:443");
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("restarts the streak when a different candidate becomes the fastest fresh sample", async () => {
|
||||
useHostRuntimeClock();
|
||||
const direct: HostConnection = {
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
};
|
||||
const host = makeHost({
|
||||
connections: [direct, relayPrimary, relayAlternate],
|
||||
preferredConnectionId: direct.id,
|
||||
});
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
const latencies: Record<string, number | Error> = {
|
||||
[direct.id]: 15,
|
||||
[relayPrimary.id]: 60,
|
||||
[relayAlternate.id]: 70,
|
||||
};
|
||||
const controller = new HostRuntimeController({
|
||||
host,
|
||||
deps: makeDeps(latencies, clients),
|
||||
});
|
||||
|
||||
await controller.start({ autoProbe: false });
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(direct.id);
|
||||
const activeClient = controller.getSnapshot().client as unknown as FakeDaemonClient;
|
||||
activeClient.heartbeatReportsRtt(95);
|
||||
|
||||
latencies[relayPrimary.id] = 30;
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(direct.id);
|
||||
}
|
||||
|
||||
latencies[relayAlternate.id] = 20;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(direct.id);
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
await controller.runProbeCycleNow();
|
||||
}
|
||||
expect(controller.getSnapshot().activeConnectionId).toBe(relayAlternate.id);
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("exposes one snapshot with active connection and status from same source", async () => {
|
||||
const host = makeHost();
|
||||
const clients: FakeDaemonClient[] = [];
|
||||
|
||||
@@ -1063,37 +1063,46 @@ export class HostRuntimeController {
|
||||
expectedProbeVersion: requestVersion,
|
||||
});
|
||||
}
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
this.resetSwitchCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeProbe || activeProbe.status !== "available") {
|
||||
// Adaptive switching counts fresh comparisons only. The probe map keeps the last known
|
||||
// sample per connection for display and failover; a streak hit needs a candidate measured
|
||||
// in this cycle and a heartbeat RTT from the socket that is active right now. Re-reading
|
||||
// the cached map at every ten-second active tick turned one idle-probe sample into three
|
||||
// "consecutive" wins, and a reconnected socket inherited its predecessor's RTT, so a
|
||||
// working relay connection was replaced within seconds of coming online.
|
||||
const sampledCandidates = connectionsToProbe.filter(
|
||||
(connection) => connection.id !== currentActiveConnectionId,
|
||||
);
|
||||
if (sampledCandidates.length === 0) {
|
||||
// An active-only tick carries no evidence about any candidate.
|
||||
return;
|
||||
}
|
||||
const fastest =
|
||||
sampledCandidates
|
||||
.flatMap((connection) => {
|
||||
const probe = probeByConnectionId.get(connection.id);
|
||||
return probe?.status === "available"
|
||||
? [{ connectionId: connection.id, latencyMs: probe.latencyMs }]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => left.latencyMs - right.latencyMs)[0] ?? null;
|
||||
if (!fastest) {
|
||||
// Every candidate sampled this cycle failed: that breaks the streak.
|
||||
this.resetSwitchCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
const available = Array.from(probeByConnectionId.entries())
|
||||
.filter(
|
||||
(entry): entry is [string, Extract<ConnectionProbeState, { status: "available" }>] =>
|
||||
entry[1].status === "available",
|
||||
)
|
||||
.map(([connectionId, probe]) => ({
|
||||
connectionId,
|
||||
latencyMs: probe.latencyMs,
|
||||
}))
|
||||
.sort((left, right) => left.latencyMs - right.latencyMs);
|
||||
|
||||
const fastest = available[0] ?? null;
|
||||
if (!fastest || fastest.connectionId === currentActiveConnectionId) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
const activeRttMs = this.currentActiveSocketRttMs();
|
||||
if (activeRttMs === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const improvement = activeProbe.latencyMs - fastest.latencyMs;
|
||||
const improvement = activeRttMs - fastest.latencyMs;
|
||||
if (improvement < ADAPTIVE_SWITCH_THRESHOLD_MS) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
this.resetSwitchCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1105,8 +1114,7 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
if (this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES) {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
this.resetSwitchCandidate();
|
||||
await this.switchToConnection({
|
||||
connectionId: fastest.connectionId,
|
||||
expectedProbeVersion: requestVersion,
|
||||
@@ -1217,6 +1225,19 @@ export class HostRuntimeController {
|
||||
});
|
||||
}
|
||||
|
||||
// The client clears its heartbeat RTT when a socket connects, so a connected client's RTT
|
||||
// describes the socket in use right now; a reconnecting client still carries the old one.
|
||||
private currentActiveSocketRttMs(): number | null {
|
||||
const client = this.snapshot.client;
|
||||
if (!client || client.getConnectionState().status !== "connected") return null;
|
||||
return client.getLastLivenessRttMs();
|
||||
}
|
||||
|
||||
private resetSwitchCandidate(): void {
|
||||
this.switchCandidateConnectionId = null;
|
||||
this.switchCandidateHitCount = 0;
|
||||
}
|
||||
|
||||
// The active connection is probed through its own client, which may still be
|
||||
// reconnecting when the probe starts. A verdict taken then is stale if the client
|
||||
// has since connected, so the decision uses the client's current state.
|
||||
@@ -1506,6 +1527,8 @@ export class HostRuntimeController {
|
||||
return;
|
||||
}
|
||||
if (state.status === "connected") this.learnRelaySecurity(client, connection.id);
|
||||
// Switch evidence belongs to the socket it was measured against.
|
||||
if (state.status !== "connected") this.resetSwitchCandidate();
|
||||
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
||||
this.updateSnapshot({
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1143,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 */
|
||||
@@ -1305,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;
|
||||
@@ -1463,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;
|
||||
@@ -1788,6 +1809,7 @@ export class DaemonClient {
|
||||
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) {
|
||||
@@ -1810,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;
|
||||
@@ -2561,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();
|
||||
@@ -2613,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 = {
|
||||
@@ -2626,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;
|
||||
|
||||
@@ -2634,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);
|
||||
@@ -2642,7 +2674,7 @@ export class DaemonClient {
|
||||
rejectSend(error);
|
||||
}
|
||||
|
||||
return promise;
|
||||
return probe;
|
||||
}
|
||||
|
||||
private startLivenessHeartbeat(): void {
|
||||
@@ -2669,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7197,6 +7270,7 @@ export class DaemonClient {
|
||||
messageType: "pong",
|
||||
});
|
||||
this.resolvePingProbe();
|
||||
this.resolvePongWaiters();
|
||||
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
|
||||
return;
|
||||
}
|
||||
@@ -7446,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);
|
||||
@@ -7476,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;
|
||||
|
||||
@@ -7495,6 +7572,10 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input?.immediate) {
|
||||
void this.attemptConnect();
|
||||
return;
|
||||
}
|
||||
this.armReconnectTimer();
|
||||
}
|
||||
|
||||
@@ -7569,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;
|
||||
@@ -7581,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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -469,7 +469,7 @@ class TestAgentSession implements AgentSession {
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
this.interrupted = false;
|
||||
const turnId = `turn-${++this.turnIdCounter}`;
|
||||
const turnId = `turn-${this.id}-${++this.turnIdCounter}`;
|
||||
// Use setTimeout so events arrive after the caller sets up the foreground waiter
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
@@ -13487,3 +13487,35 @@ test("stops only a currently owned public background item with its current token
|
||||
expect(stopped).toEqual(["one"]);
|
||||
await manager.closeAgent(agent.id);
|
||||
});
|
||||
|
||||
test("turns keep distinct identities across an idle runtime collection and resume", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-identity-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const collect = runtimeCleanup(manager);
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const consume = async (prompt: string, clientMessageId: string) => {
|
||||
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
|
||||
}
|
||||
};
|
||||
await consume("first", "first");
|
||||
const later = new Date(Date.now() + 61 * 60_000);
|
||||
expect((await collect({ now: later })).collected).toHaveLength(1);
|
||||
await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger });
|
||||
await consume("second", "second");
|
||||
const rows = manager.fetchTimeline(agent.id, { limit: 0 }).rows;
|
||||
const userTurnIds = rows
|
||||
.filter((row) => row.item.type === "user_message")
|
||||
.map((row) => row.turnId);
|
||||
expect(userTurnIds).toHaveLength(2);
|
||||
expect(new Set(userTurnIds).size).toBe(2);
|
||||
} finally {
|
||||
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2517,12 +2517,30 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async clearAgentAttention(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.attention.requiresAttention) {
|
||||
agent.attention = { requiresAttention: false };
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
const agent = this.agents.get(agentId);
|
||||
if (agent) {
|
||||
if (agent.attention.requiresAttention) {
|
||||
agent.attention = { requiresAttention: false };
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = this.requireRegistry();
|
||||
const record = await registry.get(agentId);
|
||||
if (!record || record.internal || record.archivedAt || record.requiresAttention !== true) {
|
||||
return;
|
||||
}
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
updatedAt: this.nextStoredUpdatedAt(record),
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
this.dispatchStoredAgentState(nextRecord);
|
||||
}
|
||||
|
||||
async markAgentUnread(agentId: string): Promise<void> {
|
||||
|
||||
@@ -680,16 +680,6 @@ export interface AgentUsageSession {
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
/** An enabled provider that sets its own env, as its sessions report it for usage. */
|
||||
export interface ProviderUsageEnvironment {
|
||||
/** The provider a custom provider extends, or its own id. */
|
||||
provider: string;
|
||||
/** The provider's label, used to name the logins found through it. */
|
||||
label: string;
|
||||
/** The environment the provider launches with. */
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
usageSession?(): AgentUsageSession | null;
|
||||
readonly provider: AgentProvider;
|
||||
@@ -700,6 +690,7 @@ export interface AgentSession {
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
/** Returns an ID unique across the agent lifetime, including resumed sessions and daemon restarts. */
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type AgentProvider,
|
||||
type FetchCatalogOptions,
|
||||
type ProviderSnapshotEntry,
|
||||
type ProviderUsageEnvironment,
|
||||
} from "./agent-sdk-types.js";
|
||||
import {
|
||||
raceProviderRefreshAbort,
|
||||
@@ -29,10 +28,9 @@ import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
|
||||
import type { OpenCodeBridge } from "./providers/opencode/bridge.js";
|
||||
import {
|
||||
createProviderEnv,
|
||||
type AgentProviderRuntimeSettingsMap,
|
||||
type ProviderOverride,
|
||||
import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./provider-launch-config.js";
|
||||
import {
|
||||
buildProviderRegistry,
|
||||
@@ -362,21 +360,6 @@ export class ProviderSnapshotManager {
|
||||
return this.createAgentManagerState(this.generation.definitions, this.providerClients);
|
||||
}
|
||||
|
||||
/** Enabled providers that set their own env, as their sessions report them for usage. */
|
||||
listProviderEnvironments(): ProviderUsageEnvironment[] {
|
||||
return Object.entries(this.generation.definitions).flatMap(([provider, definition]) => {
|
||||
const runtimeSettings = definition.configuration?.runtimeSettings;
|
||||
if (!definition.enabled || !runtimeSettings?.env) return [];
|
||||
return [
|
||||
{
|
||||
provider: definition.derivedFromProviderId ?? provider,
|
||||
label: definition.label,
|
||||
env: createProviderEnv({ baseEnv: process.env, runtimeSettings }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private createAgentManagerState(
|
||||
definitions: Record<AgentProvider, ProviderDefinition>,
|
||||
providerClients: Record<AgentProvider, AgentClient>,
|
||||
|
||||
@@ -1206,7 +1206,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
);
|
||||
|
||||
const firstRunId = internal.autonomousTurn?.id ?? null;
|
||||
expect(firstRunId).toBe("autonomous-turn-1");
|
||||
expect(firstRunId).toEqual(expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/));
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
@@ -1360,7 +1360,9 @@ test("a main-session frame opens no turn until Claude starts one", async () => {
|
||||
// session start a turn of its own, with no prompt from the user.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
@@ -1410,7 +1412,9 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
expect(internal.turnState).toBe("autonomous");
|
||||
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
|
||||
@@ -1446,7 +1450,9 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after
|
||||
|
||||
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-2$/),
|
||||
);
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
@@ -1483,7 +1489,9 @@ test("an idle session state closes an autonomous turn that no result closed", as
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
|
||||
// Safety net: Claude says nothing at all is running any more.
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
@@ -1685,7 +1693,9 @@ test("a new turn clears an interrupt flag no result will consume", async () => {
|
||||
|
||||
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
|
||||
expect(internal.pendingInterruptAbort).toBe(false);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.autonomousTurn?.id).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
|
||||
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
|
||||
await internal.routeSdkMessageFromPump(
|
||||
|
||||
@@ -1345,6 +1345,30 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps turn identities distinct across resumed session objects", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const handle = {
|
||||
provider: "claude" as const,
|
||||
sessionId: "saved-session",
|
||||
metadata: { cwd: process.cwd() },
|
||||
};
|
||||
const first = await client.resumeSession(handle);
|
||||
const firstTurn = await first.startTurn("first");
|
||||
await first.close();
|
||||
const resumed = await client.resumeSession(handle);
|
||||
try {
|
||||
const nextTurn = await resumed.startTurn("second");
|
||||
expect(nextTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("disables Claude hooks for internal agents only", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -2378,6 +2378,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private historySessionId: string | null = null;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private readonly turnNamespace = randomUUID();
|
||||
private nextTurnOrdinal = 1;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
@@ -3983,7 +3984,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(owner: "foreground" | "autonomous"): string {
|
||||
return `${owner}-turn-${this.nextTurnOrdinal++}`;
|
||||
return `${owner}-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
|
||||
|
||||
@@ -1359,7 +1359,7 @@ describe("Codex app-server provider", () => {
|
||||
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
|
||||
createConfig({ thinkingOptionId: undefined }),
|
||||
);
|
||||
await firstSession.startTurn("first turn");
|
||||
const firstTurn = await firstSession.startTurn("first turn");
|
||||
await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
const saved = waitForNextEvent(firstSession, "thread_started");
|
||||
beforeRestart.startsTurn({ threadId: "thread-1" });
|
||||
@@ -1378,7 +1378,8 @@ describe("Codex app-server provider", () => {
|
||||
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
|
||||
thinkingOptionId: "medium",
|
||||
});
|
||||
await resumed.startTurn("turn after restart");
|
||||
const resumedTurn = await resumed.startTurn("turn after restart");
|
||||
expect(resumedTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
beforeRestart.assertNoErrors();
|
||||
afterRestart.assertNoErrors();
|
||||
@@ -4853,6 +4854,7 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const started = waitForNextEvent(session, "turn_started");
|
||||
const resultPromise = session.run("Delegate the investigation.");
|
||||
await appServer.waitForTurnStart();
|
||||
const child = waitForProviderSubagent(session, "legacy-only-child-thread");
|
||||
@@ -4870,7 +4872,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(child).resolves.toMatchObject({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
turnId: "codex-turn-0",
|
||||
turnId: (await started).turnId,
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "legacy-only-child-thread",
|
||||
@@ -4880,7 +4882,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(spawn).resolves.toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "codex-turn-0",
|
||||
turnId: (await started).turnId,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "spawn-legacy-only-child",
|
||||
|
||||
@@ -3529,6 +3529,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private startupEvents: AgentStreamEvent[] | null = null;
|
||||
private readonly turnNamespace = randomUUID();
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
@@ -5719,7 +5720,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(): string {
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
return `codex-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -32,6 +32,16 @@ import type {
|
||||
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
|
||||
const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000;
|
||||
|
||||
// Keep event correlation and turn ordering assertions exact while accepting a fresh namespace.
|
||||
function turnIdAt(events: AgentStreamEvent[], ordinal: number): string {
|
||||
const started = events.find((event) => event.type === "turn_started");
|
||||
if (!started || started.type !== "turn_started" || typeof started.turnId !== "string") {
|
||||
throw new Error("Expected a turn_started event with a turn ID");
|
||||
}
|
||||
const namespace = started.turnId.slice(0, started.turnId.lastIndexOf("-"));
|
||||
return `${namespace}-${ordinal}`;
|
||||
}
|
||||
|
||||
function tmpCwd(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
|
||||
try {
|
||||
@@ -757,6 +767,19 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps turn identities distinct for the same persisted session", async () => {
|
||||
const first = await createParentSession("ses_persisted");
|
||||
const firstTurn = await first.parent.startTurn("first");
|
||||
await first.parent.close();
|
||||
const resumed = await createParentSession("ses_persisted");
|
||||
try {
|
||||
const nextTurn = await resumed.parent.startTurn("second");
|
||||
expect(nextTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
} finally {
|
||||
await resumed.parent.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ saved: "default", variant: undefined },
|
||||
{ saved: "high", variant: "high" },
|
||||
@@ -1638,10 +1661,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
item: { type: "user_message", clientMessageId: "client-message-1" },
|
||||
});
|
||||
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1833,7 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "error",
|
||||
message: "Provider retry (attempt 1): model does not exist",
|
||||
},
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
await session.close();
|
||||
@@ -2641,11 +2664,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(
|
||||
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
||||
).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["turn_canceled", "opencode-turn-0"],
|
||||
["turn_started", "opencode-turn-1"],
|
||||
["timeline", "opencode-turn-1"],
|
||||
["turn_completed", "opencode-turn-1"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["turn_canceled", turnIdAt(events, 0)],
|
||||
["turn_started", turnIdAt(events, 1)],
|
||||
["timeline", turnIdAt(events, 1)],
|
||||
["turn_completed", turnIdAt(events, 1)],
|
||||
]);
|
||||
} finally {
|
||||
settleRetry.resolve();
|
||||
@@ -2706,7 +2729,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
item: expect.objectContaining({ text: "Wake output streams live." }),
|
||||
}),
|
||||
),
|
||||
@@ -2726,11 +2749,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
|
||||
expect.objectContaining({ type: "turn_completed", turnId: turnIdAt(events, 1) }),
|
||||
),
|
||||
);
|
||||
await expect(session.startTurn("after the wake")).resolves.toEqual({
|
||||
turnId: "opencode-turn-2",
|
||||
turnId: turnIdAt(events, 2),
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2843,13 +2866,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 7,
|
||||
outputTokens: 3,
|
||||
@@ -2858,7 +2881,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
const autonomousCompletion = events.find(
|
||||
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
|
||||
(event) => event.type === "turn_completed" && event.turnId === turnIdAt(events, 1),
|
||||
);
|
||||
expect(autonomousCompletion?.type).toBe("turn_completed");
|
||||
if (autonomousCompletion?.type === "turn_completed") {
|
||||
@@ -2867,7 +2890,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Autonomous wake completed.",
|
||||
@@ -2875,7 +2898,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
|
||||
turnId: "opencode-turn-2",
|
||||
turnId: turnIdAt(events, 2),
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2954,7 +2977,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
});
|
||||
|
||||
openCode.emitEvent({
|
||||
@@ -2962,7 +2985,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
properties: { sessionID: "ses_retried_stop" },
|
||||
});
|
||||
await expect(session.startTurn("replacement")).resolves.toEqual({
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
});
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
} finally {
|
||||
@@ -3058,7 +3081,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-1",
|
||||
turnId: turnIdAt(events, 1),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3767,7 +3790,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
];
|
||||
const replacement = session.startTurn("second");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
|
||||
expect(countEvents(events, "turn_canceled")).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -4009,6 +4032,9 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
return { data: true };
|
||||
};
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
await session.startTurn("first");
|
||||
const interrupt = session.interrupt();
|
||||
@@ -4022,7 +4048,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(openCode.calls.sessionStatus).toHaveLength(1);
|
||||
|
||||
releaseIdle.resolve();
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
@@ -4722,9 +4748,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
"turn_completed",
|
||||
]);
|
||||
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
turnIdAt(events, 0),
|
||||
turnIdAt(events, 0),
|
||||
turnIdAt(events, 0),
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4760,7 +4786,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
});
|
||||
});
|
||||
for (const event of assistantTurnEvents({
|
||||
@@ -4774,9 +4800,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4881,9 +4907,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
]);
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
@@ -4927,9 +4953,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
@@ -5003,7 +5029,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5118,12 +5144,12 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await streamDrained.promise;
|
||||
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
@@ -5174,7 +5200,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect.objectContaining({
|
||||
type: "permission_requested",
|
||||
request: expect.objectContaining({ id: "perm_child_external" }),
|
||||
turnId: "opencode-turn-0",
|
||||
turnId: turnIdAt(events, 0),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3409,6 +3409,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private partTypes = new Map<string, string>();
|
||||
private availableModesCache: AgentMode[] | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly turnNamespace = randomUUID();
|
||||
private nextTurnOrdinal = 0;
|
||||
private turnState: OpenCodeTurnState = { status: "idle" };
|
||||
/**
|
||||
@@ -4886,7 +4887,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
private createTurnId(): string {
|
||||
this.gapRepairRevision += 1;
|
||||
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
||||
return `opencode-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
|
||||
|
||||
@@ -675,7 +675,6 @@ export async function createPaseoDaemon(
|
||||
usageAgents: {
|
||||
hasAgent: (id) => agentManager.getAgent(id) !== null,
|
||||
usageSession: (id) => agentManager.usageSession(id),
|
||||
providerEnvironments: () => providerSnapshotManager.listProviderEnvironments(),
|
||||
},
|
||||
managedSources: new ManagedPluginSources(config.paseoHome, {
|
||||
registries: config.pluginRegistries,
|
||||
|
||||
@@ -165,7 +165,11 @@ test("classifies a removed file as deleted", async () => {
|
||||
});
|
||||
|
||||
await rm(target);
|
||||
await expect.poll(() => events.find((event) => event.path === target)?.type).toBe("delete");
|
||||
// Native recursive delivery can be coalesced before the backend's deletion audit.
|
||||
// Assert eventual classification without imposing Vitest's one-second default.
|
||||
await expect
|
||||
.poll(() => events.find((event) => event.path === target)?.type, { timeout: 10_000 })
|
||||
.toBe("delete");
|
||||
await subscription.unsubscribe();
|
||||
});
|
||||
|
||||
|
||||
@@ -1873,3 +1873,115 @@ test("public project subscriptions request updates and release their producer de
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ closed: false, batch: false },
|
||||
{ closed: true, batch: false },
|
||||
{ closed: false, batch: true },
|
||||
{ closed: true, batch: true },
|
||||
])(
|
||||
"clearing workspace attention reaches independent subscribers (closed=$closed, batch=$batch)",
|
||||
async ({ closed, batch }) => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const clients = Array.from(
|
||||
{ length: 4 },
|
||||
() => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }),
|
||||
);
|
||||
const [actor, observer, unreadObserver, quiet] = clients;
|
||||
const updates: SessionOutboundMessage[] = [];
|
||||
const unreadUpdates: SessionOutboundMessage[] = [];
|
||||
const quietUpdates: SessionOutboundMessage[] = [];
|
||||
const responses: SessionOutboundMessage[] = [];
|
||||
try {
|
||||
await Promise.all(clients.map((client) => client.connect()));
|
||||
const agentIds: string[] = [];
|
||||
for (let index = 0; index < (batch ? 2 : 1); index += 1) {
|
||||
const cwd = path.join(daemon.staticDir, `workspace-${index}`);
|
||||
await mkdir(cwd);
|
||||
const agent = await actor.createAgent({ provider: "codex", cwd });
|
||||
await actor.sendMessage(agent.id, "Reply with OK.");
|
||||
await actor.waitForFinish(agent.id, 5000);
|
||||
await daemon.daemon.agentManager.clearAgentAttention(agent.id);
|
||||
if (closed) await daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
agentIds.push(agent.id);
|
||||
}
|
||||
const snapshot = await observer.fetchAgents({
|
||||
filter: { includeArchived: true },
|
||||
subscribe: {},
|
||||
});
|
||||
const workspaceIds = agentIds.map(
|
||||
(id) => snapshot.entries.find((entry) => entry.agent.id === id)!.agent.workspaceId!,
|
||||
);
|
||||
expect(new Set(workspaceIds).size).toBe(agentIds.length);
|
||||
await unreadObserver.fetchAgents({ filter: { requiresAttention: true }, subscribe: {} });
|
||||
actor.subscribeRawMessages((message) => responses.push(message));
|
||||
observer.subscribeRawMessages((message) => updates.push(message));
|
||||
unreadObserver.subscribeRawMessages((message) => unreadUpdates.push(message));
|
||||
quiet.subscribeRawMessages((message) => quietUpdates.push(message));
|
||||
const attention = (id: string) =>
|
||||
updates
|
||||
.flatMap((message) =>
|
||||
message.type === "agent_update" &&
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.agent.id === id
|
||||
? [message.payload.agent.requiresAttention]
|
||||
: [],
|
||||
)
|
||||
.at(-1);
|
||||
for (const [index, id] of agentIds.entries()) {
|
||||
await actor.markWorkspaceUnread(workspaceIds[index]);
|
||||
await expect.poll(() => attention(id)).toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
unreadUpdates.some(
|
||||
(message) =>
|
||||
message.type === "agent_update" &&
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.agent.id === id,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
updates.length = unreadUpdates.length = quietUpdates.length = 0;
|
||||
await actor.clearWorkspaceAttention(batch ? workspaceIds : workspaceIds[0]);
|
||||
const response = responses.findLast(
|
||||
(message) => message.type === "workspace.clear_attention.response",
|
||||
);
|
||||
expect(response?.payload).toEqual({
|
||||
requestId: expect.any(String),
|
||||
workspaceId: batch ? workspaceIds : workspaceIds[0],
|
||||
clearedAgentIds: agentIds,
|
||||
results: workspaceIds.map((workspaceId, index) => ({
|
||||
workspaceId,
|
||||
clearedAgentIds: [agentIds[index]],
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
const fresh = await actor.fetchAgents();
|
||||
for (const id of agentIds) {
|
||||
expect(fresh.entries.find((entry) => entry.agent.id === id)?.agent.requiresAttention).toBe(
|
||||
false,
|
||||
);
|
||||
expect(Boolean(daemon.daemon.agentManager.getAgent(id))).toBe(!closed);
|
||||
await expect.poll(() => attention(id)).toBe(false);
|
||||
await expect
|
||||
.poll(() =>
|
||||
unreadUpdates.some(
|
||||
(message) =>
|
||||
message.type === "agent_update" &&
|
||||
message.payload.kind === "remove" &&
|
||||
message.payload.agentId === id,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
expect(quietUpdates.filter((message) => message.type === "agent_update")).toEqual([]);
|
||||
} finally {
|
||||
await Promise.all(clients.map((client) => client.close()));
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,8 +6,7 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { ProviderOverride } from "../agent/provider-launch-config.js";
|
||||
import { expect, test } from "vitest";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "./builtin/index.js";
|
||||
@@ -17,55 +16,6 @@ const subprocessDirectory = fileURLToPath(
|
||||
new URL("./test-fixtures/usage-source-directory/", import.meta.url),
|
||||
);
|
||||
|
||||
async function json(file: string, value: unknown) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** The real Claude and Codex readers, compiled as built-ins, with home, env and HTTP injected. */
|
||||
interface UsageLookup {
|
||||
home: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
interface InjectedUsageSourcesOptions {
|
||||
root: string;
|
||||
lookup: UsageLookup;
|
||||
/** Source of an `async function fetchApi(url, init)` the readers call instead of the network. */
|
||||
fetchApi: string;
|
||||
}
|
||||
|
||||
async function injectedUsageSources({
|
||||
root,
|
||||
lookup,
|
||||
fetchApi,
|
||||
}: InjectedUsageSourcesOptions): Promise<BuiltinPluginLoader> {
|
||||
const plugins = path.join(root, "plugins");
|
||||
for (const source of ["codex", "claude"]) {
|
||||
const directory = path.join(plugins, `${source}-usage-source`);
|
||||
await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { inputSchema } from './shared/input.js';
|
||||
import { discover, fetchUsage } from './server/usage.js';
|
||||
const lookup = { home: ${JSON.stringify(lookup.home)}, env: ${JSON.stringify(lookup.env)}, platform: 'linux' };
|
||||
${fetchApi}
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema,
|
||||
discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}),
|
||||
fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
return new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]);
|
||||
}
|
||||
|
||||
test("lists built-in and subprocess usage; validates input and isolates fetch errors", async () => {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.9.2",
|
||||
@@ -207,8 +157,13 @@ export default function contribute(server) {
|
||||
test("built-in sources deduplicate cross-harness accounts and fall back without session wiring", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "usage-cross-harness-"));
|
||||
const home = path.join(root, "home");
|
||||
const plugins = path.join(root, "plugins");
|
||||
const codexHome = path.join(home, ".codex");
|
||||
const env = { CODEX_HOME: codexHome };
|
||||
async function json(file: string, value: unknown) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, JSON.stringify(value));
|
||||
}
|
||||
async function codexToken(token: string) {
|
||||
await json(path.join(codexHome, "auth.json"), {
|
||||
tokens: { access_token: token, account_id: "chatgpt-one" },
|
||||
@@ -250,10 +205,19 @@ test("built-in sources deduplicate cross-harness accounts and fall back without
|
||||
JSON.stringify({ access: "fixture-omp-claude", expires: Date.now() + 60_000 }),
|
||||
);
|
||||
db.close();
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root: root,
|
||||
lookup: { home, env },
|
||||
fetchApi: `async function fetchApi(url, init) {
|
||||
for (const source of ["codex", "claude"]) {
|
||||
const directory = path.join(plugins, `${source}-usage-source`);
|
||||
await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, {
|
||||
recursive: true,
|
||||
});
|
||||
// Inject home/env/HTTP at the source boundary; compile and register the real readers.
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`
|
||||
import { inputSchema } from './shared/input.js';
|
||||
import { discover, fetchUsage } from './server/usage.js';
|
||||
const lookup = { home: ${JSON.stringify(home)}, env: ${JSON.stringify(env)}, platform: 'linux' };
|
||||
async function fetchApi(url, init) {
|
||||
const token = new Headers(init.headers).get('Authorization');
|
||||
if (String(url).endsWith('/profile')) {
|
||||
const second = token === 'Bearer fixture-pi-claude';
|
||||
@@ -262,9 +226,21 @@ test("built-in sources deduplicate cross-harness accounts and fall back without
|
||||
if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 });
|
||||
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
|
||||
}`,
|
||||
}
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema,
|
||||
discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}),
|
||||
fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup),
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]),
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({ pluginsEnabled: false, builtinPlugins });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -315,167 +291,6 @@ test("built-in sources deduplicate cross-harness accounts and fall back without
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// The daemon launches every provider with its own environment, and a source lists nothing for a
|
||||
// provider that sends its agents to another API host. A developer's shell, or the repo's .env,
|
||||
// can already set these.
|
||||
const API_HOST_ENV = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"OPENAI_BASE_URL",
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("#3589: host usage lists each enabled provider's own login without an agent", async () => {
|
||||
for (const name of API_HOST_ENV) vi.stubEnv(name, undefined);
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "usage-provider-env-"));
|
||||
const home = path.join(root, "home");
|
||||
const dir = (name: string) => path.join(root, name);
|
||||
await json(path.join(home, ".claude", ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: "fixture-default" },
|
||||
});
|
||||
await json(path.join(home, ".codex", "auth.json"), {
|
||||
tokens: { access_token: "fixture-codex", account_id: "chatgpt-default" },
|
||||
});
|
||||
// claude-same holds the default account's login in another directory.
|
||||
for (const name of ["work", "same", "off"]) {
|
||||
await json(path.join(dir(`claude-${name}`), ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: `fixture-${name}` },
|
||||
});
|
||||
}
|
||||
// claude-stale's token expired. Nothing says whose account it is, so it is a card of its own.
|
||||
await json(path.join(dir("claude-stale"), ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: "fixture-stale", expiresAt: 1 },
|
||||
});
|
||||
await json(path.join(dir("codex-work"), "auth.json"), {
|
||||
tokens: { access_token: "fixture-codex-work", account_id: "chatgpt-work" },
|
||||
});
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root: root,
|
||||
lookup: { home, env: {} },
|
||||
fetchApi: `async function fetchApi(url, init) {
|
||||
const token = new Headers(init.headers).get('Authorization');
|
||||
if (String(url).endsWith('/profile')) {
|
||||
const uuid = { 'Bearer fixture-work': 'work', 'Bearer fixture-off': 'off' }[token] || 'default';
|
||||
return new Response(JSON.stringify({ account: { uuid }, organization: { uuid: 'org' } }));
|
||||
}
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: 1 } }, five_hour: { utilization: 1 } }));
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins,
|
||||
providerOverrides: {
|
||||
"claude-work": {
|
||||
extends: "claude",
|
||||
label: "Claude (work)",
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-work") },
|
||||
},
|
||||
"claude-same": {
|
||||
extends: "claude",
|
||||
label: "Claude (same)",
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-same") },
|
||||
},
|
||||
"claude-stale": {
|
||||
extends: "claude",
|
||||
label: "Claude (stale)",
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-stale") },
|
||||
},
|
||||
"claude-off": {
|
||||
extends: "claude",
|
||||
label: "Claude (off)",
|
||||
enabled: false,
|
||||
env: { CLAUDE_CONFIG_DIR: dir("claude-off") },
|
||||
},
|
||||
"codex-work": {
|
||||
extends: "codex",
|
||||
label: "Codex (work)",
|
||||
env: { CODEX_HOME: dir("codex-work") },
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const listed = async (status: "available" | "unavailable") =>
|
||||
(await client.listUsageReports()).reports.filter((entry) => entry.report.status === status);
|
||||
const availableIds = async () => (await listed("available")).map((entry) => entry.id).sort();
|
||||
try {
|
||||
await client.connect();
|
||||
expect(await availableIds()).toEqual([
|
||||
"claude:default.org",
|
||||
"claude:work.org",
|
||||
"codex:chatgpt-default",
|
||||
"codex:chatgpt-work",
|
||||
]);
|
||||
// Several cards can read the same failure, so the card says which provider's login it is.
|
||||
expect((await listed("unavailable")).map((entry) => entry.loginErrors)).toEqual([
|
||||
[
|
||||
{
|
||||
harness: "Claude (stale)",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: "1970-01-01T00:00:00.001Z",
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
await client.patchDaemonConfig({ providers: { "claude-work": { enabled: false } } });
|
||||
expect(await availableIds()).toEqual([
|
||||
"claude:default.org",
|
||||
"codex:chatgpt-default",
|
||||
"codex:chatgpt-work",
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("#3589: a provider whose agents talk to another API host has no login in the host list", async () => {
|
||||
vi.stubEnv("ANTHROPIC_BASE_URL", "https://llm-proxy.example");
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "usage-provider-host-"));
|
||||
await json(path.join(root, "claude-work", ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: "fixture-work" },
|
||||
});
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root: root,
|
||||
lookup: { home: path.join(root, "home"), env: {} },
|
||||
fetchApi: `async function fetchApi(url) {
|
||||
if (String(url).endsWith('/profile')) {
|
||||
return new Response(JSON.stringify({ account: { uuid: 'work' }, organization: { uuid: 'org' } }));
|
||||
}
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: 1 } }, five_hour: { utilization: 1 } }));
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins,
|
||||
providerOverrides: {
|
||||
"claude-work": {
|
||||
extends: "claude",
|
||||
label: "Claude (work)",
|
||||
env: { CLAUDE_CONFIG_DIR: path.join(root, "claude-work") },
|
||||
},
|
||||
},
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
expect((await client.listUsageReports()).reports).toEqual([]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a killed discovery subprocess logs kimi once and produces no provider card", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { default: pino } = await import("pino");
|
||||
@@ -595,49 +410,3 @@ dns.lookup = function(host, ...args) {
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("#3589: an empty default store and disabled built-in provider do not hide configured accounts", async () => {
|
||||
for (const name of API_HOST_ENV) vi.stubEnv(name, undefined);
|
||||
await mkdir(path.join(process.cwd(), ".artifacts"), { recursive: true });
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".artifacts", "reported-usage-"));
|
||||
const providerOverrides: Record<string, ProviderOverride> = { claude: { enabled: false } };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const configDir = path.join(root, `account-${i}`);
|
||||
await json(path.join(configDir, ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: `fixture-independent-${i}` },
|
||||
});
|
||||
providerOverrides[`claude-${i}`] = {
|
||||
extends: "claude",
|
||||
label: `Account ${i}`,
|
||||
env: { CLAUDE_CONFIG_DIR: configDir },
|
||||
};
|
||||
}
|
||||
const builtinPlugins = await injectedUsageSources({
|
||||
root,
|
||||
lookup: { home: path.join(root, "empty-home"), env: {} },
|
||||
fetchApi: `async function fetchApi(url, init) {
|
||||
const token = new Headers(init.headers).get('Authorization');
|
||||
const uuid = token.slice('Bearer fixture-independent-'.length);
|
||||
if (String(url).endsWith('/profile')) return new Response(JSON.stringify({account:{uuid},organization:{uuid:'independent'}}));
|
||||
return new Response(JSON.stringify({five_hour:{utilization:7},seven_day:{utilization:13}}));
|
||||
}`,
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins,
|
||||
providerOverrides,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const reports = (await client.listUsageReports()).reports;
|
||||
expect(reports.map((x) => x.id).sort()).toEqual(
|
||||
[0, 1, 2, 3, 4].map((i) => `claude:${i}.independent`),
|
||||
);
|
||||
expect(reports.every((x) => x.report.status === "available")).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
@@ -175,7 +175,7 @@ test("concurrent requests for the same ID share one vendor fetch", async () => {
|
||||
const second = registry.listReports();
|
||||
finish({ status: "available", windows: [] });
|
||||
const [one, two] = await Promise.all([first, second]);
|
||||
expect(one[0]).toEqual(two[0]);
|
||||
expect(one[0]).toBe(two[0]);
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
@@ -624,76 +624,6 @@ test("a hung login records its own timeout and still tries the next login", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("#3589: host listings discover each configured provider's environment after the host's own logins", async () => {
|
||||
let providers = [
|
||||
{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/work" } },
|
||||
{ provider: "claude", label: "Claude (copy)", env: { CLAUDE_CONFIG_DIR: "/default-copy" } },
|
||||
];
|
||||
const calls: unknown[] = [];
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => providers,
|
||||
});
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
if (scope.kind === "global") return [{ key: "default", input: "/default" }];
|
||||
const dir = scope.env.CLAUDE_CONFIG_DIR;
|
||||
return [{ key: dir === "/work" ? "work" : "default", input: dir }];
|
||||
},
|
||||
fetch: async (input) => {
|
||||
calls.push(input);
|
||||
return input === "/default"
|
||||
? { status: "unavailable", problem: { kind: "rejected", status: 401 } }
|
||||
: { status: "available", windows: [] };
|
||||
},
|
||||
});
|
||||
const reports = await registry.listReports();
|
||||
expect(reports.map((entry) => [entry.id, entry.report.status])).toEqual([
|
||||
["claude:default", "available"],
|
||||
["claude:work", "available"],
|
||||
]);
|
||||
expect(calls).toEqual(["/default", "/default-copy", "/work"]);
|
||||
providers = [];
|
||||
expect((await registry.listReports()).map((entry) => entry.id)).toEqual(["claude:default"]);
|
||||
});
|
||||
|
||||
test("#3589: a failed provider login is named after its provider; one the host already has keeps the host's name", async () => {
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => [
|
||||
{ provider: "claude", label: "Claude (home)", env: { CLAUDE_CONFIG_DIR: "/default" } },
|
||||
{ provider: "claude", label: "Claude (a)", env: { CLAUDE_CONFIG_DIR: "/a" } },
|
||||
{ provider: "claude", label: "Claude (b)", env: { CLAUDE_CONFIG_DIR: "/b" } },
|
||||
{ provider: "claude", label: "Claude (b again)", env: { CLAUDE_CONFIG_DIR: "/b" } },
|
||||
],
|
||||
});
|
||||
// An expired login has no account to read, so each directory is its own account.
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
const dir = scope.kind === "global" ? "/default" : scope.env.CLAUDE_CONFIG_DIR;
|
||||
return [{ key: `dir${dir?.replace("/", "-")}`, harness: "Claude", input: dir }];
|
||||
},
|
||||
fetch: async () => ({
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "1970-01-01T00:00:01.000Z", refreshedBy: "claude" },
|
||||
}),
|
||||
});
|
||||
const reports = await registry.listReports();
|
||||
expect(
|
||||
reports.map((entry) => [entry.id, entry.loginErrors?.map((login) => login.harness)]),
|
||||
).toEqual([
|
||||
["claude:dir-default", ["Claude"]],
|
||||
["claude:dir-a", ["Claude (a)"]],
|
||||
["claude:dir-b", ["Claude (b)"]],
|
||||
]);
|
||||
});
|
||||
|
||||
test("retains the last successful poll across failures, cache expiry, and recovery", async () => {
|
||||
let now = 0;
|
||||
let report: unknown = {
|
||||
@@ -898,107 +828,3 @@ test("failed discovery preserves history until discovery confirms account remova
|
||||
present = true;
|
||||
expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined();
|
||||
});
|
||||
|
||||
test("#3589: a provider's failed discovery keeps only that provider's last logins", async () => {
|
||||
let providers = [
|
||||
{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/work" } },
|
||||
];
|
||||
const failing = new Set<string>();
|
||||
const registry = new UsageSourceRegistry(
|
||||
Date.now,
|
||||
300_000,
|
||||
{ warn: () => {} },
|
||||
{
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => providers,
|
||||
},
|
||||
);
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
const dir = scope.kind === "global" ? "/default" : scope.env.CLAUDE_CONFIG_DIR;
|
||||
if (failing.has(dir)) throw new Error("Login store temporarily unavailable");
|
||||
return [{ key: dir.slice(1), input: dir }];
|
||||
},
|
||||
fetch: async () => ({ status: "available", windows: [] }),
|
||||
});
|
||||
const listIds = async () => (await registry.listReports()).map((entry) => entry.id);
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
failing.add("/work");
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
// Renaming a provider keeps its launch environment.
|
||||
providers = [{ provider: "claude", label: "Claude (Work)", env: { CLAUDE_CONFIG_DIR: "/work" } }];
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
// A changed launch environment has no previous result to keep.
|
||||
providers = [
|
||||
{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/other" } },
|
||||
];
|
||||
failing.add("/other");
|
||||
expect(await listIds()).toEqual(["claude:default"]);
|
||||
failing.clear();
|
||||
providers = [{ provider: "claude", label: "Claude (work)", env: { CLAUDE_CONFIG_DIR: "/work" } }];
|
||||
expect(await listIds()).toEqual(["claude:default", "claude:work"]);
|
||||
// The host's own failed discovery keeps the host's logins, not a disabled provider's.
|
||||
providers = [];
|
||||
failing.add("/default");
|
||||
expect(await listIds()).toEqual(["claude:default"]);
|
||||
});
|
||||
|
||||
test("unregistering a source drops the accounts each host scope kept for it", async () => {
|
||||
let fails = false;
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, { warn: () => {} });
|
||||
const claude = source({
|
||||
id: "claude",
|
||||
discover: async () => {
|
||||
if (fails) throw new Error("Login store temporarily unavailable");
|
||||
return [{ key: "default", input: {} }];
|
||||
},
|
||||
});
|
||||
registry.register(claude);
|
||||
expect((await registry.listReports()).map((entry) => entry.id)).toEqual(["claude:default"]);
|
||||
registry.unregister("claude");
|
||||
registry.register(claude);
|
||||
fails = true;
|
||||
expect(await registry.listReports()).toEqual([]);
|
||||
});
|
||||
|
||||
test("#3589: renaming a provider preserves cached usage and recovery history while updating error labels", async () => {
|
||||
let label = "Before";
|
||||
let available = true;
|
||||
let polls = 0;
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: () => false,
|
||||
usageSession: () => null,
|
||||
providerEnvironments: () => [
|
||||
{ provider: "claude", label, env: { CLAUDE_CONFIG_DIR: "/work" } },
|
||||
],
|
||||
});
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) =>
|
||||
scope.kind === "global" ? [] : [{ key: "work", input: { path: "/work" } }],
|
||||
fetch: async () => {
|
||||
polls++;
|
||||
return available
|
||||
? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }
|
||||
: { status: "error", error: "offline" };
|
||||
},
|
||||
});
|
||||
const [success] = await registry.listReports();
|
||||
available = false;
|
||||
label = "After";
|
||||
const [cached] = await registry.listReports();
|
||||
expect(cached?.report).toEqual(success?.report);
|
||||
expect(polls).toBe(1);
|
||||
const [failed] = await registry.listReports({ forceRefresh: true });
|
||||
expect(failed?.lastSuccessful?.report).toEqual(success?.report);
|
||||
expect(failed?.loginErrors?.map((login) => login.harness)).toEqual(["After"]);
|
||||
label = "Renamed again";
|
||||
const [relabeled] = await registry.listReports();
|
||||
expect(polls).toBe(2);
|
||||
expect(relabeled?.lastSuccessful).toEqual(failed?.lastSuccessful);
|
||||
expect(relabeled?.loginErrors?.map((login) => login.harness)).toEqual(["Renamed again"]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentUsageSession, ProviderUsageEnvironment } from "../../agent/agent-sdk-types.js";
|
||||
import type { AgentUsageSession } from "../../agent/agent-sdk-types.js";
|
||||
import type { UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
@@ -33,26 +33,6 @@ interface KnownReport {
|
||||
export interface AgentUsageLookup {
|
||||
hasAgent(id: string): boolean;
|
||||
usageSession(id: string): AgentUsageSession | null;
|
||||
/** Enabled providers that set their own env. Host listings discover these like a session. */
|
||||
providerEnvironments?(): ProviderUsageEnvironment[];
|
||||
}
|
||||
|
||||
interface DiscoverOptions {
|
||||
scope: UsageScope;
|
||||
/** Names the logins found in this scope; defaults to the account's harness or the source label. */
|
||||
harness?: string;
|
||||
/** This scope's last result; a source whose discovery fails keeps its accounts from it. */
|
||||
previous?: ReadonlyMap<string, KnownReport>;
|
||||
}
|
||||
|
||||
interface PresentReportOptions {
|
||||
entry: UsageReportEntry;
|
||||
known: KnownReport;
|
||||
}
|
||||
|
||||
interface MergeReportsOptions {
|
||||
into: Map<string, KnownReport>;
|
||||
reports: Iterable<[string, KnownReport]>;
|
||||
}
|
||||
|
||||
export interface ListUsageReportsOptions {
|
||||
@@ -74,7 +54,6 @@ export class UsageSourceRegistry {
|
||||
private readonly sources = new Map<string, UsageSource>();
|
||||
private readonly known = new Map<string, KnownReport>();
|
||||
private defaults = new Map<string, KnownReport>();
|
||||
private defaultsByScope = new Map<string, Map<string, KnownReport>>();
|
||||
private readonly byAgent = new Map<string, AgentReports>();
|
||||
private readonly cache = new Map<string, { at: number; entry: UsageReportEntry }>();
|
||||
private readonly successful = new Map<string, NonNullable<UsageReportEntry["lastSuccessful"]>>();
|
||||
@@ -96,8 +75,6 @@ export class UsageSourceRegistry {
|
||||
unregister(id: string): void {
|
||||
this.sources.delete(id);
|
||||
for (const key of this.defaults.keys()) if (key.startsWith(`${id}:`)) this.defaults.delete(key);
|
||||
for (const reports of this.defaultsByScope.values())
|
||||
for (const key of reports.keys()) if (key.startsWith(`${id}:`)) reports.delete(key);
|
||||
// Re-discover on the next session query when a source is replaced.
|
||||
for (const [agentId, mapping] of this.byAgent) {
|
||||
if ([...mapping.reports.values()].some((report) => report.source.id === id))
|
||||
@@ -122,37 +99,7 @@ export class UsageSourceRegistry {
|
||||
} else if (options.reportIds !== undefined) {
|
||||
ids = options.reportIds;
|
||||
} else {
|
||||
// A provider's own env can name a login the daemon's env doesn't, so list it before any of
|
||||
// its agents runs. The host's own logins come first in each account's fallback order.
|
||||
// A login is named after the provider it was found through: a login that can't be read
|
||||
// (an expired token has no account) is otherwise one more card that looks like the rest.
|
||||
const scopes: DiscoverOptions[] = [
|
||||
{ scope: { kind: "global" } },
|
||||
...(this.agents.providerEnvironments?.() ?? []).map(({ provider, label, env }) => ({
|
||||
scope: { kind: "session" as const, provider, env },
|
||||
harness: label,
|
||||
})),
|
||||
];
|
||||
// Each launch environment is discovered once, under the first provider that uses it, and
|
||||
// keeps its own last result.
|
||||
const unique = new Map<string, DiscoverOptions>();
|
||||
for (const entry of scopes) {
|
||||
const key = canonicalJson(entry.scope);
|
||||
if (!unique.has(key)) unique.set(key, entry);
|
||||
}
|
||||
const previous = this.defaultsByScope;
|
||||
this.defaultsByScope = new Map(
|
||||
await Promise.all(
|
||||
[...unique].map(
|
||||
async ([key, entry]) =>
|
||||
[key, await this.discover({ ...entry, previous: previous.get(key) })] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
const defaults = new Map<string, KnownReport>();
|
||||
for (const scoped of this.defaultsByScope.values())
|
||||
mergeReports({ into: defaults, reports: scoped });
|
||||
this.defaults = defaults;
|
||||
this.defaults = await this.discover({ kind: "global" }, this.defaults);
|
||||
this.mergeKnown();
|
||||
ids = [...this.known.keys()];
|
||||
}
|
||||
@@ -162,9 +109,8 @@ export class UsageSourceRegistry {
|
||||
if (!known) return [];
|
||||
return [
|
||||
this.fetchId(id, known, options.forceRefresh).then((entry) => {
|
||||
const report = presentReport({ entry, known });
|
||||
options.onReport?.(report);
|
||||
return report;
|
||||
options.onReport?.(entry);
|
||||
return entry;
|
||||
}),
|
||||
];
|
||||
}),
|
||||
@@ -193,12 +139,10 @@ export class UsageSourceRegistry {
|
||||
previous.model === session.model;
|
||||
if (sameScope) return [...previous.reports.keys()];
|
||||
const reports = await this.discover({
|
||||
scope: {
|
||||
kind: "session",
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
env: session.env,
|
||||
},
|
||||
kind: "session",
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
env: session.env,
|
||||
});
|
||||
// A query that finishes after a resume must not publish the previous launch's mapping.
|
||||
if (this.agents.usageSession(agentId)?.sessionKey !== session.sessionKey) return [];
|
||||
@@ -218,12 +162,18 @@ export class UsageSourceRegistry {
|
||||
this.defaults,
|
||||
...[...this.byAgent.values()].map((agent) => agent.reports),
|
||||
]) {
|
||||
mergeReports({
|
||||
into: this.known,
|
||||
reports: [...reports].filter(
|
||||
([, report]) => this.sources.get(report.source.id) === report.source,
|
||||
),
|
||||
});
|
||||
for (const [id, report] of reports) {
|
||||
if (this.sources.get(report.source.id) !== report.source) continue;
|
||||
const known = this.known.get(id);
|
||||
if (!known) {
|
||||
this.known.set(id, { ...report, logins: [...report.logins] });
|
||||
continue;
|
||||
}
|
||||
for (const login of report.logins) {
|
||||
if (!known.logins.some((existing) => loginKey(existing) === loginKey(login)))
|
||||
known.logins.push(login);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch freshness expires independently of successful snapshots. Retain only live identities.
|
||||
const active = this.activeCacheKeys();
|
||||
@@ -241,11 +191,10 @@ export class UsageSourceRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
private async discover({
|
||||
scope,
|
||||
harness,
|
||||
previous = new Map(),
|
||||
}: DiscoverOptions): Promise<Map<string, KnownReport>> {
|
||||
private async discover(
|
||||
scope: UsageScope,
|
||||
previous: ReadonlyMap<string, KnownReport> = new Map(),
|
||||
): Promise<Map<string, KnownReport>> {
|
||||
const discovered = await Promise.all(
|
||||
[...this.sources.values()].map(async (source) => {
|
||||
const reports = new Map<string, KnownReport>();
|
||||
@@ -264,18 +213,15 @@ export class UsageSourceRegistry {
|
||||
const id = `${source.id}:${account.key}`;
|
||||
const known = reports.get(id);
|
||||
// COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0.
|
||||
const login = {
|
||||
input: account.input,
|
||||
harness: harness ?? account.harness ?? source.label,
|
||||
};
|
||||
const login = { input: account.input, harness: account.harness ?? source.label };
|
||||
if (known) {
|
||||
if (!known.logins.some((existing) => sameLogin(existing, login)))
|
||||
if (!known.logins.some((existing) => loginKey(existing) === loginKey(login)))
|
||||
known.logins.push(login);
|
||||
} else reports.set(id, { source, logins: [login], label: account.label });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed");
|
||||
// A failed store read does not confirm removal. Agent session discovery has no previous
|
||||
// A failed store read does not confirm removal. Session discovery has no previous
|
||||
// mapping because a resumed agent may use a different launch environment.
|
||||
for (const [id, report] of previous) {
|
||||
if (report.source === source) reports.set(id, report);
|
||||
@@ -438,55 +384,17 @@ function legacyError(report: UsageReport, now: number): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An account found in several scopes is one report; its logins keep discovery order, once each.
|
||||
function mergeReports({ into, reports }: MergeReportsOptions): void {
|
||||
for (const [id, report] of reports) {
|
||||
const known = into.get(id);
|
||||
if (!known) {
|
||||
into.set(id, { ...report, logins: [...report.logins] });
|
||||
continue;
|
||||
}
|
||||
for (const login of report.logins) {
|
||||
if (!known.logins.some((existing) => sameLogin(existing, login))) known.logins.push(login);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes.
|
||||
// The harness only names a login, so the scope that finds it first decides what it is called.
|
||||
function sameLogin(a: Login, b: Login): boolean {
|
||||
return canonicalJson(a.input) === canonicalJson(b.input);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, nested) =>
|
||||
nested !== null && typeof nested === "object" && !Array.isArray(nested)
|
||||
? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: nested,
|
||||
function loginKey(login: Login): string {
|
||||
return JSON.stringify(login, (_key, value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: value,
|
||||
);
|
||||
}
|
||||
|
||||
function cacheIdentity(id: string, known: KnownReport): string {
|
||||
return `${id}:${createHash("sha256")
|
||||
.update(canonicalJson(known.logins.map((login) => login.input)))
|
||||
.update(JSON.stringify([known.label, known.logins.map(loginKey)]))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Labels describe the current discovery, while cached polls and history belong to login inputs.
|
||||
function presentReport({ entry, known }: PresentReportOptions): UsageReportEntry {
|
||||
return {
|
||||
...entry,
|
||||
sourceLabel: known.source.label,
|
||||
icon: known.source.icon,
|
||||
account: { label: known.label },
|
||||
...(entry.loginErrors
|
||||
? {
|
||||
// Errors are emitted only when every ordered login failed, so positions match inputs.
|
||||
loginErrors: entry.loginErrors.map((error, index) => ({
|
||||
...error,
|
||||
harness: known.logins[index]!.harness,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8076,24 +8076,7 @@ export class Session {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
updatedAt: new Date().toISOString(),
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
await this.agentStorage.upsert(nextRecord);
|
||||
const agent = this.buildStoredAgentPayload(nextRecord);
|
||||
const project = await this.buildProjectPlacementForWorkspace(workspace);
|
||||
this.emit({
|
||||
type: "agent_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
agent,
|
||||
project,
|
||||
},
|
||||
});
|
||||
await this.agentManager.clearAgentAttention(agentId);
|
||||
clearedAgentIds.push(agentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1847,81 +1847,6 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace clear attention clears stored-only agents and responds", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: REPO_CWD,
|
||||
projectId: REPO_CWD,
|
||||
cwd: REPO_CWD,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
});
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: REPO_CWD,
|
||||
rootPath: REPO_CWD,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
});
|
||||
let storedRecord = makeStoredAgent({
|
||||
id: "stored-agent-1",
|
||||
cwd: REPO_CWD,
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
});
|
||||
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
||||
|
||||
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => [
|
||||
workspace,
|
||||
];
|
||||
session.workspaceRegistry.get = async (id: string) =>
|
||||
id === workspace.workspaceId ? workspace : null;
|
||||
session.projectRegistry.list = async () => [project];
|
||||
session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null);
|
||||
session.agentStorage.get = async (agentId: string) =>
|
||||
agentId === storedRecord.id ? storedRecord : null;
|
||||
session.agentStorage.upsert = async (record: unknown) => {
|
||||
storedRecord = record as StoredAgentRecord;
|
||||
};
|
||||
session.listAgentPayloads = async () => [
|
||||
makeAgent({
|
||||
id: storedRecord.id,
|
||||
cwd: storedRecord.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
status: "closed",
|
||||
updatedAt: storedRecord.updatedAt,
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
}),
|
||||
];
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.clear_attention.request",
|
||||
workspaceId: workspace.workspaceId,
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(storedRecord.requiresAttention).toBe(false);
|
||||
expect(storedRecord.attentionReason).toBeNull();
|
||||
expect(storedRecord.attentionTimestamp).toBeNull();
|
||||
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
|
||||
requestId: "req-1",
|
||||
workspaceId: workspace.workspaceId,
|
||||
clearedAgentIds: [storedRecord.id],
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
const agentUpdate = findByType(emitted, "agent_update");
|
||||
expect(agentUpdate.payload.kind).toBe("upsert");
|
||||
if (agentUpdate.payload.kind === "upsert") {
|
||||
expect(agentUpdate.payload.agent.requiresAttention).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace clear attention responds with an error instead of timing out", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
||||
@@ -2200,110 +2125,6 @@ test("workspace mark unread rejects workspaces without a read root agent", async
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace clear attention can clear multiple workspaces in one request", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const workspaces = [
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-a",
|
||||
projectId: "/tmp/repo-a",
|
||||
cwd: "/tmp/repo-a",
|
||||
kind: "directory",
|
||||
displayName: "repo-a",
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
}),
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-repo-b",
|
||||
projectId: "/tmp/repo-b",
|
||||
cwd: "/tmp/repo-b",
|
||||
kind: "directory",
|
||||
displayName: "repo-b",
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
const projects = workspaces.map((workspace) =>
|
||||
createPersistedProjectRecord({
|
||||
projectId: workspace.projectId,
|
||||
rootPath: workspace.cwd,
|
||||
kind: "non_git",
|
||||
displayName: workspace.displayName,
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const storedRecords = new Map(
|
||||
workspaces.map((workspace, index) => [
|
||||
`stored-agent-${index + 1}`,
|
||||
makeStoredAgent({
|
||||
id: `stored-agent-${index + 1}`,
|
||||
cwd: workspace.cwd,
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
requiresAttention: true,
|
||||
attentionReason: "finished",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
|
||||
|
||||
session.workspaceRegistry.listAll = session.workspaceRegistry.listActive = async () => workspaces;
|
||||
session.workspaceRegistry.get = async (id: string) =>
|
||||
workspaces.find((workspace) => workspace.workspaceId === id) ?? null;
|
||||
session.projectRegistry.list = async () => projects;
|
||||
session.projectRegistry.get = async (id: string) =>
|
||||
projects.find((project) => project.projectId === id) ?? null;
|
||||
session.agentStorage.get = async (agentId: string) => storedRecords.get(agentId) ?? null;
|
||||
session.agentStorage.upsert = async (record: unknown) => {
|
||||
const storedRecord = record as StoredAgentRecord;
|
||||
storedRecords.set(storedRecord.id, storedRecord);
|
||||
};
|
||||
session.listAgentPayloads = async () =>
|
||||
Array.from(storedRecords.values()).map((record) => {
|
||||
const owner = workspaces.find((workspace) => workspace.cwd === record.cwd);
|
||||
return makeAgent({
|
||||
id: record.id,
|
||||
cwd: record.cwd,
|
||||
...(owner ? { workspaceId: owner.workspaceId } : {}),
|
||||
status: "closed",
|
||||
updatedAt: record.updatedAt,
|
||||
requiresAttention: record.requiresAttention,
|
||||
attentionReason: record.attentionReason,
|
||||
});
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.clear_attention.request",
|
||||
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(Array.from(storedRecords.values()).map((record) => record.requiresAttention)).toEqual([
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(findByType(emitted, "workspace.clear_attention.response").payload).toMatchObject({
|
||||
requestId: "req-1",
|
||||
workspaceId: workspaces.map((workspace) => workspace.workspaceId),
|
||||
clearedAgentIds: ["stored-agent-1", "stored-agent-2"],
|
||||
results: [
|
||||
{
|
||||
workspaceId: workspaces[0].workspaceId,
|
||||
clearedAgentIds: ["stored-agent-1"],
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
workspaceId: workspaces[1].workspaceId,
|
||||
clearedAgentIds: ["stored-agent-2"],
|
||||
success: true,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("close_items_request archives agents and kills terminals in one batch", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const archivedAt = "2026-04-01T00:00:00.000Z";
|
||||
|
||||
@@ -19,15 +19,21 @@
|
||||
# 7. Header and footer revealed with the rows (RevealedLayoutView, ListComponent).
|
||||
# 8. Top-padding shrink hold on iOS only (setPaddingTop): Android paints the held height as blank
|
||||
# space at the end.
|
||||
# 9. Rows cut off at the top count as in view (getIdsInVisibleRange): they follow the fully
|
||||
# visible rows in idsInView, so a disclosure hold on a row whose top is above the viewport
|
||||
# still anchors on that row instead of the row below it.
|
||||
# 9. Rows cut off at the top count as in view, and so does the row just past the end of the
|
||||
# viewport (getIdsInVisibleRange): they follow the fully visible rows in idsInView, so a
|
||||
# disclosure hold on a row whose top is above the viewport still anchors on that row instead
|
||||
# of the row below it, and a hold on the row below a bottom-edge trigger anchors even when
|
||||
# that row starts below the viewport.
|
||||
# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a
|
||||
# data change already does, so a disclosure that only grows (a todo card) holds the cut-off
|
||||
# row instead of the first row in view.
|
||||
# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab
|
||||
# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row
|
||||
# whose frame was unchanged when the tab showed again got no layout event to correct it.
|
||||
# 12. No Android clamp prediction for a size change (prepareMVCP): upstream measures the adjustment
|
||||
# of a shrink past the end from the new end, assuming the scroll view clamped first. The app's
|
||||
# scroll view clamps after the adjustment on both platforms (modules/paseo-scroll), so a
|
||||
# bottom-edge disclosure collapsing near the end landed at the end instead of in place.
|
||||
#
|
||||
# Upgrading: drop each hunk whose fix the new version ships, regenerate with
|
||||
# `npx patch-package @legendapp/list`, and copy this header back on top.
|
||||
@@ -48,7 +54,7 @@ index 983e937..970ba51 100644
|
||||
* Returns a stable item type used for pooling and size estimation.
|
||||
*/
|
||||
diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js
|
||||
index eeb3fb0..5fe940d 100644
|
||||
index eeb3fb0..5ebd8e9 100644
|
||||
--- a/node_modules/@legendapp/list/react-native.js
|
||||
+++ b/node_modules/@legendapp/list/react-native.js
|
||||
@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
|
||||
@@ -135,7 +141,28 @@ index eeb3fb0..5fe940d 100644
|
||||
}
|
||||
if (dataChanged && idsInView.length > 0 && state.didContainersLayout) {
|
||||
for (let i = 0; i < idsInView.length; i++) {
|
||||
@@ -2371,7 +2394,10 @@ function finishScrollTo(ctx) {
|
||||
@@ -1874,15 +1897,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
|
||||
now,
|
||||
positionDiff
|
||||
});
|
||||
- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) {
|
||||
- const maxScroll = getScrollRangeEnd(ctx);
|
||||
- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) {
|
||||
- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll));
|
||||
- state.scroll = maxScroll;
|
||||
- state.scrollPending = maxScroll;
|
||||
- positionDiff = targetScroll - maxScroll;
|
||||
- }
|
||||
- }
|
||||
+ // Paseo patch: no Android clamp prediction. Upstream assumes the scroll view clamped to the new
|
||||
+ // end before React Native adds the anchor's movement, and measures the adjustment from that
|
||||
+ // end. The app's scroll view defers the clamp until after the adjustment, as iOS does
|
||||
+ // (modules/paseo-scroll), so an adjustment measured from the end moved a shrinking row's
|
||||
+ // anchor back to the end instead of keeping it in place.
|
||||
if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) {
|
||||
state.pendingNativeMVCPAdjust = {
|
||||
amount: positionDiff,
|
||||
@@ -2371,7 +2390,10 @@ function finishScrollTo(ctx) {
|
||||
};
|
||||
if (scrollingTo.isInitialScroll || state.initialScroll) {
|
||||
const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset";
|
||||
@@ -147,7 +174,7 @@ index eeb3fb0..5fe940d 100644
|
||||
finishInitialScroll(ctx, {
|
||||
onFinished: complete,
|
||||
preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget,
|
||||
@@ -2515,11 +2541,12 @@ function checkFinishedScrollFallback(ctx) {
|
||||
@@ -2515,11 +2537,12 @@ function checkFinishedScrollFallback(ctx) {
|
||||
const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0;
|
||||
const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON;
|
||||
initialScrollCompletion.markSilentInitialScrollRetry(state);
|
||||
@@ -164,7 +191,7 @@ index eeb3fb0..5fe940d 100644
|
||||
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
|
||||
} else if (shouldRetryUnalignedEndScroll) {
|
||||
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
|
||||
@@ -3378,7 +3405,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
@@ -3378,7 +3401,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
var _a3;
|
||||
const state = ctx.state;
|
||||
clearBootstrapInitialScrollSession(state);
|
||||
@@ -174,7 +201,7 @@ index eeb3fb0..5fe940d 100644
|
||||
finishInitialScroll(ctx, {
|
||||
preserveTarget: shouldPreserveResizeTarget,
|
||||
recalculateItems: true,
|
||||
@@ -4569,6 +4597,17 @@ function getIdsInVisibleRange(state, range) {
|
||||
@@ -4569,6 +4593,24 @@ function getIdsInVisibleRange(state, range) {
|
||||
const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
|
||||
idsInView.push(id);
|
||||
}
|
||||
@@ -188,11 +215,18 @@ index eeb3fb0..5fe940d 100644
|
||||
+ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
|
||||
+ idsInView.push(id);
|
||||
+ }
|
||||
+ }
|
||||
+ // Paseo patch: the row that starts at or below the end of the viewport goes last. A disclosure
|
||||
+ // whose trigger sits on the bottom edge of its row holds the row below it, whose top is that
|
||||
+ // edge, and that row is below the viewport whenever the held row's bottom is.
|
||||
+ const belowIndex = range.endNoBuffer + 1;
|
||||
+ if (belowIndex < state.props.data.length) {
|
||||
+ idsInView.push((_b = state.idCache[belowIndex]) != null ? _b : getId(state, belowIndex));
|
||||
+ }
|
||||
}
|
||||
return idsInView;
|
||||
}
|
||||
@@ -5176,8 +5215,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
@@ -5176,8 +5218,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
function isWithinEpsilon(delta) {
|
||||
return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON;
|
||||
}
|
||||
@@ -208,7 +242,7 @@ index eeb3fb0..5fe940d 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
@@ -5291,6 +5338,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
@@ -220,7 +254,7 @@ index eeb3fb0..5fe940d 100644
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5414,7 +5466,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -229,7 +263,7 @@ index eeb3fb0..5fe940d 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6283,6 +6335,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -240,7 +274,7 @@ index eeb3fb0..5fe940d 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6290,7 +6346,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -249,7 +283,7 @@ index eeb3fb0..5fe940d 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6513,6 +6569,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -264,7 +298,7 @@ index eeb3fb0..5fe940d 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6651,7 +6715,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
|
||||
@@ -273,7 +307,7 @@ index eeb3fb0..5fe940d 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
|
||||
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6665,7 +6729,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -282,7 +316,7 @@ index eeb3fb0..5fe940d 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7886,7 +7950,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -294,7 +328,7 @@ index eeb3fb0..5fe940d 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7989,6 +8056,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -302,7 +336,7 @@ index eeb3fb0..5fe940d 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8282,6 +8350,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
@@ -311,7 +345,7 @@ index eeb3fb0..5fe940d 100644
|
||||
getItemType: useWrapIfItem(getItemType),
|
||||
hasExternalScroll,
|
||||
diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs
|
||||
index fe5f415..7f1b9dd 100644
|
||||
index fe5f415..9788ce6 100644
|
||||
--- a/node_modules/@legendapp/list/react-native.mjs
|
||||
+++ b/node_modules/@legendapp/list/react-native.mjs
|
||||
@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
|
||||
@@ -398,7 +432,28 @@ index fe5f415..7f1b9dd 100644
|
||||
}
|
||||
if (dataChanged && idsInView.length > 0 && state.didContainersLayout) {
|
||||
for (let i = 0; i < idsInView.length; i++) {
|
||||
@@ -2350,7 +2373,10 @@ function finishScrollTo(ctx) {
|
||||
@@ -1853,15 +1876,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
|
||||
now,
|
||||
positionDiff
|
||||
});
|
||||
- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) {
|
||||
- const maxScroll = getScrollRangeEnd(ctx);
|
||||
- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) {
|
||||
- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll));
|
||||
- state.scroll = maxScroll;
|
||||
- state.scrollPending = maxScroll;
|
||||
- positionDiff = targetScroll - maxScroll;
|
||||
- }
|
||||
- }
|
||||
+ // Paseo patch: no Android clamp prediction. Upstream assumes the scroll view clamped to the new
|
||||
+ // end before React Native adds the anchor's movement, and measures the adjustment from that
|
||||
+ // end. The app's scroll view defers the clamp until after the adjustment, as iOS does
|
||||
+ // (modules/paseo-scroll), so an adjustment measured from the end moved a shrinking row's
|
||||
+ // anchor back to the end instead of keeping it in place.
|
||||
if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) {
|
||||
state.pendingNativeMVCPAdjust = {
|
||||
amount: positionDiff,
|
||||
@@ -2350,7 +2369,10 @@ function finishScrollTo(ctx) {
|
||||
};
|
||||
if (scrollingTo.isInitialScroll || state.initialScroll) {
|
||||
const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset";
|
||||
@@ -410,7 +465,7 @@ index fe5f415..7f1b9dd 100644
|
||||
finishInitialScroll(ctx, {
|
||||
onFinished: complete,
|
||||
preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget,
|
||||
@@ -2494,11 +2520,12 @@ function checkFinishedScrollFallback(ctx) {
|
||||
@@ -2494,11 +2516,12 @@ function checkFinishedScrollFallback(ctx) {
|
||||
const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0;
|
||||
const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON;
|
||||
initialScrollCompletion.markSilentInitialScrollRetry(state);
|
||||
@@ -427,7 +482,7 @@ index fe5f415..7f1b9dd 100644
|
||||
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
|
||||
} else if (shouldRetryUnalignedEndScroll) {
|
||||
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
|
||||
@@ -3357,7 +3384,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
@@ -3357,7 +3380,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
var _a3;
|
||||
const state = ctx.state;
|
||||
clearBootstrapInitialScrollSession(state);
|
||||
@@ -437,7 +492,7 @@ index fe5f415..7f1b9dd 100644
|
||||
finishInitialScroll(ctx, {
|
||||
preserveTarget: shouldPreserveResizeTarget,
|
||||
recalculateItems: true,
|
||||
@@ -4548,6 +4576,17 @@ function getIdsInVisibleRange(state, range) {
|
||||
@@ -4548,6 +4572,24 @@ function getIdsInVisibleRange(state, range) {
|
||||
const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
|
||||
idsInView.push(id);
|
||||
}
|
||||
@@ -451,11 +506,18 @@ index fe5f415..7f1b9dd 100644
|
||||
+ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
|
||||
+ idsInView.push(id);
|
||||
+ }
|
||||
+ }
|
||||
+ // Paseo patch: the row that starts at or below the end of the viewport goes last. A disclosure
|
||||
+ // whose trigger sits on the bottom edge of its row holds the row below it, whose top is that
|
||||
+ // edge, and that row is below the viewport whenever the held row's bottom is.
|
||||
+ const belowIndex = range.endNoBuffer + 1;
|
||||
+ if (belowIndex < state.props.data.length) {
|
||||
+ idsInView.push((_b = state.idCache[belowIndex]) != null ? _b : getId(state, belowIndex));
|
||||
+ }
|
||||
}
|
||||
return idsInView;
|
||||
}
|
||||
@@ -5155,8 +5194,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
@@ -5155,8 +5197,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
function isWithinEpsilon(delta) {
|
||||
return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON;
|
||||
}
|
||||
@@ -471,7 +533,7 @@ index fe5f415..7f1b9dd 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
@@ -5270,6 +5317,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
@@ -483,7 +545,7 @@ index fe5f415..7f1b9dd 100644
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5393,7 +5445,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -492,7 +554,7 @@ index fe5f415..7f1b9dd 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6262,6 +6314,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -503,7 +565,7 @@ index fe5f415..7f1b9dd 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6269,7 +6325,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -512,7 +574,7 @@ index fe5f415..7f1b9dd 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6492,6 +6548,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -527,7 +589,7 @@ index fe5f415..7f1b9dd 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6630,7 +6694,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
|
||||
@@ -536,7 +598,7 @@ index fe5f415..7f1b9dd 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
|
||||
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6644,7 +6708,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -545,7 +607,7 @@ index fe5f415..7f1b9dd 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7865,7 +7929,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -557,7 +619,7 @@ index fe5f415..7f1b9dd 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7968,6 +8035,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -565,7 +627,7 @@ index fe5f415..7f1b9dd 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8261,6 +8329,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
|
||||
@@ -94,7 +94,7 @@ type PluginTurnOutcome =
|
||||
|
||||
| Field | Shape / meaning |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
|
||||
| `turnId` | Provider-reported `string` or `null`; unique across resumed sessions |
|
||||
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
|
||||
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
|
||||
| `resolution` | SDK `AgentPermissionResponse` |
|
||||
|
||||
@@ -92,7 +92,7 @@ type PluginTurnOutcome =
|
||||
|
||||
| 字段 | 结构 / 含义 |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 |
|
||||
| `turnId` | Provider 报告的 `string` 或 `null`;在会话恢复后仍保持唯一 |
|
||||
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
|
||||
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
|
||||
| `resolution` | SDK 的 `AgentPermissionResponse` |
|
||||
|
||||
@@ -492,13 +492,6 @@ Resolve the session account from that session's launch environment. Return `[]`
|
||||
Never scan default stores from session discovery or scan agents from global discovery. Discovery is a query, with no agent
|
||||
lifecycle hooks. Closed agents have no session scope until resumed.
|
||||
|
||||
The host's Usage list also runs session discovery once for each enabled provider that sets its own
|
||||
`env`, so a custom provider's login shows before any of its agents runs. That scope carries the
|
||||
provider's launch environment and no `model`. Its `provider` is the provider it extends, or its own
|
||||
ID when it extends none. The daemon sets the `harness` of each login found this way to the
|
||||
provider's label, so a failed login names its provider. A login that the host's own discovery found
|
||||
first keeps its `harness`.
|
||||
|
||||
Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each
|
||||
input against your schema before calling `fetch()`. The same key in any scope is the same report;
|
||||
fetches share a five-minute cache only when their ordered login inputs match. Each login fetch
|
||||
|
||||
Reference in New Issue
Block a user