mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bae441e45 | ||
|
|
31b730dc48 | ||
|
|
1228a6c4a1 | ||
|
|
01a40957ea |
@@ -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, 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.
|
||||
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.
|
||||
|
||||
## The contract
|
||||
|
||||
@@ -14,20 +14,12 @@ const handleToggle = () => hold(() => setExpanded((value) => !value));
|
||||
```
|
||||
|
||||
`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's
|
||||
`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.
|
||||
`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.
|
||||
|
||||
Following output:
|
||||
|
||||
- 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.
|
||||
- A hold always detaches the bottom anchor controller (`holdForDisclosure`).
|
||||
- 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
|
||||
@@ -41,16 +33,13 @@ ends at its end and the trigger moves by the difference.
|
||||
|
||||
## Web
|
||||
|
||||
`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.
|
||||
`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`).
|
||||
|
||||
## Native
|
||||
|
||||
On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do
|
||||
not hold. Summary lines, todo cards, plan cards and show more expand inline.
|
||||
not hold. Summary lines and todo cards 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
|
||||
@@ -62,14 +51,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`), 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. |
|
||||
| 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. |
|
||||
|
||||
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
|
||||
@@ -89,38 +78,15 @@ jumps by the keyboard height after the keyboard settles.
|
||||
|
||||
### The hold
|
||||
|
||||
`holdRow(rowId, apply, triggerEdge)` runs in the toggle's event:
|
||||
`holdRow(rowId, apply)` runs in the toggle's event:
|
||||
|
||||
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
|
||||
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
|
||||
`shouldRestorePosition` says. A row cut off at the top would then let the row below it hold
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -164,7 +130,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 twelve changes for the timeline. Its header lists
|
||||
`patches/@legendapp+list+3.7.1.patch` carries ten 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.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -70,8 +69,6 @@ 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();
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
expectHostWorkspaceSettledActive,
|
||||
holdNextArchiveRequest,
|
||||
outlastUndoWindow,
|
||||
pressArchiveShortcut,
|
||||
pressUndoShortcut,
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
undoButton,
|
||||
@@ -84,7 +87,90 @@ test.describe("Workspace archive undo", () => {
|
||||
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
|
||||
rowKebab(page, worktree.workspaceId).boundingBox(),
|
||||
]);
|
||||
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
|
||||
if (!buttonBox || !kebabBox) throw new Error("Row actions are not laid out");
|
||||
expect(kebabBox.x - (buttonBox.x + buttonBox.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the archived toast sits over the sidebar above its footer, and only Undo presses", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-sidebar");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
|
||||
const [toastBox, footerBox] = await Promise.all([
|
||||
page.getByTestId("workspace-archived-toast").boundingBox(),
|
||||
page.getByTestId("sidebar-footer").boundingBox(),
|
||||
]);
|
||||
if (!toastBox || !footerBox) throw new Error("Toast or footer is not laid out");
|
||||
expect(toastBox.x).toBeGreaterThanOrEqual(footerBox.x);
|
||||
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(footerBox.x + footerBox.width);
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(footerBox.y);
|
||||
expect(footerBox.y - (toastBox.y + toastBox.height)).toBeLessThanOrEqual(16);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto");
|
||||
await expect(undoButton(page)).toHaveCSS("cursor", "pointer");
|
||||
|
||||
const [labelBox, shortcutBox] = await Promise.all([
|
||||
undoButton(page).getByText("Undo", { exact: true }).boundingBox(),
|
||||
undoButton(page).getByText(/Z$/).boundingBox(),
|
||||
]);
|
||||
if (!labelBox || !shortcutBox) throw new Error("Undo button is not laid out");
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
const archive = await holdNextArchiveRequest(page);
|
||||
const worktree = await seedWorktree(page, "archive-late-failure");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await archive.waitForHeld();
|
||||
// Crossing the compact breakpoint swaps the sidebar, unmounting the toast's host.
|
||||
await page.setViewportSize({ width: 400, height: 720 });
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
|
||||
archive.releaseForUnknownWorkspace("workspace-the-daemon-never-had");
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(
|
||||
"Workspace not found: workspace-the-daemon-never-had",
|
||||
);
|
||||
});
|
||||
|
||||
test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-narrow");
|
||||
await resizeSidebarToMinimum(page);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
|
||||
const [toastBox, undoBox] = await Promise.all([
|
||||
page.getByTestId("workspace-archived-toast").boundingBox(),
|
||||
undoButton(page).boundingBox(),
|
||||
]);
|
||||
if (!toastBox || !undoBox) throw new Error("Toast is not laid out");
|
||||
expect(undoBox.x + undoBox.width).toBeLessThanOrEqual(toastBox.x + toastBox.width);
|
||||
expect(undoBox.y + undoBox.height).toBeLessThanOrEqual(toastBox.y + toastBox.height);
|
||||
});
|
||||
|
||||
test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-collapsed");
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await page.keyboard.press("ControlOrMeta+b");
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).not.toBeInViewport();
|
||||
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await pressArchiveShortcut(page);
|
||||
|
||||
const toast = page.getByTestId("workspace-archived-toast");
|
||||
await expect(toast).toBeInViewport();
|
||||
const toastBox = await toast.boundingBox();
|
||||
if (!toastBox) throw new Error("Toast is not laid out");
|
||||
expect(toastBox.y).toBeLessThan(page.viewportSize()!.height / 2);
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
|
||||
|
||||
@@ -4,12 +4,15 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
|
||||
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
|
||||
const ARCHIVE_SHORTCUT =
|
||||
process.platform === "darwin" ? "Meta+Shift+Backspace" : "Control+Shift+Backspace";
|
||||
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
|
||||
const UNDO_WINDOW_MS = 6000;
|
||||
|
||||
@@ -46,6 +49,25 @@ export async function archiveFromRowButton(page: Page, workspaceId: string): Pro
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
}
|
||||
|
||||
/** Drags the sidebar's edge past its minimum, leaving it as narrow as it goes. */
|
||||
export async function resizeSidebarToMinimum(page: Page): Promise<void> {
|
||||
const handle = await page.getByTestId("left-sidebar-resize-handle").boundingBox();
|
||||
if (!handle) throw new Error("Sidebar resize handle is not laid out");
|
||||
const y = handle.y + handle.height / 2;
|
||||
await page.mouse.move(handle.x + handle.width / 2, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x - 30, y, { steps: 8 });
|
||||
await page.mouse.move(10, y, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await page.getByTestId("sidebar-footer").boundingBox())?.width)
|
||||
.toBeLessThanOrEqual(200);
|
||||
}
|
||||
|
||||
export async function pressArchiveShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(ARCHIVE_SHORTCUT);
|
||||
}
|
||||
|
||||
export async function pressUndoShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(UNDO_SHORTCUT);
|
||||
}
|
||||
@@ -76,6 +98,51 @@ export async function expectDirectoryPresence(directory: string, present: boolea
|
||||
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
|
||||
}
|
||||
|
||||
export interface HeldArchiveRequest {
|
||||
waitForHeld(): Promise<void>;
|
||||
/** Sends the held request to the daemon naming a workspace it has no record of, so it refuses. */
|
||||
releaseForUnknownWorkspace(workspaceId: string): void;
|
||||
}
|
||||
|
||||
/** Keeps the next archive request from reaching the daemon until the test releases it. */
|
||||
export async function holdNextArchiveRequest(page: Page): Promise<HeldArchiveRequest> {
|
||||
let held: { request: SessionRequest; forward: (raw: string) => void } | null = null;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const request = parseSessionRequest(message);
|
||||
if (!held && request?.type === "archive_workspace_request") {
|
||||
held = { request, forward: (raw) => server.send(raw) };
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
return {
|
||||
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
|
||||
releaseForUnknownWorkspace: (workspaceId) => {
|
||||
if (!held) throw new Error("No archive request is held");
|
||||
held.forward(JSON.stringify({ type: "session", message: { ...held.request, workspaceId } }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionRequest {
|
||||
type: string;
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function parseSessionRequest(message: string | Buffer): SessionRequest | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof message === "string" ? message : message.toString("utf8"));
|
||||
return envelope?.type === "session" ? envelope.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatedTeardown {
|
||||
/** The `paseo.json` config whose worktree teardown parks until released. */
|
||||
paseoConfig: Record<string, unknown>;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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,15 +1,11 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from "react";
|
||||
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
|
||||
import type { StreamViewportHandle } from "./strategy";
|
||||
|
||||
/**
|
||||
* 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"`.
|
||||
* 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.
|
||||
*/
|
||||
export type DisclosureHold = (
|
||||
apply: () => void,
|
||||
options?: { triggerEdge?: DisclosureTriggerEdge },
|
||||
) => void;
|
||||
export type DisclosureHold = (apply: () => void) => void;
|
||||
|
||||
const applyInPlace: DisclosureHold = (apply) => apply();
|
||||
|
||||
@@ -25,10 +21,10 @@ export function DisclosureRow({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hold = useMemo<DisclosureHold>(
|
||||
() => (apply, options) => {
|
||||
() => (apply) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) {
|
||||
viewport.holdRow(rowId, apply, options?.triggerEdge ?? "top");
|
||||
viewport.holdRow(rowId, apply);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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.
|
||||
@@ -131,16 +130,7 @@ function mountList(heldRowId: string) {
|
||||
return position - state.scroll;
|
||||
};
|
||||
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
|
||||
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 };
|
||||
return { render, scrollTo, heldRowTop, rowSize };
|
||||
}
|
||||
|
||||
it("anchors a data change on a held row whose top is above the viewport", async () => {
|
||||
@@ -181,72 +171,6 @@ 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,10 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNativeDisclosureHold,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type DisclosureAnchor,
|
||||
} from "./native-disclosure-hold";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
|
||||
function createFrames() {
|
||||
let nextId = 1;
|
||||
@@ -44,22 +39,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({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-1");
|
||||
expect(hold.heldRowId()).toBe("turn-1");
|
||||
expect(releases).toEqual([]);
|
||||
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(hold.heldRowId()).toBeNull();
|
||||
expect(releases).toEqual(["released"]);
|
||||
});
|
||||
|
||||
it("restarts the quiet frames on every item size change", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
frames.runFrame();
|
||||
hold.noteItemSizeChanged();
|
||||
frames.runFrame();
|
||||
@@ -84,11 +79,11 @@ describe("native disclosure hold", () => {
|
||||
it("moves the hold to the latest toggled row and releases once", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
frames.runFrame();
|
||||
hold.hold({ kind: "row", rowId: "turn-2" });
|
||||
hold.hold("turn-2");
|
||||
|
||||
expect(hold.anchorRowId()).toBe("turn-2");
|
||||
expect(hold.heldRowId()).toBe("turn-2");
|
||||
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
@@ -99,137 +94,12 @@ describe("native disclosure hold", () => {
|
||||
it("drops the hold without releasing when cancelled", () => {
|
||||
const { frames, hold, releases } = createHarness();
|
||||
|
||||
hold.hold({ kind: "row", rowId: "turn-1" });
|
||||
hold.hold("turn-1");
|
||||
hold.cancel();
|
||||
frames.runFrame();
|
||||
frames.runFrame();
|
||||
|
||||
expect(hold.anchorRowId()).toBeNull();
|
||||
expect(hold.heldRowId()).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,30 +1,13 @@
|
||||
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 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.
|
||||
* 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.
|
||||
*/
|
||||
export interface NativeDisclosureHold {
|
||||
/** 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;
|
||||
heldRowId(): string | null;
|
||||
hold(rowId: string): void;
|
||||
noteItemSizeChanged(): void;
|
||||
/** Drops the hold without releasing it. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
@@ -34,10 +17,9 @@ export function createNativeDisclosureHold(input: {
|
||||
cancelFrame: (id: number) => void;
|
||||
onRelease: () => void;
|
||||
}): NativeDisclosureHold {
|
||||
let anchor: DisclosureAnchor | null = null;
|
||||
let heldRowId: string | null = null;
|
||||
let frameId: number | null = null;
|
||||
let remainingFrames = 0;
|
||||
let waitingToggle: { frameId: number; land: () => void } | null = null;
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (frameId !== null) {
|
||||
@@ -53,7 +35,7 @@ export function createNativeDisclosureHold(input: {
|
||||
return;
|
||||
}
|
||||
frameId = null;
|
||||
anchor = null;
|
||||
heldRowId = null;
|
||||
input.onRelease();
|
||||
};
|
||||
|
||||
@@ -63,80 +45,20 @@ 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 {
|
||||
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()) };
|
||||
heldRowId: () => heldRowId,
|
||||
hold(rowId) {
|
||||
heldRowId = rowId;
|
||||
restartCountdown();
|
||||
},
|
||||
noteItemSizeChanged() {
|
||||
if (anchor !== null) {
|
||||
if (heldRowId !== null) {
|
||||
restartCountdown();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
landWaitingToggle();
|
||||
stopCountdown();
|
||||
anchor = null;
|
||||
heldRowId = 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, height: 100 }, replacements)).toBe(1000);
|
||||
expect(anchor.project(0, { id: "prompt", top: 1048 }, 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,32 +59,13 @@ 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]!, "top", 600);
|
||||
anchor.pin(rows[2]!);
|
||||
// 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);
|
||||
@@ -128,8 +109,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, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500, height: 100 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(anchor.project(440, { id: "reading", top: 2500 })).toBe(2440);
|
||||
expect(
|
||||
anchor.reconcile(
|
||||
440,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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 {
|
||||
@@ -19,16 +14,7 @@ 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() {
|
||||
// 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 anchor: { id: string; top: number } | null = null;
|
||||
let geometry: readonly RowGeometry[] = [];
|
||||
const readingRow = (scrollTop: number) =>
|
||||
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
|
||||
@@ -36,12 +22,9 @@ export function createReadingAnchor() {
|
||||
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
|
||||
const project = (
|
||||
scrollTop: number,
|
||||
row: RowGeometry | undefined,
|
||||
row: Pick<RowGeometry, "id" | "top"> | undefined,
|
||||
replacements = NO_REPLACEMENTS,
|
||||
) =>
|
||||
anchor && row && row.id === getRowId(replacements)
|
||||
? (anchor.pinnedScrollTop ?? scrollTop) + edgeOf(row, anchor.edge) - anchor.position
|
||||
: scrollTop;
|
||||
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
|
||||
return {
|
||||
getRowId,
|
||||
getReadingRowId: (scrollTop: number) =>
|
||||
@@ -50,14 +33,14 @@ export function createReadingAnchor() {
|
||||
reset() {
|
||||
anchor = null;
|
||||
},
|
||||
// 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 };
|
||||
// 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 };
|
||||
},
|
||||
scroll(scrollTop: number) {
|
||||
const next = readingRow(scrollTop);
|
||||
anchor = next ? anchorTop(next) : null;
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
},
|
||||
reconcile(
|
||||
scrollTop: number,
|
||||
@@ -74,7 +57,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 ? anchorTop(next) : null;
|
||||
anchor = next ? { id: next.id, top: next.top } : null;
|
||||
return correctedTop;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,20 +35,10 @@ 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,
|
||||
disclosureAnchor,
|
||||
createEndHoldDistance,
|
||||
type EndHoldDistance,
|
||||
} from "./native-disclosure-hold";
|
||||
import { createNativeDisclosureHold } from "./native-disclosure-hold";
|
||||
import { EndPinnedScrollView } from "./native-end-pin";
|
||||
import { createNewRowEstimates } from "./new-row-estimates";
|
||||
import type {
|
||||
DisclosureTriggerEdge,
|
||||
StreamRenderInput,
|
||||
StreamStrategy,
|
||||
StreamViewportHandle,
|
||||
} from "./strategy";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy, isNearBottomForStreamRenderStrategy } from "./strategy";
|
||||
import {
|
||||
isHistoryStartLoadingOperation,
|
||||
@@ -316,14 +306,8 @@ 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(() => {
|
||||
setEndHold(null);
|
||||
bottomAnchorController.releaseDisclosure({
|
||||
isAtEnd: endHold ? endHold.distance.current() <= AT_END_OFFSET_PX : isAtEnd(),
|
||||
});
|
||||
bottomAnchorController.releaseDisclosure({ isAtEnd: isAtEnd() });
|
||||
});
|
||||
const [disclosureHold] = useState(() =>
|
||||
createNativeDisclosureHold({
|
||||
@@ -333,54 +317,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onRelease: releaseDisclosure,
|
||||
}),
|
||||
);
|
||||
const cancelDisclosureHold = useStableEvent(() => {
|
||||
disclosureHold.cancel();
|
||||
setEndHold(null);
|
||||
});
|
||||
useEffect(() => () => cancelDisclosureHold(), [cancelDisclosureHold]);
|
||||
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, 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();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
// 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 handleItemSizeChanged = useStableEvent(
|
||||
(info: { size: number; previous: number; itemKey: string; itemData: StreamItem }) => {
|
||||
newRowEstimates.noteMeasured(info.itemData, info.size);
|
||||
@@ -388,8 +334,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
);
|
||||
const shouldRestorePosition = useStableEvent((item: StreamItem) => {
|
||||
const anchorRowId = disclosureHold.anchorRowId();
|
||||
return anchorRowId === null || item.id === anchorRowId;
|
||||
const heldRowId = disclosureHold.heldRowId();
|
||||
return heldRowId === null || item.id === heldRowId;
|
||||
});
|
||||
// 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
|
||||
@@ -403,23 +349,16 @@ 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, 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.
|
||||
// LegendList keeps only the held row in place.
|
||||
// - 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";
|
||||
// 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 pinsEnd = isFollowingEnd && isListPlaced;
|
||||
const maintainVisibleContentPosition = useMemo(
|
||||
() => (isEndPinned ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isEndPinned, shouldRestorePosition],
|
||||
() => (isFollowingEnd ? false : { data: true, size: true, shouldRestorePosition }),
|
||||
[isFollowingEnd, shouldRestorePosition],
|
||||
);
|
||||
// A new identity re-renders LegendList's scroll component, which is how the pin follows the mode.
|
||||
const renderScrollComponent = useCallback(
|
||||
@@ -566,7 +505,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.
|
||||
cancelDisclosureHold();
|
||||
disclosureHold.cancel();
|
||||
bottomAnchorController.beginUserScroll();
|
||||
historyStartPagination.rearm();
|
||||
});
|
||||
@@ -642,7 +581,6 @@ 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 { DisclosureTriggerEdge, StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import type { StreamRenderInput, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -91,15 +91,10 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
|
||||
return {
|
||||
render: (segments: Partial<StreamRenderInput["segments"]>) => act(() => render(segments)),
|
||||
hold: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge = "top") =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply, triggerEdge)),
|
||||
hold: (rowId: string, apply: () => void) =>
|
||||
act(() => viewportRef.current!.holdRow(rowId, apply)),
|
||||
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.
|
||||
@@ -144,53 +139,6 @@ 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,7 +418,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{
|
||||
id: String(measurement.key),
|
||||
top: measurement.start,
|
||||
height: measurement.size,
|
||||
},
|
||||
props.readingRowReplacements,
|
||||
)
|
||||
@@ -1173,12 +1172,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdRow: (rowId, apply, triggerEdge) => {
|
||||
holdRow: (rowId, apply) => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
const row = measureRows()?.find((candidate) => candidate.id === rowId);
|
||||
const scrollTop = scrollContainerRef.current?.scrollTop;
|
||||
if (row && scrollTop !== undefined) readingAnchor.pin(row, triggerEdge, scrollTop);
|
||||
if (row) readingAnchor.pin(row);
|
||||
disclosurePendingRef.current = true;
|
||||
apply();
|
||||
},
|
||||
|
||||
@@ -46,23 +46,16 @@ 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 the edge the trigger
|
||||
* sits on in place. 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 its visual top.
|
||||
* Following output stops; it resumes if the change leaves the list at its end.
|
||||
*/
|
||||
holdRow: (rowId: string, apply: () => void, triggerEdge: DisclosureTriggerEdge) => void;
|
||||
holdRow: (rowId: string, apply: () => void) => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
"objective-c": "m",
|
||||
markdown: "md",
|
||||
elixir: "ex",
|
||||
terraform: "tf",
|
||||
};
|
||||
|
||||
function fenceLanguageToExtension(info: string | null | undefined): string | null {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarToastRegion } from "@/components/sidebar/sidebar-toast-region";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
@@ -644,31 +645,33 @@ function MobileSidebar({
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarViewSlot dismiss={handleWorkspacePress}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarToastRegion active={active}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarToastRegion>
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarFooter
|
||||
@@ -842,31 +845,33 @@ function DesktopSidebar({
|
||||
</View>
|
||||
|
||||
<SidebarViewSlot dismiss={closeSidebar}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarToastRegion active={active}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarToastRegion>
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useDisclosureHold } from "@/agent-stream/disclosure";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -51,8 +50,7 @@ function PlanCardContent({
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const hold = useDisclosureHold();
|
||||
const toggleExpanded = useCallback(() => hold(() => setExpanded((value) => !value)), [hold]);
|
||||
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, disableOuterSpacing && styles.containerCompact],
|
||||
[disableOuterSpacing],
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
|
||||
/**
|
||||
* Shows toasts raised by the sidebar's list over its bottom edge, just above the footer.
|
||||
*
|
||||
* A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden
|
||||
* their toasts go to the app's host instead. The sidebar's API stays stable across open and close
|
||||
* so that toggling the panel doesn't re-render every row.
|
||||
*/
|
||||
export function SidebarToastRegion({ active, children }: { active: boolean; children: ReactNode }) {
|
||||
const appToast = useToast();
|
||||
const { api: sidebarToast, toast, dismiss } = useToastHost();
|
||||
const activeRef = useRef(active);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
return () => {
|
||||
// An archive holds on to this API, so a failure that lands later needs a host on screen.
|
||||
activeRef.current = false;
|
||||
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
|
||||
dismiss();
|
||||
};
|
||||
}, [active, dismiss]);
|
||||
|
||||
const api = useMemo<ToastApi>(() => {
|
||||
const visibleHost = () => (activeRef.current ? sidebarToast : appToast);
|
||||
return {
|
||||
show: (content, options) => visibleHost().show(content, options),
|
||||
copied: (label) => visibleHost().copied(label),
|
||||
error: (message) => visibleHost().error(message),
|
||||
};
|
||||
}, [appToast, sidebarToast]);
|
||||
|
||||
return (
|
||||
<View style={styles.region}>
|
||||
<ToastApiProvider api={api}>{children}</ToastApiProvider>
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
region: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -74,7 +74,11 @@ const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMappin
|
||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
/** Pointer-only, so it is always the kebab's pointer size. */
|
||||
export const ARCHIVE_BUTTON_SIZE = 18;
|
||||
const ARCHIVE_BUTTON_SIZE = 18;
|
||||
/** Keeps a press aimed at the kebab from landing on the one-click archive. */
|
||||
const ARCHIVE_BUTTON_KEBAB_GAP = 8;
|
||||
/** The width the archive button and its gap take up left of the kebab. */
|
||||
export const ARCHIVE_BUTTON_FOOTPRINT = ARCHIVE_BUTTON_SIZE + ARCHIVE_BUTTON_KEBAB_GAP;
|
||||
|
||||
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
|
||||
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
|
||||
@@ -482,7 +486,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
// Center the touch box on the 20px title line it is anchored to.
|
||||
marginTop: bySizing({ touch: -6, pointer: 0 }),
|
||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||
@@ -493,10 +496,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
// The kebab's box without its offsets: the archive glyph fills its view box, so it needs
|
||||
// none of the nudging the three dots do. The kebab's marginLeft is the gap between them.
|
||||
// none of the nudging the three dots do.
|
||||
archiveButton: {
|
||||
width: ARCHIVE_BUTTON_SIZE,
|
||||
height: ARCHIVE_BUTTON_SIZE,
|
||||
marginRight: ARCHIVE_BUTTON_KEBAB_GAP,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
|
||||
@@ -31,7 +31,7 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import {
|
||||
ARCHIVE_BUTTON_SIZE,
|
||||
ARCHIVE_BUTTON_FOOTPRINT,
|
||||
SidebarWorkspaceArchiveButton,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
||||
@@ -446,9 +446,6 @@ export function SidebarWorkspaceTrailingActionBase({
|
||||
);
|
||||
}
|
||||
|
||||
// The archive button plus the kebab's marginLeft, which is the gap between them.
|
||||
const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2;
|
||||
|
||||
export function SidebarWorkspaceTrailingActionOverlay({
|
||||
visible,
|
||||
scrimBackdrop,
|
||||
@@ -473,7 +470,7 @@ export function SidebarWorkspaceTrailingActionOverlay({
|
||||
{scrimBackdrop ? (
|
||||
<TrailingActionScrim
|
||||
backdrop={scrimBackdrop}
|
||||
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_SCRIM_WIDTH : SCRIM_WIDTH}
|
||||
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_FOOTPRINT : SCRIM_WIDTH}
|
||||
testID="sidebar-workspace-trailing-scrim"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -4,14 +4,7 @@ import {
|
||||
} from "@/components/floating-header";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet as NativeStyleSheet,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Platform, StyleSheet as NativeStyleSheet, Text, ToastAndroid, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -59,7 +52,7 @@ export interface ToastApi {
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
type ToastViewportPlacement = "app-shell" | "panel" | "sidebar";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
const noopDismiss: DismissToast = () => {};
|
||||
@@ -221,11 +214,17 @@ export function ToastViewport({
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={styles.container(placement, topOffset)}
|
||||
style={
|
||||
placement === "sidebar" ? styles.sidebarContainer : styles.container(placement, topOffset)
|
||||
}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
|
||||
<View
|
||||
style={placement === "sidebar" ? styles.sidebarWidthBoundary : styles.widthBoundary}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
{toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
@@ -234,7 +233,7 @@ export function ToastViewport({
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
<ToastCard toast={toast} onHoverIn={pauseDismiss} onHoverOut={resumeDismiss} />
|
||||
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -250,12 +249,12 @@ export function ToastViewport({
|
||||
|
||||
function ToastCard({
|
||||
toast,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
toast: ToastState;
|
||||
onHoverIn: () => void;
|
||||
onHoverOut: () => void;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
let defaultIcon: ReactNode = null;
|
||||
if (toast.variant === "info") {
|
||||
@@ -270,10 +269,9 @@ function ToastCard({
|
||||
const icon = toast.icon ?? defaultIcon;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessible={false}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
<View
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "info" ? styles.toastInfo : null,
|
||||
@@ -291,7 +289,7 @@ function ToastCard({
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,7 +298,7 @@ const animatedStyles = NativeStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: (placement: ToastViewportPlacement, topOffset: number) => ({
|
||||
container: (placement: Exclude<ToastViewportPlacement, "sidebar">, topOffset: number) => ({
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
@@ -308,11 +306,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
}),
|
||||
sidebarContainer: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
bottom: theme.spacing[2],
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
widthBoundary: {
|
||||
width: "92%",
|
||||
maxWidth: TOAST_MAX_WIDTH,
|
||||
alignItems: "center",
|
||||
},
|
||||
sidebarWidthBoundary: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "100%",
|
||||
|
||||
@@ -9,11 +9,8 @@ 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 = {
|
||||
@@ -29,11 +26,7 @@ export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
|
||||
[],
|
||||
);
|
||||
const hold = useDisclosureHold();
|
||||
const toggle = useCallback(
|
||||
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
|
||||
[hold],
|
||||
);
|
||||
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -9,7 +9,10 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const undoKeys = useShortcutKeys("undo-archive-workspace");
|
||||
const undoShortcut = useMemo(() => (undoKeys ? <Shortcut chord={undoKeys} /> : null), [undoKeys]);
|
||||
const undoShortcut = useMemo(
|
||||
() => (undoKeys ? <Shortcut chord={undoKeys} style={styles.shortcut} /> : null),
|
||||
[undoKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -29,9 +32,14 @@ export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// Cancels the badge's own inset, which would otherwise add to the button's gap.
|
||||
shortcut: {
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
"src/astro/LICENSE",
|
||||
"src/hcl/LICENSE"
|
||||
"src/astro/LICENSE"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { setImmediate } from "node:timers/promises";
|
||||
import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js";
|
||||
import type { HighlightToken } from "../types.js";
|
||||
import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js";
|
||||
import { getParserForFile } from "../parsers.js";
|
||||
|
||||
describe("highlightCode", () => {
|
||||
it("highlights JavaScript code with correct token styles", () => {
|
||||
@@ -345,137 +344,6 @@ const title = "Hello";
|
||||
});
|
||||
});
|
||||
|
||||
describe("Terraform", () => {
|
||||
function styleOf(line: HighlightToken[], text: string) {
|
||||
return line.find((token) => token.text === text)?.style;
|
||||
}
|
||||
|
||||
it("highlights blocks, labels, attributes, and values", () => {
|
||||
const code = [
|
||||
"# The web server",
|
||||
'resource "aws_instance" "web" {',
|
||||
" count = 2",
|
||||
" enabled = true",
|
||||
' tags = merge(var.labels, { Name = "web" })',
|
||||
"}",
|
||||
].join("\n");
|
||||
const result = highlightCode(code, "main.tf");
|
||||
|
||||
expect(result[0][0]).toEqual({ text: "# The web server", style: "comment" });
|
||||
expect(styleOf(result[1], "resource")).toBe("type");
|
||||
expect(styleOf(result[1], '"aws_instance"')).toBe("string");
|
||||
expect(styleOf(result[1], '"web"')).toBe("string");
|
||||
expect(styleOf(result[2], "count")).toBe("definition");
|
||||
expect(styleOf(result[2], "2")).toBe("number");
|
||||
expect(styleOf(result[3], "true")).toBe("literal");
|
||||
expect(styleOf(result[4], "merge")).toBe("function");
|
||||
expect(styleOf(result[4], "var")).toBe("variable");
|
||||
expect(styleOf(result[4], "labels")).toBe("property");
|
||||
});
|
||||
|
||||
it("highlights expressions interpolated inside strings", () => {
|
||||
const result = highlightCode('name = "app-${var.env}-web"', "main.tf");
|
||||
|
||||
expect(result[0].map((token) => [token.text, token.style])).toEqual([
|
||||
["name", "definition"],
|
||||
[" ", null],
|
||||
["=", "operator"],
|
||||
[" ", null],
|
||||
['"app-', "string"],
|
||||
["${", "punctuation"],
|
||||
["var", "variable"],
|
||||
[".", "operator"],
|
||||
["env", "property"],
|
||||
["}", "punctuation"],
|
||||
['-web"', "string"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("highlights heredoc bodies as strings and resumes after the terminator", () => {
|
||||
const code = ["policy = <<-EOT", ' { "Version": "2012" }', " EOT", "retries = 3"].join("\n");
|
||||
const result = highlightCode(code, "main.tf");
|
||||
|
||||
expect(styleOf(result[0], "EOT")).toBe("keyword");
|
||||
expect(result[1].map((token) => token.style)).toEqual(["string"]);
|
||||
expect(styleOf(result[3], "retries")).toBe("definition");
|
||||
expect(styleOf(result[3], "3")).toBe("number");
|
||||
});
|
||||
|
||||
it("ends a heredoc with a Unicode delimiter", () => {
|
||||
const result = highlightCode("x = <<ÉOF\nfoo\nÉOF\ny = 1", "main.tf");
|
||||
|
||||
expect(styleOf(result[3], "y")).toBe("definition");
|
||||
expect(styleOf(result[3], "1")).toBe("number");
|
||||
});
|
||||
|
||||
it.each([
|
||||
['x = "$$${var.name}"', "$"],
|
||||
['x = "%%%{ if true }"', "%"],
|
||||
])("keeps escaped template text in %s as a string", (code) => {
|
||||
const line = highlightCode(code, "main.tf")[0];
|
||||
|
||||
expect(line[line.length - 1]).toEqual({ text: code.slice(4), style: "string" });
|
||||
});
|
||||
|
||||
it("parses a real configuration without error nodes", () => {
|
||||
const code = [
|
||||
"terraform {",
|
||||
' required_version = ">= 1.5"',
|
||||
' backend "s3" {}',
|
||||
"}",
|
||||
"",
|
||||
'variable "subnets" {',
|
||||
" type = map(object({ cidr = string }))",
|
||||
" default = {}",
|
||||
"}",
|
||||
"",
|
||||
"locals {",
|
||||
" names = [for key, subnet in var.subnets : upper(key) if subnet.cidr != null]",
|
||||
' first = length(local.names) > 0 ? local.names[0] : "none"',
|
||||
" arns = aws_instance.web[*].arn",
|
||||
" /* block comment */",
|
||||
" path = provider::time::rfc3339_parse(timestamp()) // trailing",
|
||||
"}",
|
||||
"",
|
||||
'data "aws_iam_policy_document" "assume" {',
|
||||
' dynamic "statement" {',
|
||||
" for_each = var.subnets",
|
||||
" content {",
|
||||
' sid = "${statement.key}-%{ if statement.value.cidr != "" }set%{ else }unset%{ endif }"',
|
||||
" }",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
'output "script" {',
|
||||
" value = <<EOT",
|
||||
"#!/bin/sh",
|
||||
"echo ${local.first}",
|
||||
"EOT",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
const parser = getParserForFile("main.tf");
|
||||
if (!parser) throw new Error("Expected a parser for .tf files");
|
||||
const errors: number[] = [];
|
||||
parser.parse(code).iterate({
|
||||
enter(node) {
|
||||
if (node.type.isError) errors.push(node.from);
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors.map((from) => code.slice(from, from + 30))).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the same grammar for tfvars and hcl files", () => {
|
||||
expect(highlightCode('region = "eu-west-1"', "prod.tfvars")).toEqual(
|
||||
highlightCode('region = "eu-west-1"', "terragrunt.hcl"),
|
||||
);
|
||||
expect(styleOf(highlightLine('region = "eu-west-1"', "prod.tfvars"), "region")).toBe(
|
||||
"definition",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlightCodeInSteps", () => {
|
||||
it("splits a large file into many bounded steps with the same result", () => {
|
||||
const code = Array.from(
|
||||
|
||||
@@ -22,9 +22,6 @@ describe("isLanguageSupported", () => {
|
||||
expect(isLanguageSupported("test.dart")).toBe(true);
|
||||
expect(isLanguageSupported("test.cs")).toBe(true);
|
||||
expect(isLanguageSupported("test.nix")).toBe(true);
|
||||
expect(isLanguageSupported("main.tf")).toBe(true);
|
||||
expect(isLanguageSupported("prod.tfvars")).toBe(true);
|
||||
expect(isLanguageSupported("terragrunt.hcl")).toBe(true);
|
||||
expect(isLanguageSupported("test.ex")).toBe(true);
|
||||
expect(isLanguageSupported("Counter.svelte")).toBe(true);
|
||||
expect(isLanguageSupported("Page.astro")).toBe(true);
|
||||
@@ -68,9 +65,6 @@ describe("getSupportedExtensions", () => {
|
||||
expect(extensions).toContain("dart");
|
||||
expect(extensions).toContain("cs");
|
||||
expect(extensions).toContain("nix");
|
||||
expect(extensions).toContain("tf");
|
||||
expect(extensions).toContain("tfvars");
|
||||
expect(extensions).toContain("hcl");
|
||||
expect(extensions).toContain("json");
|
||||
expect(extensions).toContain("svelte");
|
||||
expect(extensions).toContain("astro");
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2021 by Marijn Haverbeke <marijnh@gmail.com> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,41 +0,0 @@
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
|
||||
export const hclHighlighting = styleTags({
|
||||
"for in if else endfor endif": t.controlKeyword,
|
||||
"Attribute/Identifier": t.definition(t.propertyName),
|
||||
"ObjectKey/Expression/VariableExpr/Identifier": t.definition(t.propertyName),
|
||||
"ObjectKey/Expression/LiteralValue/StringLit!": t.definition(t.propertyName),
|
||||
"ForIntro/Identifier TemplateForStart/Identifier": t.definition(t.variableName),
|
||||
"BlockType!": t.typeName,
|
||||
// Paseo themes have no role for labelName; labels render as the strings they are.
|
||||
"BlockLabel!": t.string,
|
||||
"FunctionNamespace/Identifier": t.namespace,
|
||||
"FunctionName/Identifier": t.function(t.variableName),
|
||||
"VariableExpr/Identifier": t.variableName,
|
||||
"GetAttr/Identifier": t.propertyName,
|
||||
Identifier: t.name,
|
||||
"QuotedTemplateStart QuotedTemplateEnd TemplateLiteral": t.string,
|
||||
NumericLit: t.number,
|
||||
"true false": t.bool,
|
||||
null: t.null,
|
||||
LineComment: t.lineComment,
|
||||
BlockComment: t.blockComment,
|
||||
"( )": t.paren,
|
||||
"{ }": t.brace,
|
||||
"[ ]": t.squareBracket,
|
||||
ArithOp: t.arithmeticOperator,
|
||||
LogicOp: t.logicOperator,
|
||||
CompareOp: t.compareOperator,
|
||||
ControlOp: t.controlOperator,
|
||||
"ControlOp/Colon": t.controlOperator,
|
||||
AssignOp: t.definitionOperator,
|
||||
DerefOp: t.derefOperator,
|
||||
HeredocIdentifier: t.special(t.controlKeyword),
|
||||
HeredocStart: t.operator,
|
||||
"TemplateInterpolationStart TemplateInterpolationEnd": t.special(t.brace),
|
||||
"TemplateDirectiveStart TemplateDirectiveEnd": t.special(t.brace),
|
||||
StripMarker: t.special(t.brace),
|
||||
"AttrSplat FullSplat LegacyIndex": t.derefOperator,
|
||||
"Arrow Ellipsis": t.punctuation,
|
||||
"ObjectElem/Colon ForIntro/Colon Comma NamespaceSeparator": t.separator,
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
delimitedIndent,
|
||||
foldInside,
|
||||
foldNodeProp,
|
||||
indentNodeProp,
|
||||
LRLanguage,
|
||||
} from "@codemirror/language";
|
||||
import type { SyntaxNode } from "@lezer/common";
|
||||
import { parser } from "./parser.js";
|
||||
|
||||
export const hclLanguage = LRLanguage.define({
|
||||
name: "HCL",
|
||||
parser: parser.configure({
|
||||
props: [
|
||||
indentNodeProp.add({
|
||||
Block: delimitedIndent({ closing: "}", align: false }),
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
"Object Tuple TemplateFor TemplateIf": foldInside,
|
||||
BlockComment: (tree: SyntaxNode) => {
|
||||
return { from: tree.from + 2, to: tree.to - 2 };
|
||||
},
|
||||
HeredocTemplate(tree: SyntaxNode) {
|
||||
const start = tree.getChild("HeredocIdentifier");
|
||||
if (!start) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: start.to,
|
||||
to: tree.to,
|
||||
};
|
||||
},
|
||||
FunctionCall(tree: SyntaxNode) {
|
||||
const start = tree.getChild("(");
|
||||
const end = tree.getChild(")");
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: start.from + 1,
|
||||
to: end.to - 1,
|
||||
};
|
||||
},
|
||||
Block(tree: SyntaxNode) {
|
||||
const start = tree.getChild("{");
|
||||
const end = tree.getChild("}");
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: start.from + 1,
|
||||
to: end.to - 1,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
languageData: {
|
||||
commentTokens: { line: "#", block: { open: "/*", close: "*/" } },
|
||||
},
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
// Generated by lezer-generator, taken from codemirror-lang-hcl@0.2.0 (MIT, see LICENSE).
|
||||
// Vendored for the same reason as packages/highlight/src/nix: only Lezer primitives load here.
|
||||
import { LRParser } from "@lezer/lr";
|
||||
import { hclHighlighting } from "./highlight.js";
|
||||
import {
|
||||
scanQuotedTemplateStart,
|
||||
scanQuotedTemplateEnd,
|
||||
scanTemplateInterpolationStart,
|
||||
scanTemplateInterpolationEnd,
|
||||
scanTemplateDirectiveStart,
|
||||
scanTemplateDirectiveEnd,
|
||||
scanHeredocIdentifier,
|
||||
scanTemplateLiteralChunk,
|
||||
trackTemplate,
|
||||
} from "./tokens.js";
|
||||
|
||||
const spec_Identifier = new Map<string, number>([
|
||||
["true", 41],
|
||||
["false", 43],
|
||||
["null", 47],
|
||||
["for", 66],
|
||||
["in", 70],
|
||||
["endfor", 74],
|
||||
["if", 80],
|
||||
["else", 84],
|
||||
["endif", 88],
|
||||
]);
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states:
|
||||
"GxQ]Q&jOOPeO&jOOOmQ&kO'#DcO!kQ&kO'#E_O!yQ&kO'#E^OOQO'#Ea'#EaO#UQ&jO'#ChQOQ&jOOPOOO'#Cd'#CdPOOO)C@`)C@`OOQy'#Co'#CoOOQy'#Cr'#CrO#aQ)vO'#CwOOQy'#Cm'#CmOOQ!b'#D['#D[O#oQ'|O'#DZOOQy'#Cv'#CvO$rQ&kO'#D`O$yQ&jO'#DeO%UQ&jO'#DdO&XQ&kO'#EqO&cQ&kO'#DcOOQy,59},59}O&jQ&jO,59}OOQy'#D]'#D]O(YQ'dO'#DgOOQO'#Ee'#EeO)PQ&jO'#DiO)UQ&jO'#DiOOQy'#Dp'#DpOOQP'#Dx'#DxOOQP'#Dy'#DyO%^Q&kO'#DwO*nQ'dO'#ClOOQy'#Dv'#DvOOQy'#El'#ElO%^Q&kO'#ElOOQy'#Cl'#ClO%^Q&kO,59TO#jQ)`O'#CtOOQP'#E`'#E`OOQP'#Eg'#EgO+eQ&kO,5:xO+pQ&jO,5:xOOQO-E8_-E8_O+xQ+YO'#CuO,ZQ)vO,59`O,iQ&sO'#CxO,sQ)tO'#C{O-OQ)tO'#DSOOQ%n'#Cz'#CzOOQ%n'#Eb'#EbO-ZQ)vO,59cOOQy,59c,59cO-iQ&jO'#DTO-tQ+WO,59uO.SQ&jO'#EpOOQy,59z,59zO._Q&jO,59zO%^Q&kO,5:]O.dQ&jO'#DrOOQP'#E['#E[O%^Q&kO,5:uO.iQ&kO,5:OOOQP'#Ed'#EdO/dQ&kO,5;]O/kQ&kO,5;]O/uQ&kO,5:`OOQy1G/i1G/iOOQO'#Dk'#DkOOQO,5:U,5:UO0pQ&jO'#DlOOQO-E8c-E8cO0xQ&jO,5:TO0}Q&kO,5:TOOQy,5:c,5:cOOQP'#D{'#D{OOQP'#D|'#D|OOQP'#D}'#D}OOQP'#EO'#EOOOQP'#EP'#EPOOQP'#EQ'#EQO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO'#ESOOQy'#ER'#ERO1UQ&jO'#EUOOQy'#EX'#EXOOQy'#EY'#EYOOQy'#EW'#EWOOQy,5;W,5;WO1ZQ&jO,5;WO1cQ&jO1G.oO1qQ&lO,59`OOQP-E8e-E8eO1vQ&jO1G0dOOQO1G0d1G0dO1yQ&jO1G0dO+xQ+YO'#EmOOQ%n,59a,59aOOQy1G.z1G.zOOQ%n,59d,59dO2OQ&sO,59dO2YQ&rO,59dOOQ%n,59g,59gO,sQ)tO,59gO2eQ&jO'#DQOOQ%n,59n,59nO2sQ)tO,59nO-OQ)tO,59nO3OQ&jO'#DVOOQ%n-E8`-E8`OOQy1G.}1G.}O3aQ&jO,59hO3fQ&jO,59oO%^Q&kO,59oO3nQ+WO1G/aOOQy1G/a1G/aO3|Q&kO,5;[O4TQ&jO,5;[OOQy1G/f1G/fO4]Q&jO1G/wO4hQ&jO,5:^O4pQ&jO1G0aO4xQ&kO1G/jOOQP,5;O,5;OO5|Q&kO1G0wOOQP-E8b-E8bO7eQ,UO'#DgO7oQ,UO1G/zO9RQ,UO'#ClP9]Q&jO'#DjO9bQ&kO1G/oO9iQ&jO'#DnOOQy1G/o1G/oO9wQ&jO1G/oOOQy1G0Q1G0QO;|Q'dO1G0QO<WQ'dO1G0QO>bQ'dO1G0QO>lQ'dO1G0QO@^Q'dO1G0QOAQQ&jO,5:nOOQy,5:p,5:pPOQy'#Ef'#EfPOQy,5:s,5:sPOQy,5:t,5:tOOQy1G0r1G0rOOQO7+&O7+&OOAYQ&jO7+&OOOQ%n,5;X,5;XOOQ%n1G/O1G/OOA_Q&rO1G/OOAdQ&rO1G/OOOQ%n1G/R1G/ROAoQ&jO,59lOAzQ'[O,59lOOQ%n1G/Y1G/YO2sQ)tO1G/YOBSQ&jO'#DXO2sQ)tO1G/YOBbQ&jO,59qOBpQ'[O,59qOBxQ'[O,59sOCQQ&jO1G/SOCYQ&jO1G/SO%^Q&kO1G/ZOC_Q'[O1G/ZOOQy7+${7+${OCjQ&jO,5:}OC{Q&kO1G0vOOQO-E8a-E8aOOQy7+%c7+%cODSQ&jO7+%cO%^Q&kO'#DsO%^Q&kO1G/xODXQ&jO1G/xOOQP'#E]'#E]O%^Q&kO7+%{P%^Q&kO'#EdO%^Q&kO7+%fOOQy7+%Z7+%ZOD^Q&jO7+%ZODcQ&kO,5:YODjQ&jO,5:YOOQO,5:Y,5:YOOQy1G0Y1G0YPOQy-E8d-E8dOOQO<<Ij<<IjOOQ%n7+$j7+$jODuQ&rO7+$jODzQ'[O1G/WOOQ%n1G/W1G/WOESQ'[O1G/WOOQ%n7+$t7+$tOEXQ&jO,59sO2sQ)tO7+$tOEdQ'[O1G/]OElQ'[O1G/_OOQ$Y1G/]1G/]OEtQ'[O1G/]OOQ%n1G/_1G/_OEyQ'[O1G/_O%^Q&kO7+$nOFOQ&jO7+$nOFTQ&jO7+$nOF]Q'[O7+$uOOQ$Y7+$u7+$uOFhQ'[O7+$uP%^Q&kO'#EcOOQy<<H}<<H}OFmQ&jO,5:_OFxQ&jO7+%dOGQQ&jO7+%dOOQy<<Ig<<IgOGVQ&jO<<IQOHrQ,UO1G0QOJWQ,UO1G0QOKlQ,UO1G0QOMQQ,UO1G0QOMXQ,UO1G0QOOQy<<Hu<<HuOM`Q&kO1G/tOOQO1G/t1G/tOOQ%n<<HU<<HUOOQ%n7+$r7+$rOMgQ'[O7+$rOOQ%n<<H`<<H`OOQ$Y7+$w7+$wOMlQ'[O7+$wOOQ%n7+$y7+$yOMqQ'[O7+$yOMvQ'[O<<HYONRQ&jO<<HYO%^Q&kO<<HYONWQ&jO<<HYOOQ$Y<<Ha<<HaON]Q'[O<<HaOOQP<<IO<<IOO%^Q&kO<<IOOOQyAN>lAN>lONbQ&jOAN>lONgQ&jOAN>lOOQ%n<<H^<<H^OOQ$Y<<Hc<<HcOOQ%n<<He<<HeOOQ$YAN=tAN=tONoQ'[OAN=tO%^Q&kOAN=tONtQ'[OAN=tO! PQ&jOAN=tOOQ$YAN={AN={O! UQ&jOAN>jOOQyG24WG24WO! ^Q&jOG24WOOQ$YG23`G23`O! cQ'[OG23`O! nQ'[OG23`O%^Q&kOG23`OOQPG24UG24UOOQyLD)rLD)rOOQ$YLD(zLD(zO! sQ'[OLD(zO! xQ'[OLD(zOOQ$Y!$'Lf!$'LfO!!TQ'[O!$'LfOOQ$Y!)9BQ!)9BQO!!YQ&kO'#DgO.iQ&kO'#DwO/uQ&kO'#DwO!!|Q&kO'#ClO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO!#sQ&kO1G0QO!$pQ&kO1G0QO!%mQ&kO1G0QO!(mQ&kO1G0QO!(tQ&kO1G0QO.iQ&kO7+%{O/uQ&kO7+%{O4pQ&jO1G0aO4pQ&jO1G0aO%^Q&kO,5:uO%^Q&kO,5:u",
|
||||
stateData:
|
||||
"!)T~O#_OSXPQYPQ~O^RO!UQO~OXWOYWO~OP[O^iOb]OdYOeYOgZO!RaO!TfO!UeO!atO#b^O#c^O#gnO#hoO~O_vOP#RX^#RX!U#RX~OPwO^xO!U{O~O^RO#[[X!T[X~OQ!VOR!POT!WO#]}O~OV!XO~OP[O^iOb]OdYOeYOgZOq!^O!RaO!UeO!atO#b^O#c^O#gnO#hoO~O!Q!ZO~P#tO#w!_O_!XX!Y!XX~O_!aO!Y!aO~OP[O^iOb]OdYOeYOgZO!RaO!UeO!atO#b^O#c^O#gnO#hoO~Or!cO!T#eX~P%^Oq!^O~PmO!T!fO~O#f!gO!R!ZX!w!ZX!y!ZX#g!ZX#i!ZX#j!ZX#k!ZX#l!ZX#m!ZX#n!ZX#o!ZX#p!ZX#q!ZX#r!ZX#s!ZX#t!ZX#u!ZX#v!ZX#w!ZXr!ZX^!ZX!T!ZX~O_!ZX!Y!ZX!a!`X!Q!ZX![!ZX#[!ZXS!ZXm!ZXx!ZX!c!ZXU!ZX~P&oO^!iO~O!a!lO~O!w!{O!y!|O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO#s!rO#t!sO#u!}O#v#OO#w`Xr`X^`X!T`X~O!R!zO_`X!Y`X!Q`X![`X#[`XS`Xm`Xx`X!c`XU`X~P)ZOPwO^xO!U#VO~O^RO!T#WO~O#]#YOQ#aPR#aPT#aPV#aP~OQ#[OR#UXT#UX#]#UX~OS#]Om#^O~P%^OR!POT#bO#]}O~OR!POT#fO#]}O~OQ#hOR!POT!WO#]}O~Om#jOq#iOx#kO~OR!POT!WOV#mO#]}O~Or#nO#w!_O!Q#dX~O!Q#pO~O^#rO~OP[O^&yOb]OdYOeYOgZO!RaO!UeO!atO#b^O#c^O#gnO#hoO~O!T#ea~P%^Or#vO!T#ea~P%^OP[O^#xOb]OdYOeYOgZO!RaO!UeO!atO#b^O#c^O#gnO#hoO~O#f!gO!a!`X~O!a#|O~O![$OO~P%^O^$XO~O![$]O#w!_O~O#w!_O^]i#[]i!T]i~OQ#[O~O^RO!T$^O~OS$aOm$bO~P%^OS$aOm$bO#w!_O~Om$eOq#iOu$fOx#kO~OR!POT$iO#]}O~Om$kOq#iOx#kOz$lO|$mO~O^$nO~Oq$oOx$pO~OR!POT!WOV$rO#]}O~O!Q#da~P%^Or$tO!Q#da~Ox$xO!Q$vO#w!_O~Or$zOs$yO~O!Y${O#w!_O~O#w!_OP!Wi^!Wib!Wid!Wie!Wig!Wir!Wi!R!Wi!T!Wi!U!Wi!a!Wi#b!Wi#c!Wi#g!Wi#h!Wi~O!T#ei~P%^O#f!gO!R!ZX!w!ZX!y!ZX#g!ZX#i!ZX#j!ZX#k!ZX#l!ZX#m!ZX#n!ZX#o!ZX#p!ZX#q!ZX#r!ZX#s!ZX#t!ZX#u!ZX#v!ZX#w!ZX~O!a!`X!i!ZX~P6TO!i%OO#w!_O~O!w!{O!y!|O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO#s!rO#t!sO#u!}O#v#OO#w`X~O!R!zO!i`X~P7wO#f!gO~O![%PO~P%^Or%RO!c%TO#w!_O![!bX~O![%PO~O#i!nO#j!nO#k!nO_!ni!R!ni!Y!ni!w!ni!y!ni#m!ni#n!ni#o!ni#p!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!nir!ni!Q!ni![!ni^!ni#[!niS!nim!nix!ni!c!niU!ni!T!ni~O#g!ni#l!ni~P9|O#g!oO#l!oO~P9|O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO_!ni!R!ni!Y!ni!w!ni!y!ni#s!ni#t!ni#u!ni#v!ni#w!nir!ni!Q!ni![!ni^!ni#[!niS!nim!nix!ni!c!niU!ni!T!ni~O#q!ni#r!ni~P<bO#q!qO#r!qO~P<bO#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO#s!rO!R!ni!w!ni!y!ni#t!ni#u!ni#v!ni#w!nir!ni^!ni!T!ni~O_!ni!Y!ni!Q!ni![!ni#[!niS!nim!nix!ni!c!niU!ni~P>vO!Q%UO#w!_O~O!T%WO~OS%XO~OS%XOm%YO#w!_O~Oq$oOu%ZOx$pO~OU%[Om%]O~Om%_Oq#iOx#kO|$mO~Oq$oOx$pOz%aO|%bO~OU%cOm%dO~OU%eOm%fO~Or%hOs%gO~O^%iO~OU%kOm%lO#w!_O~O#w!_Or#Va!Q#Va![#Va!c#Va~O!Q#di~P%^O!Q%nO~O^%qO~O![%yO~O![!ba~P%^Or%zO!c%{O![!ba~OS%|O~OU%}Om&OO~OU%}O~Oq$oOx$pO|%bO~OU&QOm&RO~OU&SOm&TO~OU&QO~OU&SO~O^&VO~Or&XOs&WO~OU&YOm&ZO#w!_O~OU&YO~O#w!_O!Q!ga!T!ga~O!Y&[O#w!_O~Os&]O~Ox$xO!T&^O!c&`O#w!_O~O#i!nO#j!nO#k!nO!R!ni!w!ni!y!ni#g!ni#l!ni#m!ni#n!ni#o!ni#p!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!ni~O!i!ni~PGeO#g!oO#i!nO#j!nO#k!nO#l!oO!R!ni!w!ni!y!ni#m!ni#n!ni#o!ni#p!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!ni~O!i!ni~PHyO#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO!R!ni!w!ni!y!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!ni~O!i!ni~PJ_O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO!R!ni!i!ni!w!ni!y!ni#t!ni#u!ni#v!ni#w!ni~O#s!ni~PKsO#s!rO~PKsO![!bi~P%^OU&aO~OU&bO~OU&cO~OU&dOm&eO#w!_O~Os&fO~O^&hO~OU&iO~O!T&kO~Ox$xO!T&kO~OU&mO~OU&mOm&oO#w!_O~Os&pO~O!Y&qO#w!_O~O!T&rO~OU&sOm&tO#w!_O~OU&sO~OU&vO~OU&vOm&wO#w!_O~OU&xO~OP!ZXb!ZXd!ZXe!ZXg!ZX!U!ZX!a!ZX#b!ZX#c!ZX#h!ZX~P&oOP`Xb`Xd`Xe`Xg`X!R`X!U`X!a`X#b`X#c`X#h`X~P)ZOP!ni^!nib!nid!nie!nig!nir!ni!T!ni!U!ni!a!ni#b!ni#c!ni#h!ni~PGeOP!ni^!nib!nid!nie!nig!nir!ni!T!ni!U!ni!a!ni#b!ni#c!ni#h!ni~PHyOP!ni^!nib!nid!nie!nig!nir!ni!T!ni!U!ni!a!ni#b!ni#c!ni#h!ni~PJ_O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qOP!ni^!nib!nid!nie!nig!nir!ni!R!ni!T!ni!U!ni!a!ni!w!ni!y!ni#b!ni#c!ni#h!ni#t!ni#u!ni#v!ni#w!ni~O#s!ni~P!&jO#s!rO~P!&jOb!w!y^X#j^~",
|
||||
goto: "CW#fPPPPPPPP#gPPP#j#sPP#y%xP'YPP'YP(j*O%x*c+sP+s,R,aPPP,oP,R,uP-TP-ZP*c-i%xPP.yPP0Z1n1{P%xP%x2U3g3nP5RP%x5X6i6o5XP%x6x8Y8Y6x9n:W:m;P;`;l;u;zP;uP;u<O<O<T=O=o#s=x>O>S>[>v?Q?WP@kPPPP@qByPPCPCSRXPQVOQ#X{R$_#VXTOU{#V^bQde!c!d#v$}Q!YaQ#RtQ#SvQ#_!PQ#q!]Q#s!`Q#t!aQ#y!eS#}!l#|Q$W!zQ$c#^Q$q#kY$s#n$t%R%m%zQ%j$pQ%o$xQ%p$yU%r$|'`'aQ%s%OQ&U%gQ&g&WQ&j&]Q&n&fQ&u&pQ'b'dR'c'e#msQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e#m]Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e#l]Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eTxSzQ!O[f!T!Q!R!U!X#a#d#e#l$h$j%`R#Tw#m`Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'ei!T[!Q!R!U!X#a#d#e#l$h$j%`i!S[!Q!R!U!X#a#d#e#l$h$j%`i!Q[!Q!R!U!X#a#d#e#l$h$j%`Q#`!QR$d#ai!R[!Q!R!U!X#a#d#e#l$h$j%`Q#d!RR$j#eQ#c!RS$g#d#eS%^$h$jR&P%`#m_Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e#mhQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eQVO#mhQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eSdQeS!bd!dV#u!c#v$}_cQde!c!d#v$}#ojQadekptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eZ!hi!i#x#{&y#llQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eR!kkQ$P!lR%Q#|#mmQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eQ!]aR!eeQ$w#qQ&_%sR&l&`#mrQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e!vpQadeptv!P!]!`!c!d!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p'd'eb&z!a&z&}'P'R'T'V'X'`c&{!e&{'O'Q'S'U'W'Y'a[!tq$R$S$T$U$V[&}&|'Z'[']'^'_]'O#z%t%u%v%w%xY!uq$S$T$U$VY'P&|'[']'^'_Z'Q#z%u%v%w%xW!vq$T$U$VW'R&|']'^'_X'S#z%v%w%xU!wq$U$VU'T&|'^'_V'U#z%w%xS!xq$VS'V&|'_T'W#z%xQ!yqQ'X&|R'Y#zV#Qq#z&|T!{q#zV#Pq#z&|!quQadetv!P!]!`!a!c!d!e!l!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p'`'a'd'e!O!`b!Y#R#S#_#q#s#}$W$c$q$s%j%o%p%s&U&g&j&n&u'b'cQ'd#tR'e#yQ$|#sQ'`'bR'a'cXSOU{#VTySzUUO{#VR|UQ!U[Q#a!QQ#e!R[#g!U#a#e#l$h%`Q#l!XQ$h#dR%`$jQ#o!YS$u#o%SR%S#}Q!ddR#w!d#lkQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eR!jkQzSR#Uz!hqQadetv!P!]!`!c!d!l!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p'd'eU!mp&z&{S#z!e'aU$Q!t&}'OQ$R!uQ$S!vQ$T!wQ$U!xQ$V!yQ%t'QQ%u'SQ%v'UQ%w'WQ%x'YS&|!a'`Q'Z'PQ'['RQ']'TQ'^'VR'_'XQ#Z}R$`#YR![aTgQe",
|
||||
nodeNames:
|
||||
"⚠ QuotedTemplateStart QuotedTemplateEnd TemplateInterpolationStart TemplateInterpolationEnd TemplateDirectiveStart TemplateDirectiveEnd HeredocIdentifier Comment LineComment BlockComment ConfigFile Body Attribute Identifier AssignOp Expression LiteralValue NumericLit BoolLit true false NullLit null StringLit TemplateLiteral TemplateExpr QuotedTemplate TemplateInterpolation StripMarker TemplateDirective TemplateFor TemplateForStart for Comma in TemplateForEnd endfor TemplateIf TemplateIfIntro if TemplateElseIntro else TemplateIfEnd endif HeredocTemplate HeredocStart CollectionValue ] [ Tuple } { Object ObjectElem ObjectKey Colon VariableExpr ) FunctionCall FunctionNamespace NamespaceSeparator FunctionName ( FunctionArguments Ellipsis ForExpr ForTupleExpr ForIntro ForCond ForObjectExpr Arrow Operation UnaryOperation ArithOp LogicOp BinaryOperation ArithOp ArithOp CompareOp CompareOp LogicOp LogicOp Index NewIndex LegacyIndex GetAttr DerefOp Splat AttrSplat FullSplat Conditional ControlOp ControlOp Block BlockType BlockLabel",
|
||||
maxTerm: 131,
|
||||
context: trackTemplate,
|
||||
nodeProps: [
|
||||
["openedBy", 48, "[", 51, "{"],
|
||||
["closedBy", 49, "]", 52, "}"],
|
||||
],
|
||||
skippedNodes: [0, 8, 9, 10],
|
||||
repeatNodeCount: 7,
|
||||
tokenData:
|
||||
"-p~RvX^#ipq#iqr$^st$kuv%Svw%Xxy%dyz%iz{%n{|%s|}%x}!O%}!O!P&S!P!Q&z!Q!R(^!R![)l![!]*c!^!_*p!_!`+_!`!a+t!a!b,R!c!},W!}#O,l#P#Q-P#R#S,W#T#o,W#o#p-U#p#q-Z#q#r-f#r#s-k#y#z#i$f$g#i#BY#BZ#i$IS$I_#i$I|$JO#i$JT$JU#i$KV$KW#i&FU&FV#i~#nY#_~X^#ipq#i#y#z#i$f$g#i#BY#BZ#i$IS$I_#i$I|$JO#i$JT$JU#i$KV$KW#i&FU&FV#i~$cP#h~!_!`$f~$kO#r~~$pSX~OY$kZ;'S$k;'S;=`$|<%lO$k~%PP;=`<%l$k~%XO#k~~%[Pvw%_~%dO#s~~%iO!a~~%nO![~~%sO#i~~%xO#l~~%}Or~~&SO#g~~&XR!y~z{&b!O!P&g!Q![&r~&gO#u~~&jP!O!P&m~&rO!c~~&wP!w~!Q![&r~'PQ#j~z{'V!P!Q$k~'YTOz'Vz{'i{;'S'V;'S;=`(W<%lO'V~'lVOz'Vz{'i{!P'V!P!Q(R!Q;'S'V;'S;=`(W<%lO'V~(WOY~~(ZP;=`<%l'V~(cRb~!O!P(l!Q![)l#l#m)w~(oP!Q![(r~(wRb~!Q![(r!g!h)Q#X#Y)Q~)TR{|)^}!O)^!Q![)d~)aP!Q![)d~)iPb~!Q![)d~)qQb~!O!P(l!Q![)l~)zR!Q![*T!c!i*T#T#Z*T~*YRb~!Q![*T!c!i*T#T#Z*T~*hP!Y~![!]*k~*pO#f~~*uQ#o~!^!_*{!_!`+Y~+QP#b~}!O+T~+YO#c~~+_O#p~~+dQ_P!_!`+j!`!a+o~+oO#q~Q+tO!iQ~+yP#m~!_!`+|~,RO#n~~,WO#w~~,]T^~}!O,W!Q![,W!c!},W#R#S,W#T#o,W~,qP!R~z{,t~,wP#P#Q,z~-PO#v~~-UO!Q~~-ZO!U~~-^P#p#q-a~-fO#t~~-kO!T~~-pOm~",
|
||||
tokenizers: [
|
||||
scanQuotedTemplateStart,
|
||||
scanQuotedTemplateEnd,
|
||||
scanTemplateInterpolationStart,
|
||||
scanTemplateInterpolationEnd,
|
||||
scanTemplateDirectiveStart,
|
||||
scanTemplateDirectiveEnd,
|
||||
scanHeredocIdentifier,
|
||||
scanTemplateLiteralChunk,
|
||||
0,
|
||||
1,
|
||||
],
|
||||
topRules: { ConfigFile: [0, 11] },
|
||||
dynamicPrecedences: { "19": 1, "22": 1 },
|
||||
specialized: [{ term: 14, get: (value: string) => spec_Identifier.get(value) ?? -1 }],
|
||||
propSources: [hclHighlighting],
|
||||
tokenPrec: 2481,
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// Generated by lezer-generator, taken from codemirror-lang-hcl@0.2.0 (MIT, see LICENSE).
|
||||
// Vendored for the same reason as packages/highlight/src/nix: only Lezer primitives load here.
|
||||
export const QuotedTemplateStart = 1;
|
||||
export const QuotedTemplateEnd = 2;
|
||||
export const TemplateInterpolationStart = 3;
|
||||
export const TemplateInterpolationEnd = 4;
|
||||
export const TemplateDirectiveStart = 5;
|
||||
export const TemplateDirectiveEnd = 6;
|
||||
export const HeredocIdentifier = 7;
|
||||
export const templateLiteralChunk = 105;
|
||||
@@ -1,317 +0,0 @@
|
||||
// Taken from codemirror-lang-hcl@0.2.0 (MIT, see LICENSE).
|
||||
// Vendored for the same reason as packages/highlight/src/nix: only Lezer primitives load here.
|
||||
import { ContextTracker, ExternalTokenizer, type InputStream } from "@lezer/lr";
|
||||
import {
|
||||
QuotedTemplateStart,
|
||||
QuotedTemplateEnd,
|
||||
TemplateInterpolationStart,
|
||||
TemplateInterpolationEnd,
|
||||
TemplateDirectiveStart,
|
||||
TemplateDirectiveEnd,
|
||||
HeredocIdentifier,
|
||||
templateLiteralChunk,
|
||||
} from "./terms.js";
|
||||
|
||||
const ContextType = {
|
||||
QUOTED_TEMPLATE: 1,
|
||||
TEMPLATE_INTERPOLATION: 2,
|
||||
TEMPLATE_DIRECTIVE: 4,
|
||||
HEREDOC_TEMPLATE: 8,
|
||||
};
|
||||
|
||||
const hashStr = function (str: string) {
|
||||
var hash = 0,
|
||||
i,
|
||||
chr;
|
||||
if (str.length === 0) return hash;
|
||||
for (i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
class Context {
|
||||
readonly parent: Context | null;
|
||||
readonly type: number;
|
||||
readonly heredoc_identifier: string;
|
||||
readonly hash: number;
|
||||
|
||||
constructor(parent: Context | null, type: number, heredoc_identifier: string) {
|
||||
this.parent = parent;
|
||||
this.type = type;
|
||||
this.heredoc_identifier = heredoc_identifier;
|
||||
this.hash =
|
||||
(parent ? (parent.hash + parent.hash) << 8 : 0) +
|
||||
(type << 4) +
|
||||
(this.heredoc_identifier === "" ? 0 : hashStr(this.heredoc_identifier));
|
||||
}
|
||||
}
|
||||
|
||||
function readTokenText(input: InputStream, end: number): string {
|
||||
let text = "";
|
||||
for (let offset = 0; offset < end - input.pos; offset++) {
|
||||
text += String.fromCharCode(input.peek(offset));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
const topCtx = new Context(null, 0, "");
|
||||
|
||||
export const trackTemplate = new ContextTracker({
|
||||
start: topCtx,
|
||||
shift(context, term, stack, input) {
|
||||
if (term === QuotedTemplateStart) {
|
||||
return new Context(context, ContextType.QUOTED_TEMPLATE, "");
|
||||
}
|
||||
if (term === TemplateInterpolationStart) {
|
||||
return new Context(context, ContextType.TEMPLATE_INTERPOLATION, "");
|
||||
}
|
||||
if (term === TemplateDirectiveStart) {
|
||||
return new Context(context, ContextType.TEMPLATE_DIRECTIVE, "");
|
||||
}
|
||||
if (term === HeredocIdentifier && context.type !== ContextType.HEREDOC_TEMPLATE) {
|
||||
const ctx = new Context(
|
||||
context,
|
||||
ContextType.HEREDOC_TEMPLATE,
|
||||
readTokenText(input, stack.pos),
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
if (term === HeredocIdentifier && context.type === ContextType.HEREDOC_TEMPLATE) {
|
||||
return context.parent ?? context;
|
||||
}
|
||||
switch (term) {
|
||||
case QuotedTemplateEnd:
|
||||
case TemplateInterpolationEnd:
|
||||
case TemplateDirectiveEnd:
|
||||
return context.parent ?? context;
|
||||
}
|
||||
|
||||
return context;
|
||||
},
|
||||
hash(context) {
|
||||
return context.hash;
|
||||
},
|
||||
});
|
||||
|
||||
const HEX_DIGIT = /[0-9a-fA-F]/;
|
||||
|
||||
function scanEscapeSequence(input: InputStream): void {
|
||||
input.advance();
|
||||
const kind = String.fromCharCode(input.next);
|
||||
if (kind === "u" || kind === "U") {
|
||||
const digits = kind === "u" ? 4 : 8;
|
||||
for (let i = 0; i < digits; i++) {
|
||||
input.advance();
|
||||
if (!HEX_DIGIT.test(String.fromCharCode(input.next))) return;
|
||||
}
|
||||
} else if (!'"nrt\\'.includes(kind)) {
|
||||
return;
|
||||
}
|
||||
input.advance();
|
||||
input.acceptToken(templateLiteralChunk);
|
||||
}
|
||||
|
||||
function scanHeredocBody(input: InputStream, expected: string): void {
|
||||
let has_leading_whitespace_with_newline = false;
|
||||
let has_leading_whitespace = false;
|
||||
let next = String.fromCharCode(input.next);
|
||||
while (/\s/.test(next)) {
|
||||
has_leading_whitespace = true;
|
||||
if (next === "\n") {
|
||||
has_leading_whitespace_with_newline = true;
|
||||
}
|
||||
input.advance();
|
||||
next = String.fromCharCode(input.next);
|
||||
}
|
||||
if (has_leading_whitespace) {
|
||||
input.acceptToken(templateLiteralChunk);
|
||||
}
|
||||
|
||||
if (!has_leading_whitespace_with_newline) {
|
||||
return;
|
||||
}
|
||||
|
||||
let advance = 0;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (String.fromCharCode(input.next) === expected[i]) {
|
||||
input.advance();
|
||||
advance++;
|
||||
} else {
|
||||
if (advance) {
|
||||
input.acceptToken(templateLiteralChunk);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// check if the identifier is on a line of its own
|
||||
let i = 0;
|
||||
while (true) {
|
||||
const ch = input.peek(i);
|
||||
if (ch == -1 || String.fromCharCode(ch) === "\n") {
|
||||
return input.acceptToken(HeredocIdentifier);
|
||||
}
|
||||
|
||||
if (!/\s/.test(String.fromCharCode(ch))) {
|
||||
return input.acceptToken(templateLiteralChunk);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// `$${` and `%%{` are escapes. The doubled marker counts only when `{` follows it, so in `$$${`
|
||||
// the first `$` is literal text and the rest is the escape.
|
||||
function scanTemplateMarker(input: InputStream, marker: string, startToken: number): void {
|
||||
input.advance();
|
||||
if (String.fromCharCode(input.next) === "{") {
|
||||
input.advance();
|
||||
return input.acceptToken(startToken);
|
||||
}
|
||||
if (String.fromCharCode(input.next) === marker && String.fromCharCode(input.peek(1)) === "{") {
|
||||
input.advance();
|
||||
input.advance();
|
||||
}
|
||||
input.acceptToken(templateLiteralChunk);
|
||||
}
|
||||
|
||||
export const scanQuotedTemplateStart = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(QuotedTemplateStart) &&
|
||||
!(stack.context && stack.context.type === ContextType.QUOTED_TEMPLATE) &&
|
||||
String.fromCharCode(input.next) === '"'
|
||||
) {
|
||||
input.advance();
|
||||
return input.acceptToken(QuotedTemplateStart);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
export const scanQuotedTemplateEnd = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(QuotedTemplateEnd) &&
|
||||
stack.context &&
|
||||
stack.context.type === ContextType.QUOTED_TEMPLATE &&
|
||||
String.fromCharCode(input.next) === '"'
|
||||
) {
|
||||
input.advance();
|
||||
return input.acceptToken(QuotedTemplateEnd);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
|
||||
export const scanTemplateLiteralChunk = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(templateLiteralChunk) &&
|
||||
stack.context &&
|
||||
stack.context.type === ContextType.QUOTED_TEMPLATE
|
||||
) {
|
||||
if (String.fromCharCode(input.next) === "\\") {
|
||||
return scanEscapeSequence(input);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
stack.canShift(templateLiteralChunk) &&
|
||||
stack.context &&
|
||||
(stack.context.type === ContextType.QUOTED_TEMPLATE ||
|
||||
stack.context.type === ContextType.HEREDOC_TEMPLATE) &&
|
||||
input.next !== -1
|
||||
) {
|
||||
input.advance();
|
||||
return input.acceptToken(templateLiteralChunk);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
export const scanTemplateInterpolationStart = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(TemplateInterpolationStart) &&
|
||||
stack.canShift(templateLiteralChunk) &&
|
||||
!(stack.context && stack.context.type === ContextType.TEMPLATE_INTERPOLATION) &&
|
||||
String.fromCharCode(input.next) === "$"
|
||||
) {
|
||||
return scanTemplateMarker(input, "$", TemplateInterpolationStart);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
export const scanTemplateInterpolationEnd = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(TemplateInterpolationEnd) &&
|
||||
stack.context &&
|
||||
stack.context.type === ContextType.TEMPLATE_INTERPOLATION &&
|
||||
String.fromCharCode(input.next) === "}"
|
||||
) {
|
||||
input.advance();
|
||||
return input.acceptToken(TemplateInterpolationEnd);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
export const scanTemplateDirectiveStart = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(TemplateDirectiveStart) &&
|
||||
stack.canShift(templateLiteralChunk) &&
|
||||
!(stack.context && stack.context.type === ContextType.TEMPLATE_DIRECTIVE) &&
|
||||
String.fromCharCode(input.next) === "%"
|
||||
) {
|
||||
return scanTemplateMarker(input, "%", TemplateDirectiveStart);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
export const scanTemplateDirectiveEnd = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(TemplateDirectiveEnd) &&
|
||||
stack.context &&
|
||||
stack.context.type === ContextType.TEMPLATE_DIRECTIVE &&
|
||||
String.fromCharCode(input.next) === "}"
|
||||
) {
|
||||
input.advance();
|
||||
return input.acceptToken(TemplateDirectiveEnd);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
|
||||
export const scanHeredocIdentifier = new ExternalTokenizer(
|
||||
(input, stack) => {
|
||||
if (
|
||||
stack.canShift(HeredocIdentifier) &&
|
||||
!(stack.context && stack.context.type === ContextType.HEREDOC_TEMPLATE) &&
|
||||
input.next !== -1
|
||||
) {
|
||||
let next = String.fromCharCode(input.next);
|
||||
while (/[\p{L}\p{N}_-]/u.test(next)) {
|
||||
input.advance();
|
||||
next = String.fromCharCode(input.next);
|
||||
}
|
||||
|
||||
return input.acceptToken(HeredocIdentifier);
|
||||
}
|
||||
|
||||
if (
|
||||
stack.canShift(HeredocIdentifier) &&
|
||||
stack.canShift(templateLiteralChunk) &&
|
||||
stack.context &&
|
||||
stack.context.type === ContextType.HEREDOC_TEMPLATE &&
|
||||
input.next != -1
|
||||
) {
|
||||
return scanHeredocBody(input, stack.context.heredoc_identifier);
|
||||
}
|
||||
},
|
||||
{ contextual: true, fallback: true },
|
||||
);
|
||||
@@ -20,7 +20,6 @@ import { csharpLanguage } from "./csharp/language.js";
|
||||
import { astroParser } from "./astro/parser.js";
|
||||
import { vueParser } from "./vue/parser.js";
|
||||
import { nixLanguage } from "./nix/language.js";
|
||||
import { hclLanguage } from "./hcl/language.js";
|
||||
import { parser as svelteBaseParser } from "./svelte/parser.js";
|
||||
import { configureNesting, defaultNesting } from "./svelte/nesting.js";
|
||||
|
||||
@@ -83,10 +82,6 @@ const languagesByExtension: Record<string, Language> = {
|
||||
cs: csharpLanguage,
|
||||
// Nix
|
||||
nix: nixLanguage,
|
||||
// Terraform / HCL
|
||||
tf: hclLanguage,
|
||||
tfvars: hclLanguage,
|
||||
hcl: hclLanguage,
|
||||
// Elixir
|
||||
ex: language(elixirParser),
|
||||
exs: language(elixirParser),
|
||||
|
||||
@@ -469,7 +469,7 @@ class TestAgentSession implements AgentSession {
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
this.interrupted = false;
|
||||
const turnId = `turn-${this.id}-${++this.turnIdCounter}`;
|
||||
const turnId = `turn-${++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,35 +13487,3 @@ 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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -690,7 +690,6 @@ 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;
|
||||
|
||||
@@ -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).toEqual(expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/));
|
||||
expect(firstRunId).toBe("autonomous-turn-1");
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
@@ -1360,9 +1360,7 @@ 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).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
@@ -1412,9 +1410,7 @@ 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).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.turnState).toBe("autonomous");
|
||||
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
|
||||
@@ -1450,9 +1446,7 @@ 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).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-2$/),
|
||||
);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
@@ -1489,9 +1483,7 @@ 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).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
// Safety net: Claude says nothing at all is running any more.
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
@@ -1693,9 +1685,7 @@ 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).toEqual(
|
||||
expect.stringMatching(/^autonomous-turn-[0-9a-f-]{36}-1$/),
|
||||
);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
|
||||
await internal.routeSdkMessageFromPump(
|
||||
|
||||
@@ -1345,30 +1345,6 @@ 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,7 +2378,6 @@ 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;
|
||||
@@ -3984,7 +3983,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(owner: "foreground" | "autonomous"): string {
|
||||
return `${owner}-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
return `${owner}-turn-${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 }),
|
||||
);
|
||||
const firstTurn = await firstSession.startTurn("first turn");
|
||||
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,8 +1378,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({
|
||||
thinkingOptionId: "medium",
|
||||
});
|
||||
const resumedTurn = await resumed.startTurn("turn after restart");
|
||||
expect(resumedTurn.turnId).not.toBe(firstTurn.turnId);
|
||||
await resumed.startTurn("turn after restart");
|
||||
await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" });
|
||||
beforeRestart.assertNoErrors();
|
||||
afterRestart.assertNoErrors();
|
||||
@@ -4854,7 +4853,6 @@ 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");
|
||||
@@ -4872,7 +4870,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(child).resolves.toMatchObject({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
turnId: (await started).turnId,
|
||||
turnId: "codex-turn-0",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "legacy-only-child-thread",
|
||||
@@ -4882,7 +4880,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(spawn).resolves.toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: (await started).turnId,
|
||||
turnId: "codex-turn-0",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "spawn-legacy-only-child",
|
||||
|
||||
@@ -3529,7 +3529,6 @@ 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;
|
||||
@@ -5720,7 +5719,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(): string {
|
||||
return `codex-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -32,16 +32,6 @@ 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 {
|
||||
@@ -767,19 +757,6 @@ 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" },
|
||||
@@ -1661,10 +1638,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([
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
turnIdAt(turn.events, 0),
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1856,7 +1833,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "error",
|
||||
message: "Provider retry (attempt 1): model does not exist",
|
||||
},
|
||||
turnId: turnIdAt(events, 0),
|
||||
turnId: "opencode-turn-0",
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
await session.close();
|
||||
@@ -2664,11 +2641,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(
|
||||
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
||||
).toEqual([
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["turn_canceled", turnIdAt(events, 0)],
|
||||
["turn_started", turnIdAt(events, 1)],
|
||||
["timeline", turnIdAt(events, 1)],
|
||||
["turn_completed", turnIdAt(events, 1)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["turn_canceled", "opencode-turn-0"],
|
||||
["turn_started", "opencode-turn-1"],
|
||||
["timeline", "opencode-turn-1"],
|
||||
["turn_completed", "opencode-turn-1"],
|
||||
]);
|
||||
} finally {
|
||||
settleRetry.resolve();
|
||||
@@ -2729,7 +2706,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: turnIdAt(events, 1),
|
||||
turnId: "opencode-turn-1",
|
||||
item: expect.objectContaining({ text: "Wake output streams live." }),
|
||||
}),
|
||||
),
|
||||
@@ -2749,11 +2726,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_completed", turnId: turnIdAt(events, 1) }),
|
||||
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
|
||||
),
|
||||
);
|
||||
await expect(session.startTurn("after the wake")).resolves.toEqual({
|
||||
turnId: turnIdAt(events, 2),
|
||||
turnId: "opencode-turn-2",
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2866,13 +2843,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: turnIdAt(events, 1),
|
||||
turnId: "opencode-turn-1",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
turnId: turnIdAt(events, 1),
|
||||
turnId: "opencode-turn-1",
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 7,
|
||||
outputTokens: 3,
|
||||
@@ -2881,7 +2858,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
const autonomousCompletion = events.find(
|
||||
(event) => event.type === "turn_completed" && event.turnId === turnIdAt(events, 1),
|
||||
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
|
||||
);
|
||||
expect(autonomousCompletion?.type).toBe("turn_completed");
|
||||
if (autonomousCompletion?.type === "turn_completed") {
|
||||
@@ -2890,7 +2867,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: turnIdAt(events, 1),
|
||||
turnId: "opencode-turn-1",
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Autonomous wake completed.",
|
||||
@@ -2898,7 +2875,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
|
||||
turnId: turnIdAt(events, 2),
|
||||
turnId: "opencode-turn-2",
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2977,7 +2954,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: turnIdAt(events, 0),
|
||||
turnId: "opencode-turn-0",
|
||||
});
|
||||
|
||||
openCode.emitEvent({
|
||||
@@ -2985,7 +2962,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
properties: { sessionID: "ses_retried_stop" },
|
||||
});
|
||||
await expect(session.startTurn("replacement")).resolves.toEqual({
|
||||
turnId: turnIdAt(events, 1),
|
||||
turnId: "opencode-turn-1",
|
||||
});
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
} finally {
|
||||
@@ -3081,7 +3058,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: turnIdAt(events, 1),
|
||||
turnId: "opencode-turn-1",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3790,7 +3767,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
];
|
||||
const replacement = session.startTurn("second");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
expect(countEvents(events, "turn_canceled")).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -4032,9 +4009,6 @@ 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();
|
||||
@@ -4048,7 +4022,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(openCode.calls.sessionStatus).toHaveLength(1);
|
||||
|
||||
releaseIdle.resolve();
|
||||
await expect(replacement).resolves.toEqual({ turnId: turnIdAt(events, 1) });
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
@@ -4748,9 +4722,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
"turn_completed",
|
||||
]);
|
||||
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
turnIdAt(events, 0),
|
||||
turnIdAt(events, 0),
|
||||
turnIdAt(events, 0),
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4786,7 +4760,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: turnIdAt(events, 0),
|
||||
turnId: "opencode-turn-0",
|
||||
});
|
||||
});
|
||||
for (const event of assistantTurnEvents({
|
||||
@@ -4800,9 +4774,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4907,9 +4881,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
]);
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
@@ -4953,9 +4927,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", turnIdAt(events, 0)],
|
||||
["timeline", turnIdAt(events, 0)],
|
||||
["turn_completed", turnIdAt(events, 0)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
@@ -5029,7 +5003,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5144,12 +5118,12 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await streamDrained.promise;
|
||||
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: turnIdAt(events, 0) },
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: turnIdAt(events, 0),
|
||||
turnId: "opencode-turn-0",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
@@ -5200,7 +5174,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect.objectContaining({
|
||||
type: "permission_requested",
|
||||
request: expect.objectContaining({ id: "perm_child_external" }),
|
||||
turnId: turnIdAt(events, 0),
|
||||
turnId: "opencode-turn-0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3409,7 +3409,6 @@ 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" };
|
||||
/**
|
||||
@@ -4887,7 +4886,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
private createTurnId(): string {
|
||||
this.gapRepairRevision += 1;
|
||||
return `opencode-turn-${this.turnNamespace}-${this.nextTurnOrdinal++}`;
|
||||
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
|
||||
|
||||
@@ -19,21 +19,15 @@
|
||||
# 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, 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.
|
||||
# 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.
|
||||
# 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.
|
||||
@@ -54,7 +48,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..5ebd8e9 100644
|
||||
index eeb3fb0..5fe940d 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
|
||||
@@ -141,28 +135,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
}
|
||||
if (dataChanged && idsInView.length > 0 && state.didContainersLayout) {
|
||||
for (let i = 0; i < idsInView.length; i++) {
|
||||
@@ -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) {
|
||||
@@ -2371,7 +2394,10 @@ function finishScrollTo(ctx) {
|
||||
};
|
||||
if (scrollingTo.isInitialScroll || state.initialScroll) {
|
||||
const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset";
|
||||
@@ -174,7 +147,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
finishInitialScroll(ctx, {
|
||||
onFinished: complete,
|
||||
preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget,
|
||||
@@ -2515,11 +2537,12 @@ function checkFinishedScrollFallback(ctx) {
|
||||
@@ -2515,11 +2541,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);
|
||||
@@ -191,7 +164,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
|
||||
} else if (shouldRetryUnalignedEndScroll) {
|
||||
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
|
||||
@@ -3378,7 +3401,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
@@ -3378,7 +3405,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
var _a3;
|
||||
const state = ctx.state;
|
||||
clearBootstrapInitialScrollSession(state);
|
||||
@@ -201,7 +174,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
finishInitialScroll(ctx, {
|
||||
preserveTarget: shouldPreserveResizeTarget,
|
||||
recalculateItems: true,
|
||||
@@ -4569,6 +4593,24 @@ function getIdsInVisibleRange(state, range) {
|
||||
@@ -4569,6 +4597,17 @@ function getIdsInVisibleRange(state, range) {
|
||||
const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
|
||||
idsInView.push(id);
|
||||
}
|
||||
@@ -215,18 +188,11 @@ index eeb3fb0..5ebd8e9 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 +5218,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
@@ -5176,8 +5215,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
function isWithinEpsilon(delta) {
|
||||
return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON;
|
||||
}
|
||||
@@ -242,7 +208,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5291,6 +5338,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
@@ -254,7 +220,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5414,7 +5466,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -263,7 +229,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6283,6 +6335,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -274,7 +240,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6290,7 +6346,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -283,7 +249,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6513,6 +6569,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -298,7 +264,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6651,7 +6715,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
|
||||
@@ -307,7 +273,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
|
||||
@@ -6665,7 +6729,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -316,7 +282,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7886,7 +7950,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -328,7 +294,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7989,6 +8056,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -336,7 +302,7 @@ index eeb3fb0..5ebd8e9 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8282,6 +8350,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
dataVersion,
|
||||
drawDistance,
|
||||
estimatedItemSize,
|
||||
@@ -345,7 +311,7 @@ index eeb3fb0..5ebd8e9 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..9788ce6 100644
|
||||
index fe5f415..7f1b9dd 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
|
||||
@@ -432,28 +398,7 @@ index fe5f415..9788ce6 100644
|
||||
}
|
||||
if (dataChanged && idsInView.length > 0 && state.didContainersLayout) {
|
||||
for (let i = 0; i < idsInView.length; i++) {
|
||||
@@ -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) {
|
||||
@@ -2350,7 +2373,10 @@ function finishScrollTo(ctx) {
|
||||
};
|
||||
if (scrollingTo.isInitialScroll || state.initialScroll) {
|
||||
const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset";
|
||||
@@ -465,7 +410,7 @@ index fe5f415..9788ce6 100644
|
||||
finishInitialScroll(ctx, {
|
||||
onFinished: complete,
|
||||
preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget,
|
||||
@@ -2494,11 +2516,12 @@ function checkFinishedScrollFallback(ctx) {
|
||||
@@ -2494,11 +2520,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);
|
||||
@@ -482,7 +427,7 @@ index fe5f415..9788ce6 100644
|
||||
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
|
||||
} else if (shouldRetryUnalignedEndScroll) {
|
||||
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
|
||||
@@ -3357,7 +3380,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
@@ -3357,7 +3384,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
|
||||
var _a3;
|
||||
const state = ctx.state;
|
||||
clearBootstrapInitialScrollSession(state);
|
||||
@@ -492,7 +437,7 @@ index fe5f415..9788ce6 100644
|
||||
finishInitialScroll(ctx, {
|
||||
preserveTarget: shouldPreserveResizeTarget,
|
||||
recalculateItems: true,
|
||||
@@ -4548,6 +4572,24 @@ function getIdsInVisibleRange(state, range) {
|
||||
@@ -4548,6 +4576,17 @@ function getIdsInVisibleRange(state, range) {
|
||||
const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
|
||||
idsInView.push(id);
|
||||
}
|
||||
@@ -506,18 +451,11 @@ index fe5f415..9788ce6 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 +5197,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
@@ -5155,8 +5194,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
|
||||
function isWithinEpsilon(delta) {
|
||||
return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON;
|
||||
}
|
||||
@@ -533,7 +471,7 @@ index fe5f415..9788ce6 100644
|
||||
}
|
||||
|
||||
// src/core/updateItemSizes.ts
|
||||
@@ -5270,6 +5317,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
|
||||
const result = {};
|
||||
const previousTotalSize = state.totalSize;
|
||||
for (const measurement of measurements) {
|
||||
@@ -545,7 +483,7 @@ index fe5f415..9788ce6 100644
|
||||
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
|
||||
if (ownsMeasuredItem) {
|
||||
const index = state.indexByKey.get(measurement.itemKey);
|
||||
@@ -5393,7 +5445,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
|
||||
state.fixedItemSizeKeys.add(itemKey);
|
||||
}
|
||||
const prevSizeKnown = sizesKnown.get(itemKey);
|
||||
@@ -554,7 +492,7 @@ index fe5f415..9788ce6 100644
|
||||
return 0;
|
||||
}
|
||||
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
|
||||
@@ -6262,6 +6314,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
|
||||
|
||||
// src/components/Containers.native.tsx
|
||||
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
|
||||
@@ -565,7 +503,7 @@ index fe5f415..9788ce6 100644
|
||||
var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
children,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6269,7 +6325,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
|
||||
}) {
|
||||
const ctx = useStateContext();
|
||||
const columnWrapperStyle = ctx.columnWrapperStyle;
|
||||
@@ -574,7 +512,7 @@ index fe5f415..9788ce6 100644
|
||||
const minSize = useCommittedMinSize();
|
||||
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
|
||||
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
|
||||
@@ -6492,6 +6548,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -589,7 +527,7 @@ index fe5f415..9788ce6 100644
|
||||
var ListComponent = typedMemo(function ListComponent2({
|
||||
canRender,
|
||||
freshDataTransitionEpoch,
|
||||
@@ -6630,7 +6694,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
|
||||
},
|
||||
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
|
||||
@@ -598,7 +536,7 @@ index fe5f415..9788ce6 100644
|
||||
ListEmptyComponent && getComponent(ListEmptyComponent),
|
||||
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
|
||||
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
|
||||
@@ -6644,7 +6708,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
|
||||
stickyHeaderConfig
|
||||
}
|
||||
),
|
||||
@@ -607,7 +545,7 @@ index fe5f415..9788ce6 100644
|
||||
IS_DEV && ENABLE_DEVMODE
|
||||
);
|
||||
});
|
||||
@@ -7865,7 +7929,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
|
||||
const state = ctx.state;
|
||||
if (stylePaddingTop !== void 0) {
|
||||
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
|
||||
@@ -619,7 +557,7 @@ index fe5f415..9788ce6 100644
|
||||
let prevTotalSize = peek$(ctx, "totalSize") || 0;
|
||||
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
|
||||
state.timeoutSetPaddingTop = setTimeout(() => {
|
||||
@@ -7968,6 +8035,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
estimatedListSize,
|
||||
extraData,
|
||||
getFixedItemSize,
|
||||
@@ -627,7 +565,7 @@ index fe5f415..9788ce6 100644
|
||||
getItemType,
|
||||
horizontal,
|
||||
rtl,
|
||||
@@ -8261,6 +8329,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
|
||||
@@ -8261,6 +8326,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`; unique across resumed sessions |
|
||||
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
|
||||
| `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` |
|
||||
|
||||
Reference in New Issue
Block a user