Fix bottom anchoring and Claude wake routing
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectDaemonClient,
|
||||
createReplyTurn,
|
||||
expectDetachedFromBottom,
|
||||
expectNearBottom,
|
||||
getChatContainerKey,
|
||||
readScrollMetrics,
|
||||
scrollUpFromBottom,
|
||||
seedBottomAnchorAgent,
|
||||
waitForAgentReady,
|
||||
waitForContentGrowth,
|
||||
} from "./helpers/agent-bottom-anchor";
|
||||
|
||||
test.describe.configure({ timeout: 180000 });
|
||||
|
||||
async function openWorkspaceAgentTab(page: Page, agentId: string) {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
test("direct load and refresh land at the bottom for history-backed chats", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-direct-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-direct-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("revisiting a loaded chat restores bottom anchoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-switch-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-switch-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.getByTestId("sidebar-new-agent").first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sticky mode stays pinned through composer growth and viewport resize, but detached mode does not fight streamed updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-sticky-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-sticky-${Date.now()}`,
|
||||
turnCount: 10,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1320, height: 920 });
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expectNearBottom(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.click();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await composer.pressSequentially(`composer growth line ${index + 1}`);
|
||||
if (index < 5) {
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
}
|
||||
}
|
||||
await expectNearBottom(page);
|
||||
await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 760 });
|
||||
await expectNearBottom(page);
|
||||
|
||||
await scrollUpFromBottom(page, 720);
|
||||
await expectDetachedFromBottom(page);
|
||||
const beforeExternalUpdate = await readScrollMetrics(page);
|
||||
|
||||
const externalTurn = createReplyTurn(`external-stream-${Date.now()}`);
|
||||
await client.sendAgentMessage(agent.id, externalTurn.message);
|
||||
await waitForContentGrowth(page, beforeExternalUpdate.contentHeight);
|
||||
const finish = await client.waitForFinish(agent.id, 120000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expectDetachedFromBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("web partial virtualization keeps bottom anchoring stable across direct load, refresh, and resize", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
(window as typeof window & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: number;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: number;
|
||||
}).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
});
|
||||
|
||||
const repo = await createTempGitRepo("paseo-e2e-bottom-anchor-virtualized-");
|
||||
const client = await connectDaemonClient();
|
||||
|
||||
try {
|
||||
const agent = await seedBottomAnchorAgent({
|
||||
client,
|
||||
cwd: repo.path,
|
||||
title: `bottom-anchor-virtualized-${Date.now()}`,
|
||||
turnCount: 4,
|
||||
});
|
||||
|
||||
await page.goto(agent.url, { waitUntil: "domcontentloaded" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await openWorkspaceAgentTab(page, agent.id);
|
||||
await waitForAgentReady(page, agent.expectedTailText);
|
||||
await expect
|
||||
.poll(async () => await getChatContainerKey(page))
|
||||
.toBe("web-partial-virtualized");
|
||||
await expectNearBottom(page);
|
||||
|
||||
await page.setViewportSize({ width: 780, height: 720 });
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "../../src/utils/host-routes";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
|
||||
export type ScrollMetrics = {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
|
||||
export type SeededAgent = {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
};
|
||||
|
||||
export type DaemonClientInstance = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number
|
||||
): Promise<{ status: string }>;
|
||||
};
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
return Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = (index + 1).toString().padStart(2, "0");
|
||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string): string {
|
||||
return ["REPLY:", buildReplyBlock(label)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
message: string;
|
||||
expectedReply: string;
|
||||
} {
|
||||
return {
|
||||
message: buildReplyMessage(label),
|
||||
expectedReply: buildReplyBlock(label),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js")
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedBottomAnchorAgent(input: {
|
||||
client: DaemonClientInstance;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id),
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(
|
||||
0,
|
||||
contentHeight - (offsetY + viewportHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("agent-chat-scroll").evaluate(
|
||||
(root: Element, amount: number) => {
|
||||
const rootElement = root as HTMLElement;
|
||||
const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))];
|
||||
const scrollElement =
|
||||
candidates.find(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.scrollHeight - element.clientHeight > 1
|
||||
) ?? rootElement;
|
||||
|
||||
const bottomOffset = Math.max(
|
||||
0,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
scrollElement.scrollTop = Math.max(0, bottomOffset - amount);
|
||||
},
|
||||
pixels
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
||||
if (expectedTailText) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNearBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number
|
||||
): Promise<ScrollMetrics> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return page
|
||||
.getByTestId("agent-chat-scroll")
|
||||
.evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,7 @@ interface AgentInputAreaProps {
|
||||
commandDraftConfig?: DraftCommandConfig
|
||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||
onMessageSent?: () => void
|
||||
onComposerHeightChange?: (height: number) => void
|
||||
/** Controlled status controls rendered in input area (draft flows). */
|
||||
statusControls?: DraftAgentStatusBarProps
|
||||
}
|
||||
@@ -85,6 +86,7 @@ export function AgentInputArea({
|
||||
onAddImages,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
onComposerHeightChange,
|
||||
statusControls,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
@@ -823,6 +825,7 @@ export function AgentInputArea({
|
||||
setCursorIndex(selection.start)
|
||||
}}
|
||||
onFocusChange={setIsMessageInputFocused}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
resolveStreamRenderStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
|
||||
@@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
expect(strategy.getFlatListInverted()).toBe(false);
|
||||
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
|
||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
|
||||
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses inverted_stream on native", () => {
|
||||
@@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => {
|
||||
expect(strategy.getFlatListInverted()).toBe(true);
|
||||
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
|
||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
|
||||
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
});
|
||||
});
|
||||
|
||||
it("delays native verification while viewport settling is in flight", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "ios",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: true,
|
||||
})
|
||||
).toEqual({
|
||||
verificationDelayFrames: 4,
|
||||
verificationRetryMode: "recheck",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inflate forward-stream verification delays during web resize", () => {
|
||||
const strategy = resolveStreamRenderStrategy({
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy,
|
||||
isViewportSettling: true,
|
||||
})
|
||||
).toEqual({
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ export type MaintainVisibleContentPositionConfig = Readonly<{
|
||||
autoscrollToTopThreshold: number;
|
||||
}>;
|
||||
|
||||
export type BottomAnchorTransportBehavior = Readonly<{
|
||||
verificationDelayFrames: number;
|
||||
verificationRetryMode: "rescroll" | "recheck";
|
||||
}>;
|
||||
|
||||
export type StreamViewportMetrics = {
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
@@ -29,7 +34,7 @@ export type StreamEdgeSlotProps = {
|
||||
};
|
||||
|
||||
export type StreamRenderRefs = {
|
||||
flatListRef: RefObject<FlatList<StreamItem> | null>;
|
||||
flatListRef: RefObject<FlatList<any> | null>;
|
||||
scrollViewRef: RefObject<ScrollView | null>;
|
||||
bottomAnchorRef: RefObject<View | null>;
|
||||
};
|
||||
@@ -58,6 +63,7 @@ export interface StreamRenderStrategy {
|
||||
getMaintainVisibleContentPosition: () =>
|
||||
| MaintainVisibleContentPositionConfig
|
||||
| undefined;
|
||||
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||
getFlatListInverted: () => boolean;
|
||||
getOverlayScrollbarInverted: () => boolean;
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||
@@ -76,6 +82,8 @@ export interface StreamRenderStrategy {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
||||
|
||||
type StreamRenderStrategyConfig = {
|
||||
orderTailReverse: boolean;
|
||||
orderHeadReverse: boolean;
|
||||
@@ -84,6 +92,7 @@ type StreamRenderStrategyConfig = {
|
||||
flatListInverted: boolean;
|
||||
overlayScrollbarInverted: boolean;
|
||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
|
||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||
anchorBottomOnContentSizeChange: boolean;
|
||||
animateManualScrollToBottom: boolean;
|
||||
@@ -137,28 +146,26 @@ function scrollAnchorIntoView(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
function forceScrollContainerToBottom(
|
||||
refs: StreamRenderRefs,
|
||||
fallbackOffset: number
|
||||
): void {
|
||||
const resolveNode = (
|
||||
input: unknown
|
||||
): HTMLElement | null => {
|
||||
if (!(input instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
if (input.scrollHeight - input.clientHeight > 1) {
|
||||
return input;
|
||||
}
|
||||
let node: HTMLElement | null = input.parentElement;
|
||||
while (node) {
|
||||
if (node.scrollHeight - node.clientHeight > 1) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
function resolvePotentialScrollNode(input: unknown): HTMLElement | null {
|
||||
if (!(input instanceof HTMLElement)) {
|
||||
return null;
|
||||
};
|
||||
}
|
||||
if (input.scrollHeight - input.clientHeight > 1) {
|
||||
return input;
|
||||
}
|
||||
let node: HTMLElement | null = input.parentElement;
|
||||
while (node) {
|
||||
if (node.scrollHeight - node.clientHeight > 1) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function resolveWebScrollContainerNode(
|
||||
refs: StreamRenderRefs
|
||||
): HTMLElement | null {
|
||||
|
||||
const scrollViewHandle = refs.scrollViewRef.current as
|
||||
| {
|
||||
@@ -185,18 +192,43 @@ function forceScrollContainerToBottom(
|
||||
|
||||
let scrollNode: HTMLElement | null = null;
|
||||
for (const candidate of candidates) {
|
||||
scrollNode = resolveNode(candidate);
|
||||
scrollNode = resolvePotentialScrollNode(candidate);
|
||||
if (scrollNode) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollNode && typeof document !== "undefined") {
|
||||
scrollNode = resolveNode(
|
||||
scrollNode = resolvePotentialScrollNode(
|
||||
document.querySelector("[data-testid='agent-chat-scroll']")
|
||||
);
|
||||
}
|
||||
|
||||
return scrollNode;
|
||||
}
|
||||
|
||||
export function resolveBottomAnchorTransportBehavior(input: {
|
||||
strategy: StreamRenderStrategy;
|
||||
isViewportSettling: boolean;
|
||||
}): BottomAnchorTransportBehavior {
|
||||
const baseBehavior = input.strategy.getBottomAnchorTransportBehavior();
|
||||
if (!input.isViewportSettling || !input.strategy.getFlatListInverted()) {
|
||||
return baseBehavior;
|
||||
}
|
||||
return {
|
||||
verificationDelayFrames: Math.max(
|
||||
baseBehavior.verificationDelayFrames,
|
||||
NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES
|
||||
),
|
||||
verificationRetryMode: "recheck",
|
||||
};
|
||||
}
|
||||
|
||||
function forceScrollContainerToBottom(
|
||||
refs: StreamRenderRefs,
|
||||
fallbackOffset: number
|
||||
): void {
|
||||
const scrollNode = resolveWebScrollContainerNode(refs);
|
||||
if (!scrollNode) {
|
||||
return;
|
||||
}
|
||||
@@ -267,6 +299,7 @@ function createStreamRenderStrategy(
|
||||
};
|
||||
},
|
||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
|
||||
getFlatListInverted: () => config.flatListInverted,
|
||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||
@@ -289,6 +322,10 @@ function createInvertedStreamStrategy(): StreamRenderStrategy {
|
||||
flatListInverted: true,
|
||||
overlayScrollbarInverted: true,
|
||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||
bottomAnchorTransportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: false,
|
||||
animateManualScrollToBottom: true,
|
||||
@@ -316,6 +353,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
|
||||
flatListInverted: false,
|
||||
overlayScrollbarInverted: false,
|
||||
maintainVisibleContentPosition: undefined,
|
||||
bottomAnchorTransportBehavior: {
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
},
|
||||
disableParentScrollOnInlineDetailsExpansion: false,
|
||||
anchorBottomOnContentSizeChange: true,
|
||||
animateManualScrollToBottom: false,
|
||||
@@ -335,6 +376,14 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
|
||||
0,
|
||||
metrics.contentHeight - metrics.viewportHeight
|
||||
);
|
||||
if (refs.flatListRef.current) {
|
||||
refs.flatListRef.current.scrollToEnd?.({ animated });
|
||||
refs.flatListRef.current.scrollToOffset?.({
|
||||
offset: bottomOffset,
|
||||
animated,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const usedAnchor = scrollAnchorIntoView({ refs, animated });
|
||||
if (!usedAnchor) {
|
||||
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
||||
@@ -347,6 +396,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
|
||||
forceScrollContainerToBottom(refs, bottomOffset);
|
||||
},
|
||||
scrollToOffset: ({ refs, offset, animated }) => {
|
||||
if (refs.flatListRef.current) {
|
||||
refs.flatListRef.current.scrollToOffset?.({ offset, animated });
|
||||
return;
|
||||
}
|
||||
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
LayoutChangeEvent,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
InteractionManager,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
} from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -75,9 +75,22 @@ import {
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
resolveWebScrollContainerNode,
|
||||
resolveStreamRenderStrategy,
|
||||
type StreamEdgeSlotProps,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import {
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./agent-stream-web-virtualization";
|
||||
import {
|
||||
useBottomAnchorController,
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
@@ -109,7 +122,8 @@ function renderStreamEdgeComponent(
|
||||
}
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
scrollToBottom(): void;
|
||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||
prepareForViewportChange(): void;
|
||||
}
|
||||
|
||||
export interface AgentStreamViewProps {
|
||||
@@ -118,6 +132,8 @@ export interface AgentStreamViewProps {
|
||||
agent: Agent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
}
|
||||
|
||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
||||
@@ -126,8 +142,10 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
}, ref) {
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const flatListRef = useRef<FlatList<any>>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const bottomAnchorRef = useRef<View>(null);
|
||||
const { theme } = useUnistyles();
|
||||
@@ -145,19 +163,23 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const pendingAnchorRequestRef = useRef(false);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollAnimatedRef = useRef(false);
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const previousStreamItemsRef = useRef<StreamItem[] | null>(null);
|
||||
const streamViewportMetricsRef = useRef({
|
||||
containerKey: "unknown",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null as string | null,
|
||||
contentMeasuredForKey: null as string | null,
|
||||
});
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
@@ -165,6 +187,38 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
|
||||
[]
|
||||
);
|
||||
const clearNativeViewportSettling = useCallback(() => {
|
||||
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||
cancelAnimationFrame(nativeViewportSettlingFrameIdRef.current);
|
||||
nativeViewportSettlingFrameIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const markNativeViewportSettling = useCallback(() => {
|
||||
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
|
||||
return;
|
||||
}
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(true);
|
||||
let remainingFrames = 4;
|
||||
const tick = () => {
|
||||
if (remainingFrames <= 0) {
|
||||
nativeViewportSettlingFrameIdRef.current = null;
|
||||
setIsNativeViewportSettling(false);
|
||||
return;
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
nativeViewportSettlingFrameIdRef.current = requestAnimationFrame(tick);
|
||||
}, [clearNativeViewportSettling, streamRenderStrategy]);
|
||||
const bottomAnchorTransportBehavior = useMemo(
|
||||
() =>
|
||||
resolveBottomAnchorTransportBehavior({
|
||||
strategy: streamRenderStrategy,
|
||||
isViewportSettling: isNativeViewportSettling,
|
||||
}),
|
||||
[isNativeViewportSettling, streamRenderStrategy]
|
||||
);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -194,12 +248,52 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
: FadeOut.duration(200);
|
||||
|
||||
useEffect(() => {
|
||||
hasScrolledInitially.current = false;
|
||||
hasAutoScrolledOnce.current = false;
|
||||
isNearBottomRef.current = true;
|
||||
pendingAnchorRequestRef.current = false;
|
||||
scrollOffsetYRef.current = 0;
|
||||
programmaticScrollEventBudgetRef.current = 0;
|
||||
previousStreamItemsRef.current = null;
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: "unknown",
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
}, [agentId]);
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web" || !streamRenderStrategy.getFlatListInverted()) {
|
||||
return;
|
||||
}
|
||||
const keyboardEvents = [
|
||||
"keyboardWillShow",
|
||||
"keyboardWillHide",
|
||||
"keyboardDidShow",
|
||||
"keyboardDidHide",
|
||||
"keyboardWillChangeFrame",
|
||||
"keyboardDidChangeFrame",
|
||||
] as const;
|
||||
const subscriptions = keyboardEvents.map((eventName) =>
|
||||
Keyboard.addListener(eventName, () => {
|
||||
markNativeViewportSettling();
|
||||
})
|
||||
);
|
||||
return () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.remove();
|
||||
}
|
||||
clearNativeViewportSettling();
|
||||
};
|
||||
}, [
|
||||
clearNativeViewportSettling,
|
||||
markNativeViewportSettling,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
|
||||
const handleInlinePathPress = useCallback(
|
||||
(target: InlinePathTarget) => {
|
||||
@@ -252,23 +346,112 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
setIsNearBottom(value);
|
||||
}, []);
|
||||
|
||||
const requestAnchorToBottom = useCallback(() => {
|
||||
pendingAnchorRequestRef.current = true;
|
||||
}, []);
|
||||
const flatListData = useMemo(() => {
|
||||
return orderTailForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamItems,
|
||||
});
|
||||
}, [streamItems, streamRenderStrategy]);
|
||||
const indexedFlatListData = useMemo<IndexedStreamItem[]>(
|
||||
() => flatListData.map((item, index) => ({ item, index })),
|
||||
[flatListData]
|
||||
);
|
||||
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
|
||||
const shouldUseWebPartialVirtualization =
|
||||
Platform.OS === "web" &&
|
||||
!isMobile &&
|
||||
!usesVirtualizedList &&
|
||||
indexedFlatListData.length > getWebPartialVirtualizationThreshold();
|
||||
const currentContainerKey = usesVirtualizedList
|
||||
? "native-virtualized"
|
||||
: shouldUseWebPartialVirtualization
|
||||
? "web-partial-virtualized"
|
||||
: "scroll-view";
|
||||
|
||||
useEffect(() => {
|
||||
const current = streamViewportMetricsRef.current;
|
||||
if (current.containerKey === currentContainerKey) {
|
||||
return;
|
||||
}
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: currentContainerKey,
|
||||
contentHeight: 0,
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
}, [currentContainerKey]);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const targetOffset = streamRenderStrategy.getBottomOffset(
|
||||
streamViewportMetricsRef.current
|
||||
);
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
streamRenderStrategy.scrollToBottom({
|
||||
refs: streamRenderRefs,
|
||||
metrics: streamViewportMetricsRef.current,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = targetOffset;
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
offsetY: targetOffset,
|
||||
};
|
||||
updateNearBottom(true);
|
||||
},
|
||||
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
|
||||
);
|
||||
|
||||
const bottomAnchorController = useBottomAnchorController({
|
||||
agentId,
|
||||
routeRequest: routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
renderStrategy:
|
||||
Platform.OS === "web" ? "forward-stream" : "inverted-stream",
|
||||
transportBehavior: bottomAnchorTransportBehavior,
|
||||
getMeasurementState: () => streamViewportMetricsRef.current,
|
||||
isNearBottom: () => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
return isNearBottomForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
offsetY: metrics.offsetY,
|
||||
threshold: Math.max(insets.bottom, 32),
|
||||
contentHeight: metrics.contentHeight,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
});
|
||||
},
|
||||
scrollToBottom: (animated) => {
|
||||
scrollToBottomInternal({ animated });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (previousStreamItemsRef.current === null) {
|
||||
previousStreamItemsRef.current = streamItems;
|
||||
return;
|
||||
}
|
||||
previousStreamItemsRef.current = streamItems;
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
}, [bottomAnchorController, streamItems]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const previousOffsetY = scrollOffsetYRef.current;
|
||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||
scrollOffsetYRef.current = contentOffset.y;
|
||||
streamViewportMetricsRef.current = {
|
||||
contentHeight: Math.max(0, contentSize.height),
|
||||
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
||||
containerKey: currentContainerKey,
|
||||
offsetY: contentOffset.y,
|
||||
viewportMeasuredForKey: currentContainerKey,
|
||||
contentMeasuredForKey: currentContainerKey,
|
||||
};
|
||||
const offsetDelta = contentOffset.y - previousOffsetY;
|
||||
const contentHeightDelta =
|
||||
streamViewportMetricsRef.current.contentHeight - previousContentHeight;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
@@ -277,23 +460,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
});
|
||||
updateNearBottom(nearBottom);
|
||||
const expectedBottomOffset = streamRenderStrategy.getBottomOffset(
|
||||
streamViewportMetricsRef.current
|
||||
);
|
||||
const isProgrammaticBottomEvent =
|
||||
Math.abs(contentOffset.y - expectedBottomOffset) <= 8;
|
||||
|
||||
const pendingAnchorBefore = pendingAnchorRequestRef.current;
|
||||
const shouldSuppressFalseNearBottom =
|
||||
pendingAnchorBefore &&
|
||||
!nearBottom &&
|
||||
Math.abs(offsetDelta) <= 1 &&
|
||||
contentHeightDelta > 0;
|
||||
if (shouldSuppressFalseNearBottom) {
|
||||
updateNearBottom(true);
|
||||
if (
|
||||
programmaticScrollEventBudgetRef.current > 0 &&
|
||||
isProgrammaticBottomEvent
|
||||
) {
|
||||
programmaticScrollEventBudgetRef.current -= 1;
|
||||
} else {
|
||||
updateNearBottom(nearBottom);
|
||||
}
|
||||
|
||||
const shouldClearPendingAnchor =
|
||||
pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1;
|
||||
if (shouldClearPendingAnchor) {
|
||||
pendingAnchorRequestRef.current = false;
|
||||
programmaticScrollEventBudgetRef.current = 0;
|
||||
bottomAnchorController.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: nearBottom,
|
||||
scrollDelta: contentOffset.y - previousOffsetY,
|
||||
});
|
||||
}
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
@@ -305,162 +489,151 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
showDesktopWebScrollbar,
|
||||
streamRenderStrategy,
|
||||
streamScrollbarMetrics,
|
||||
currentContainerKey,
|
||||
bottomAnchorController,
|
||||
updateNearBottom,
|
||||
]
|
||||
);
|
||||
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||
const viewportWidth = Math.max(0, event.nativeEvent.layout.width);
|
||||
const viewportHeight = Math.max(0, event.nativeEvent.layout.height);
|
||||
const viewportChanged =
|
||||
(previousViewportWidth > 0 && previousViewportWidth !== viewportWidth) ||
|
||||
(previousViewportHeight > 0 && previousViewportHeight !== viewportHeight);
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
viewportHeight: Math.max(0, event.nativeEvent.layout.height),
|
||||
containerKey: currentContainerKey,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
viewportMeasuredForKey: currentContainerKey,
|
||||
};
|
||||
if (viewportChanged) {
|
||||
markNativeViewportSettling();
|
||||
}
|
||||
bottomAnchorController.handleViewportMetricsChange({
|
||||
previousViewportWidth,
|
||||
viewportWidth,
|
||||
previousViewportHeight,
|
||||
viewportHeight,
|
||||
});
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
||||
);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const targetOffset = streamRenderStrategy.getBottomOffset(
|
||||
streamViewportMetricsRef.current
|
||||
);
|
||||
streamRenderStrategy.scrollToBottom({
|
||||
refs: streamRenderRefs,
|
||||
metrics: streamViewportMetricsRef.current,
|
||||
animated,
|
||||
});
|
||||
scrollOffsetYRef.current = targetOffset;
|
||||
updateNearBottom(true);
|
||||
},
|
||||
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
|
||||
[
|
||||
showDesktopWebScrollbar,
|
||||
streamScrollbarMetrics,
|
||||
currentContainerKey,
|
||||
markNativeViewportSettling,
|
||||
bottomAnchorController,
|
||||
]
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom() {
|
||||
requestAnchorToBottom();
|
||||
scrollToBottom(reason = "jump-to-bottom") {
|
||||
bottomAnchorController.requestLocalAnchor({ agentId, reason });
|
||||
},
|
||||
}), [requestAnchorToBottom]);
|
||||
prepareForViewportChange() {
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
},
|
||||
}), [agentId, bottomAnchorController]);
|
||||
|
||||
const handleContentSizeChange = useCallback(
|
||||
(width: number, height: number) => {
|
||||
const previousMetrics = streamViewportMetricsRef.current;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
const wasNearBottom = isNearBottomForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
threshold,
|
||||
contentHeight: previousMetrics.contentHeight,
|
||||
viewportHeight: previousMetrics.viewportHeight,
|
||||
});
|
||||
|
||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||
const nextContentHeight = Math.max(0, height);
|
||||
streamViewportMetricsRef.current = {
|
||||
...previousMetrics,
|
||||
contentHeight: Math.max(0, height),
|
||||
...streamViewportMetricsRef.current,
|
||||
containerKey: currentContainerKey,
|
||||
contentHeight: nextContentHeight,
|
||||
contentMeasuredForKey: currentContainerKey,
|
||||
};
|
||||
|
||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
hasAutoScrolledOnce.current = true;
|
||||
hasScrolledInitially.current = true;
|
||||
} else if (
|
||||
wasNearBottom ||
|
||||
isNearBottomRef.current ||
|
||||
pendingAnchorRequestRef.current
|
||||
) {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
}
|
||||
}
|
||||
bottomAnchorController.handleContentSizeChange({
|
||||
previousContentHeight,
|
||||
contentHeight: nextContentHeight,
|
||||
});
|
||||
|
||||
if (showDesktopWebScrollbar) {
|
||||
streamScrollbarMetrics.onContentSizeChange(width, height);
|
||||
}
|
||||
},
|
||||
[
|
||||
insets.bottom,
|
||||
scrollToBottomInternal,
|
||||
showDesktopWebScrollbar,
|
||||
streamRenderStrategy,
|
||||
streamScrollbarMetrics,
|
||||
currentContainerKey,
|
||||
bottomAnchorController,
|
||||
]
|
||||
);
|
||||
|
||||
const scheduleAutoScroll = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
pendingAutoScrollAnimatedRef.current =
|
||||
pendingAutoScrollAnimatedRef.current || animated;
|
||||
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingAutoScrollFrameRef.current = requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null;
|
||||
const shouldAnimate = pendingAutoScrollAnimatedRef.current;
|
||||
pendingAutoScrollAnimatedRef.current = false;
|
||||
scrollToBottomInternal({ animated: shouldAnimate });
|
||||
});
|
||||
},
|
||||
[scrollToBottomInternal]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingAutoScrollFrameRef.current !== null) {
|
||||
cancelAnimationFrame(pendingAutoScrollFrameRef.current);
|
||||
pendingAutoScrollFrameRef.current = null;
|
||||
}
|
||||
pendingAutoScrollAnimatedRef.current = false;
|
||||
if (
|
||||
Platform.OS !== "web" ||
|
||||
typeof ResizeObserver === "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollNode = resolveWebScrollContainerNode(streamRenderRefs);
|
||||
if (!scrollNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyObservedMetrics = () => {
|
||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||
const viewportWidth = Math.max(0, scrollNode.clientWidth);
|
||||
const viewportHeight = Math.max(0, scrollNode.clientHeight);
|
||||
const contentHeight = Math.max(0, scrollNode.scrollHeight);
|
||||
|
||||
streamViewportMetricsRef.current = {
|
||||
...streamViewportMetricsRef.current,
|
||||
containerKey: currentContainerKey,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
viewportMeasuredForKey: currentContainerKey,
|
||||
contentMeasuredForKey: currentContainerKey,
|
||||
};
|
||||
|
||||
bottomAnchorController.handleViewportMetricsChange({
|
||||
previousViewportWidth,
|
||||
viewportWidth,
|
||||
previousViewportHeight,
|
||||
viewportHeight,
|
||||
});
|
||||
bottomAnchorController.handleContentSizeChange({
|
||||
previousContentHeight,
|
||||
contentHeight,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamItems.length === 0) {
|
||||
return;
|
||||
applyObservedMetrics();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
applyObservedMetrics();
|
||||
});
|
||||
resizeObserver.observe(scrollNode);
|
||||
const contentNode = scrollNode.firstElementChild;
|
||||
if (contentNode instanceof HTMLElement) {
|
||||
resizeObserver.observe(contentNode);
|
||||
}
|
||||
|
||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
||||
// Forward streams anchor from measurement updates in handleContentSizeChange.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAutoScrolledOnce.current) {
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
scrollToBottomInternal({ animated: false });
|
||||
hasAutoScrolledOnce.current = true;
|
||||
hasScrolledInitially.current = true;
|
||||
});
|
||||
return () => handle.cancel();
|
||||
}
|
||||
|
||||
if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAnimate = hasScrolledInitially.current;
|
||||
scheduleAutoScroll({ animated: shouldAnimate });
|
||||
hasScrolledInitially.current = true;
|
||||
}, [
|
||||
scheduleAutoScroll,
|
||||
scrollToBottomInternal,
|
||||
streamItems,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [bottomAnchorController, currentContainerKey, streamRenderRefs]);
|
||||
|
||||
function scrollToBottom() {
|
||||
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
|
||||
scrollToBottomInternal({ animated });
|
||||
}
|
||||
|
||||
const flatListData = useMemo(() => {
|
||||
return orderTailForStreamRenderStrategy({
|
||||
strategy: streamRenderStrategy,
|
||||
streamItems,
|
||||
bottomAnchorController.requestLocalAnchor({
|
||||
agentId,
|
||||
reason: "jump-to-bottom",
|
||||
});
|
||||
}, [streamItems, streamRenderStrategy]);
|
||||
}
|
||||
|
||||
const orderedStreamHead = useMemo(() => {
|
||||
return orderHeadForStreamRenderStrategy({
|
||||
@@ -790,7 +963,35 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const showBottomBar = showWorkingIndicator;
|
||||
const usesVirtualizedList = streamRenderStrategy.shouldUseVirtualizedList();
|
||||
const webVirtualizedHistoryWindow = useMemo(() => {
|
||||
if (!shouldUseWebPartialVirtualization) {
|
||||
return null;
|
||||
}
|
||||
return splitWebVirtualizedHistory({
|
||||
entries: indexedFlatListData,
|
||||
minMountedCount: getWebMountedRecentStreamItems(),
|
||||
});
|
||||
}, [indexedFlatListData, shouldUseWebPartialVirtualization]);
|
||||
|
||||
const renderIndexedStreamItem = useCallback(
|
||||
(entry: IndexedStreamItem) =>
|
||||
renderStreamItem({
|
||||
item: entry.item,
|
||||
index: entry.index,
|
||||
separators: NOOP_SEPARATORS,
|
||||
}),
|
||||
[renderStreamItem]
|
||||
);
|
||||
const renderWebVirtualizedStreamItem = useCallback(
|
||||
({ item }: ListRenderItemInfo<IndexedStreamItem>) => {
|
||||
const rendered = renderIndexedStreamItem(item);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return <Fragment key={item.item.id}>{rendered}</Fragment>;
|
||||
},
|
||||
[renderIndexedStreamItem]
|
||||
);
|
||||
|
||||
const listEdgeSlotComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
@@ -929,22 +1130,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
listEdgeSlotProps.ListFooterComponent
|
||||
);
|
||||
const nonVirtualizedItems = useMemo(() => {
|
||||
if (flatListData.length === 0) {
|
||||
if (indexedFlatListData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return flatListData.map((item, index) => {
|
||||
const rendered = renderStreamItem({
|
||||
item,
|
||||
index,
|
||||
separators: NOOP_SEPARATORS,
|
||||
});
|
||||
return indexedFlatListData.map((entry) => {
|
||||
const rendered = renderIndexedStreamItem(entry);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return <Fragment key={item.id}>{rendered}</Fragment>;
|
||||
return <Fragment key={entry.item.id}>{rendered}</Fragment>;
|
||||
});
|
||||
}, [flatListData, renderStreamItem]);
|
||||
}, [indexedFlatListData, renderIndexedStreamItem]);
|
||||
const webVirtualizedListHeader = useMemo(() => {
|
||||
if (!shouldUseWebPartialVirtualization || !headerEdgeContent) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={listEdgeSlotProps.ListHeaderComponentStyle}>
|
||||
{headerEdgeContent}
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
headerEdgeContent,
|
||||
listEdgeSlotProps.ListHeaderComponentStyle,
|
||||
shouldUseWebPartialVirtualization,
|
||||
]);
|
||||
const webVirtualizedListFooter = useMemo(() => {
|
||||
if (!shouldUseWebPartialVirtualization || !webVirtualizedHistoryWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{webVirtualizedHistoryWindow.mountedEntries.map((entry) => (
|
||||
<Fragment key={entry.item.id}>
|
||||
{renderIndexedStreamItem(entry)}
|
||||
</Fragment>
|
||||
))}
|
||||
{footerEdgeContent ? (
|
||||
<View style={listEdgeSlotProps.ListFooterComponentStyle}>
|
||||
{footerEdgeContent}
|
||||
</View>
|
||||
) : null}
|
||||
<View ref={bottomAnchorRef} collapsable={false} />
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
footerEdgeContent,
|
||||
listEdgeSlotProps.ListFooterComponentStyle,
|
||||
renderIndexedStreamItem,
|
||||
shouldUseWebPartialVirtualization,
|
||||
webVirtualizedHistoryWindow,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
@@ -957,6 +1195,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
renderItem={renderStreamItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
||||
{...listEdgeSlotProps}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
@@ -975,10 +1214,34 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
inverted={streamRenderStrategy.getFlatListInverted()}
|
||||
/>
|
||||
) : shouldUseWebPartialVirtualization && webVirtualizedHistoryWindow ? (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={webVirtualizedHistoryWindow.virtualizedEntries}
|
||||
renderItem={renderWebVirtualizedStreamItem}
|
||||
keyExtractor={(entry) => entry.item.id}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
||||
ListHeaderComponent={webVirtualizedListHeader}
|
||||
ListFooterComponent={webVirtualizedListFooter}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
extraData={flatListExtraData}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
scrollEnabled={streamScrollEnabled}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
inverted={false}
|
||||
/>
|
||||
) : (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID="agent-chat-scroll"
|
||||
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={stylesheet.list}
|
||||
onLayout={handleListLayout}
|
||||
@@ -1028,6 +1291,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scroll to bottom"
|
||||
testID="scroll-to-bottom-button"
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
estimateStreamItemHeight,
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
splitWebVirtualizedHistory,
|
||||
type IndexedStreamItem,
|
||||
} from "./agent-stream-web-virtualization";
|
||||
|
||||
function createTimestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: createTimestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): StreamItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: createTimestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "test_tool",
|
||||
arguments: {},
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
|
||||
return items.map((item, index) => ({ item, index }));
|
||||
}
|
||||
|
||||
describe("findMountedWindowStart", () => {
|
||||
it("keeps all items mounted when the chat is below the threshold", () => {
|
||||
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
|
||||
|
||||
expect(
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 50,
|
||||
})
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("rewinds to the previous user boundary when the cutoff lands inside a turn", () => {
|
||||
const items: StreamItem[] = [];
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
const seed = index * 3;
|
||||
items.push(userMessage(`u${index}`, seed + 1));
|
||||
items.push(toolCall(`t${index}`, seed + 2));
|
||||
items.push(assistantMessage(`a${index}`, seed + 3));
|
||||
}
|
||||
|
||||
expect(
|
||||
findMountedWindowStart({
|
||||
items,
|
||||
minMountedCount: 50,
|
||||
})
|
||||
).toBe(39);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitWebVirtualizedHistory", () => {
|
||||
it("splits older entries into the virtualized section and keeps the recent window mounted", () => {
|
||||
const items: StreamItem[] = [];
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
const seed = index * 2;
|
||||
items.push(userMessage(`u${index}`, seed + 1));
|
||||
items.push(assistantMessage(`a${index}`, seed + 2));
|
||||
}
|
||||
|
||||
const window = splitWebVirtualizedHistory({
|
||||
entries: indexEntries(items),
|
||||
minMountedCount: 50,
|
||||
});
|
||||
|
||||
expect(window.virtualizedEntries).toHaveLength(10);
|
||||
expect(window.virtualizedEntries[0]?.item.id).toBe("u0");
|
||||
expect(window.virtualizedEntries.at(-1)?.item.id).toBe("a4");
|
||||
expect(window.mountedEntries[0]?.item.id).toBe("u5");
|
||||
expect(window.mountedEntries).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateStreamItemHeight", () => {
|
||||
it("uses a larger estimate for user messages with image attachments", () => {
|
||||
const item: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "u-image",
|
||||
text: "image",
|
||||
timestamp: createTimestamp(1),
|
||||
images: [
|
||||
{
|
||||
id: "att-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "desktop-file",
|
||||
storageKey: "/tmp/screenshot.png",
|
||||
fileName: "screenshot.png",
|
||||
byteSize: 1024,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web virtualization test overrides", () => {
|
||||
it("uses defaults unless explicit positive integer overrides are present", () => {
|
||||
const globalWithOverrides = globalThis as typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
const previousThreshold =
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
const previousMounted =
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
|
||||
try {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
expect(getWebPartialVirtualizationThreshold()).toBe(
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
|
||||
);
|
||||
expect(getWebMountedRecentStreamItems()).toBe(
|
||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS
|
||||
);
|
||||
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6;
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4;
|
||||
expect(getWebPartialVirtualizationThreshold()).toBe(6);
|
||||
expect(getWebMountedRecentStreamItems()).toBe(4);
|
||||
} finally {
|
||||
if (previousThreshold === undefined) {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
} else {
|
||||
globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD =
|
||||
previousThreshold;
|
||||
}
|
||||
if (previousMounted === undefined) {
|
||||
delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
} else {
|
||||
globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS =
|
||||
previousMounted;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
|
||||
function readPositiveIntegerOverride(value: unknown): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getWebPartialVirtualizationThreshold(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as BottomAnchorE2ETestGlobals)
|
||||
.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD
|
||||
);
|
||||
return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD;
|
||||
}
|
||||
|
||||
export function getWebMountedRecentStreamItems(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as BottomAnchorE2ETestGlobals)
|
||||
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS
|
||||
);
|
||||
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
export type IndexedStreamItem = {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type WebVirtualizedHistoryWindow = {
|
||||
virtualizedEntries: IndexedStreamItem[];
|
||||
mountedEntries: IndexedStreamItem[];
|
||||
};
|
||||
|
||||
export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return 220;
|
||||
case "tool_call":
|
||||
return 136;
|
||||
case "thought":
|
||||
return 112;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
case "activity_log":
|
||||
return 88;
|
||||
case "compaction":
|
||||
return 72;
|
||||
default:
|
||||
return 120;
|
||||
}
|
||||
}
|
||||
|
||||
export function findMountedWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
minMountedCount: number;
|
||||
}): number {
|
||||
const { items, minMountedCount } = input;
|
||||
if (items.length <= minMountedCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
export function splitWebVirtualizedHistory(input: {
|
||||
entries: IndexedStreamItem[];
|
||||
minMountedCount: number;
|
||||
}): WebVirtualizedHistoryWindow {
|
||||
const startIndex = findMountedWindowStart({
|
||||
items: input.entries.map((entry) => entry.item),
|
||||
minMountedCount: input.minMountedCount,
|
||||
});
|
||||
return {
|
||||
virtualizedEntries: input.entries.slice(0, startIndex),
|
||||
mountedEntries: input.entries.slice(startIndex),
|
||||
};
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export interface MessageInputProps {
|
||||
/** Reports cursor selection updates from the underlying input. */
|
||||
onSelectionChange?: (selection: { start: number; end: number }) => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
@@ -153,6 +154,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onKeyPress: onKeyPressCallback,
|
||||
onSelectionChange: onSelectionChangeCallback,
|
||||
onFocusChange,
|
||||
onHeightChange,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -473,7 +475,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onSubmit(payload)
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT
|
||||
setInputHeight(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onSubmit, isAgentRunning])
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onSubmit, isAgentRunning, onHeightChange])
|
||||
|
||||
const handleQueueMessage = useCallback(() => {
|
||||
if (!onQueue) return
|
||||
@@ -487,7 +490,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onChangeText('')
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT
|
||||
setInputHeight(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onQueue, onChangeText])
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT)
|
||||
}, [value, images, onQueue, onChangeText, onHeightChange])
|
||||
|
||||
// Web input height measurement
|
||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||
@@ -587,6 +591,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
|
||||
inputHeightRef.current = bounded
|
||||
setInputHeight(bounded)
|
||||
onHeightChange?.(bounded)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -597,6 +602,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return
|
||||
inputHeightRef.current = bounded
|
||||
setInputHeight(bounded)
|
||||
onHeightChange?.(bounded)
|
||||
}
|
||||
|
||||
function handleContentSizeChange(
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__private__,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
type BottomAnchorMode,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
||||
|
||||
type MeasurementState = ReturnType<typeof createMeasurementState>;
|
||||
|
||||
function createMeasurementState(
|
||||
overrides?: Partial<{
|
||||
containerKey: string;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
contentHeight: number;
|
||||
offsetY: number;
|
||||
viewportMeasuredForKey: string | null;
|
||||
contentMeasuredForKey: string | null;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
containerKey: "scroll-view",
|
||||
viewportWidth: 0,
|
||||
viewportHeight: 0,
|
||||
contentHeight: 0,
|
||||
offsetY: 0,
|
||||
viewportMeasuredForKey: null,
|
||||
contentMeasuredForKey: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPendingRequest() {
|
||||
return {
|
||||
id: 1,
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry" as const,
|
||||
requestKey: "route:agent-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createFrameScheduler() {
|
||||
let sequence = 0;
|
||||
const tasks = new Map<
|
||||
number,
|
||||
{
|
||||
cancelled: boolean;
|
||||
remainingFrames: number;
|
||||
callback: () => void;
|
||||
kind: "attempt" | "verification";
|
||||
}
|
||||
>();
|
||||
|
||||
return {
|
||||
schedule(params: {
|
||||
kind: "attempt" | "verification";
|
||||
callback: () => void;
|
||||
delayFrames?: number;
|
||||
}) {
|
||||
const id = ++sequence;
|
||||
tasks.set(id, {
|
||||
cancelled: false,
|
||||
remainingFrames: Math.max(0, params.delayFrames ?? 0),
|
||||
callback: params.callback,
|
||||
kind: params.kind,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
cancel(handle: unknown) {
|
||||
const task = tasks.get(handle as number);
|
||||
if (task) {
|
||||
task.cancelled = true;
|
||||
}
|
||||
},
|
||||
flushFrame() {
|
||||
const due: Array<() => void> = [];
|
||||
for (const [id, task] of Array.from(tasks.entries())) {
|
||||
if (task.cancelled) {
|
||||
tasks.delete(id);
|
||||
continue;
|
||||
}
|
||||
if (task.remainingFrames > 0) {
|
||||
task.remainingFrames -= 1;
|
||||
continue;
|
||||
}
|
||||
tasks.delete(id);
|
||||
due.push(task.callback);
|
||||
}
|
||||
for (const callback of due) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
flushAll(limit = 20) {
|
||||
for (let index = 0; index < limit && tasks.size > 0; index += 1) {
|
||||
this.flushFrame();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDriverHarness(input?: {
|
||||
transportBehavior?: BottomAnchorTransportBehavior;
|
||||
isNearBottom?: boolean;
|
||||
measurementState?: MeasurementState;
|
||||
authoritativeReady?: boolean;
|
||||
}) {
|
||||
const scheduler = createFrameScheduler();
|
||||
const measurementState =
|
||||
input?.measurementState ??
|
||||
createMeasurementState({
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 480,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
});
|
||||
const context = {
|
||||
agentId: "agent-1",
|
||||
authoritativeReady: input?.authoritativeReady ?? true,
|
||||
renderStrategy: "forward-stream",
|
||||
transportBehavior:
|
||||
input?.transportBehavior ?? {
|
||||
verificationDelayFrames: 0,
|
||||
verificationRetryMode: "rescroll",
|
||||
},
|
||||
measurementState,
|
||||
nearBottom: input?.isNearBottom ?? true,
|
||||
};
|
||||
const scrollToBottom = vi.fn(() => {
|
||||
context.nearBottom = true;
|
||||
context.measurementState.offsetY = 720;
|
||||
});
|
||||
const modeChanges: BottomAnchorMode[] = [];
|
||||
const warnings: Array<{ agentId: string; reason: string }> = [];
|
||||
const driver = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => context.agentId,
|
||||
getIsAuthoritativeHistoryReady: () => context.authoritativeReady,
|
||||
getRenderStrategy: () => context.renderStrategy,
|
||||
getTransportBehavior: () => context.transportBehavior,
|
||||
getMeasurementState: () => context.measurementState,
|
||||
isNearBottom: () => context.nearBottom,
|
||||
scrollToBottom,
|
||||
onModeChange: (mode) => {
|
||||
modeChanges.push(mode);
|
||||
},
|
||||
log: () => {},
|
||||
warn: (details) => warnings.push(details),
|
||||
scheduleFrame: (params) => scheduler.schedule(params),
|
||||
cancelFrame: (handle) => scheduler.cancel(handle),
|
||||
});
|
||||
|
||||
return {
|
||||
context,
|
||||
driver,
|
||||
scheduler,
|
||||
scrollToBottom,
|
||||
modeChanges,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveBottomAnchorBlockedReason", () => {
|
||||
it("keeps initial-entry pending until history is ready and geometry is measurable", () => {
|
||||
const pendingRequest = createPendingRequest();
|
||||
|
||||
expect(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: false,
|
||||
measurementState: createMeasurementState(),
|
||||
pendingVerificationRequestId: null,
|
||||
})
|
||||
).toBe("waiting_for_history_readiness");
|
||||
|
||||
expect(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
viewportHeight: 480,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
pendingVerificationRequestId: null,
|
||||
})
|
||||
).toBe("waiting_for_measurable_content");
|
||||
|
||||
expect(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
viewportHeight: 480,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
pendingVerificationRequestId: pendingRequest.id,
|
||||
})
|
||||
).toBe("waiting_for_post_layout_verification");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bottom anchor controller driver", () => {
|
||||
it("keeps initial-entry pending until authoritative history and current geometry exist", () => {
|
||||
const harness = createDriverHarness({
|
||||
authoritativeReady: false,
|
||||
measurementState: createMeasurementState(),
|
||||
});
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry",
|
||||
requestKey: "route:agent-1:initial-entry",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
blockedReason: "waiting_for_history_readiness",
|
||||
pendingRequest: {
|
||||
reason: "initial-entry",
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.authoritativeReady = true;
|
||||
harness.context.measurementState.viewportHeight = 480;
|
||||
harness.context.measurementState.contentHeight = 1200;
|
||||
harness.context.measurementState.viewportMeasuredForKey = "scroll-view";
|
||||
harness.context.measurementState.contentMeasuredForKey = "scroll-view";
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.notifyAuthoritativeHistoryMaybeChanged();
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: null,
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses sticky maintenance while detached", () => {
|
||||
const harness = createDriverHarness();
|
||||
|
||||
harness.driver.detachByUser();
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1500,
|
||||
});
|
||||
harness.driver.handleViewportMetricsChange({
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 640,
|
||||
previousViewportHeight: 480,
|
||||
viewportHeight: 420,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||
const harness = createDriverHarness({
|
||||
isNearBottom: false,
|
||||
});
|
||||
|
||||
harness.driver.detachByUser();
|
||||
harness.driver.requestLocalAnchor({
|
||||
agentId: "agent-1",
|
||||
reason: "jump-to-bottom",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.modeChanges).toContain("detached");
|
||||
expect(harness.modeChanges).toContain("sticky-bottom");
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
});
|
||||
|
||||
it("schedules sticky maintenance on viewport and content growth", () => {
|
||||
const harness = createDriverHarness();
|
||||
|
||||
harness.driver.handleViewportMetricsChange({
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 640,
|
||||
previousViewportHeight: 480,
|
||||
viewportHeight: 420,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1600,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a pending request blocked when stale container measurements arrive", () => {
|
||||
const harness = createDriverHarness({
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "web-partial-virtualized",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
});
|
||||
|
||||
harness.driver.applyRouteRequest({
|
||||
agentId: "agent-1",
|
||||
reason: "resume",
|
||||
requestKey: "route:agent-1:resume",
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
blockedReason: "waiting_for_measurable_viewport",
|
||||
pendingRequest: {
|
||||
reason: "resume",
|
||||
},
|
||||
});
|
||||
|
||||
harness.context.measurementState.viewportMeasuredForKey = "web-partial-virtualized";
|
||||
harness.context.measurementState.contentMeasuredForKey = "web-partial-virtualized";
|
||||
harness.driver.reevaluate();
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
it("uses delayed rechecks instead of repeated rescroll loops for native transport", () => {
|
||||
const harness = createDriverHarness({
|
||||
transportBehavior: {
|
||||
verificationDelayFrames: 2,
|
||||
verificationRetryMode: "recheck",
|
||||
},
|
||||
isNearBottom: false,
|
||||
});
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 0;
|
||||
});
|
||||
|
||||
harness.driver.requestLocalAnchor({
|
||||
agentId: "agent-1",
|
||||
reason: "jump-to-bottom",
|
||||
});
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.scheduler.flushFrame();
|
||||
harness.scheduler.flushFrame();
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||
expect(harness.warnings).toEqual([]);
|
||||
expect(harness.driver.getSnapshot().pendingRequest).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps sticky-bottom during viewport growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 720;
|
||||
});
|
||||
|
||||
harness.driver.handleViewportMetricsChange({
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 800,
|
||||
previousViewportHeight: 480,
|
||||
viewportHeight: 420,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: true,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 64,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
|
||||
it("keeps sticky-bottom during streaming growth until bottom is re-verified", () => {
|
||||
const harness = createDriverHarness();
|
||||
harness.context.nearBottom = false;
|
||||
harness.scrollToBottom.mockImplementation(() => {
|
||||
harness.context.measurementState.offsetY = 900;
|
||||
});
|
||||
|
||||
harness.driver.handleContentSizeChange({
|
||||
previousContentHeight: 1200,
|
||||
contentHeight: 1400,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
|
||||
expect(harness.scrollToBottom).toHaveBeenCalledTimes(4);
|
||||
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||
mode: "sticky-bottom",
|
||||
pendingRequest: null,
|
||||
pendingVerification: null,
|
||||
});
|
||||
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("sticky-bottom");
|
||||
|
||||
harness.context.nearBottom = true;
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: true,
|
||||
scrollDelta: 0,
|
||||
});
|
||||
harness.scheduler.flushAll();
|
||||
harness.driver.handleScrollNearBottomChange({
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 64,
|
||||
});
|
||||
|
||||
expect(harness.driver.getSnapshot().mode).toBe("detached");
|
||||
});
|
||||
});
|
||||
|
||||
describe("controller helper predicates", () => {
|
||||
it("rejects stale container measurements during post-scroll verification", () => {
|
||||
expect(
|
||||
__private__.deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "web-partial-virtualized",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
})
|
||||
).toBe("waiting_for_measurable_viewport");
|
||||
});
|
||||
|
||||
it("allows verification only after authoritative readiness and current geometry exist", () => {
|
||||
expect(
|
||||
__private__.deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: false,
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "scroll-view",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
})
|
||||
).toBe("waiting_for_history_readiness");
|
||||
|
||||
expect(
|
||||
__private__.deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: true,
|
||||
measurementState: createMeasurementState({
|
||||
containerKey: "scroll-view",
|
||||
viewportHeight: 420,
|
||||
contentHeight: 1200,
|
||||
viewportMeasuredForKey: "scroll-view",
|
||||
contentMeasuredForKey: "scroll-view",
|
||||
}),
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses auto-anchor helpers while detached", () => {
|
||||
const mode: BottomAnchorMode = "detached";
|
||||
|
||||
expect(
|
||||
__private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: 1000,
|
||||
contentHeight: 1100,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
__private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: 800,
|
||||
viewportWidth: 640,
|
||||
previousViewportHeight: 400,
|
||||
viewportHeight: 360,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not detach from sticky while a restick request is still pending", () => {
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: true,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: true,
|
||||
hasUnverifiedStickyMeasurementChange: false,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: true,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 0,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a large scroll delta as user detach even during an unverified sticky change", () => {
|
||||
expect(
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode: "sticky-bottom",
|
||||
nextIsNearBottom: false,
|
||||
scrollDelta: 48,
|
||||
hasPendingRequest: false,
|
||||
hasPendingVerification: false,
|
||||
hasUnverifiedStickyMeasurementChange: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,817 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strategy";
|
||||
|
||||
export type BottomAnchorMode = "sticky-bottom" | "detached";
|
||||
|
||||
export type BottomAnchorRouteRequest = {
|
||||
reason: "initial-entry" | "resume";
|
||||
agentId: string;
|
||||
requestKey: string;
|
||||
};
|
||||
|
||||
export type BottomAnchorLocalRequest = {
|
||||
reason: "jump-to-bottom" | "message-sent";
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export type BottomAnchorBlockedReason =
|
||||
| "waiting_for_history_readiness"
|
||||
| "waiting_for_measurable_viewport"
|
||||
| "waiting_for_measurable_content"
|
||||
| "waiting_for_post_layout_verification";
|
||||
|
||||
type BottomAnchorRequestReason =
|
||||
| BottomAnchorRouteRequest["reason"]
|
||||
| BottomAnchorLocalRequest["reason"];
|
||||
|
||||
type BottomAnchorRequest = {
|
||||
id: number;
|
||||
agentId: string;
|
||||
reason: BottomAnchorRequestReason;
|
||||
requestKey: string;
|
||||
};
|
||||
|
||||
type ControllerMeasurementState = {
|
||||
containerKey: string;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
contentHeight: number;
|
||||
offsetY: number;
|
||||
viewportMeasuredForKey: string | null;
|
||||
contentMeasuredForKey: string | null;
|
||||
};
|
||||
|
||||
type AttemptContext = {
|
||||
requestId: number | null;
|
||||
retries: number;
|
||||
};
|
||||
|
||||
type ScheduledFrameHandle = {
|
||||
cancelled: boolean;
|
||||
rafId: number | null;
|
||||
remainingFrames: number;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
type BottomAnchorEvent =
|
||||
| "request_created"
|
||||
| "attempt_started"
|
||||
| "attempt_verified"
|
||||
| "attempt_failed"
|
||||
| "request_fulfilled"
|
||||
| "request_cancelled"
|
||||
| "detached_by_user"
|
||||
| "blocked_reason_changed";
|
||||
|
||||
type BottomAnchorControllerDriver = {
|
||||
destroy: () => void;
|
||||
getSnapshot: () => {
|
||||
mode: BottomAnchorMode;
|
||||
pendingRequest: BottomAnchorRequest | null;
|
||||
pendingVerification: AttemptContext | null;
|
||||
blockedReason: BottomAnchorBlockedReason | null;
|
||||
};
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
detachByUser: () => void;
|
||||
handleViewportMetricsChange: (params: {
|
||||
previousViewportWidth: number;
|
||||
viewportWidth: number;
|
||||
previousViewportHeight: number;
|
||||
viewportHeight: number;
|
||||
}) => void;
|
||||
handleContentSizeChange: (params: {
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}) => void;
|
||||
prepareForStickyViewportChange: () => void;
|
||||
prepareForStickyContentChange: () => void;
|
||||
handleScrollNearBottomChange: (params: {
|
||||
nextIsNearBottom: boolean;
|
||||
scrollDelta: number;
|
||||
}) => void;
|
||||
notifyAuthoritativeHistoryMaybeChanged: () => void;
|
||||
reevaluate: (animated?: boolean) => void;
|
||||
};
|
||||
|
||||
type CreateBottomAnchorControllerDriverInput = {
|
||||
getAgentId: () => string;
|
||||
getIsAuthoritativeHistoryReady: () => boolean;
|
||||
getRenderStrategy: () => string;
|
||||
getTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||
getMeasurementState: () => ControllerMeasurementState;
|
||||
isNearBottom: () => boolean;
|
||||
scrollToBottom: (animated: boolean) => void;
|
||||
onModeChange: (mode: BottomAnchorMode) => void;
|
||||
log: (event: BottomAnchorEvent, details: Record<string, unknown>) => void;
|
||||
warn: (details: { agentId: string; reason: BottomAnchorRequestReason }) => void;
|
||||
scheduleFrame: (params: {
|
||||
kind: "attempt" | "verification";
|
||||
callback: () => void;
|
||||
delayFrames?: number;
|
||||
}) => unknown;
|
||||
cancelFrame: (handle: unknown) => void;
|
||||
};
|
||||
|
||||
const MAX_VERIFICATION_RETRIES = 3;
|
||||
const USER_SCROLL_AWAY_DELTA_PX = 24;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
function logBottomAnchorEvent(
|
||||
event: BottomAnchorEvent,
|
||||
details: Record<string, unknown>
|
||||
): void {
|
||||
if (!IS_DEV) {
|
||||
return;
|
||||
}
|
||||
console.debug("[BottomAnchor]", event, details);
|
||||
}
|
||||
|
||||
function scheduleAnimationFrameWithDelay(input: {
|
||||
callback: () => void;
|
||||
delayFrames?: number;
|
||||
}): ScheduledFrameHandle {
|
||||
const handle: ScheduledFrameHandle = {
|
||||
cancelled: false,
|
||||
rafId: null,
|
||||
remainingFrames: Math.max(0, input.delayFrames ?? 0),
|
||||
callback: input.callback,
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (handle.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (handle.remainingFrames > 0) {
|
||||
handle.remainingFrames -= 1;
|
||||
handle.rafId = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
handle.rafId = null;
|
||||
input.callback();
|
||||
};
|
||||
|
||||
handle.rafId = requestAnimationFrame(tick);
|
||||
return handle;
|
||||
}
|
||||
|
||||
function cancelScheduledAnimationFrame(handle: unknown): void {
|
||||
const scheduled = handle as ScheduledFrameHandle | null;
|
||||
if (!scheduled) {
|
||||
return;
|
||||
}
|
||||
scheduled.cancelled = true;
|
||||
if (scheduled.rafId !== null) {
|
||||
cancelAnimationFrame(scheduled.rafId);
|
||||
scheduled.rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVerificationBlockedReason(input: {
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
measurementState: ControllerMeasurementState;
|
||||
}): Exclude<BottomAnchorBlockedReason, "waiting_for_post_layout_verification"> | null {
|
||||
if (!input.isAuthoritativeHistoryReady) {
|
||||
return "waiting_for_history_readiness";
|
||||
}
|
||||
if (
|
||||
input.measurementState.viewportHeight <= 0 ||
|
||||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_viewport";
|
||||
}
|
||||
if (
|
||||
input.measurementState.contentHeight <= 0 ||
|
||||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_content";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deriveBottomAnchorBlockedReason(input: {
|
||||
pendingRequest: BottomAnchorRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
measurementState: ControllerMeasurementState;
|
||||
pendingVerificationRequestId: number | null;
|
||||
}): BottomAnchorBlockedReason | null {
|
||||
if (!input.pendingRequest) {
|
||||
return null;
|
||||
}
|
||||
if (!input.isAuthoritativeHistoryReady) {
|
||||
return "waiting_for_history_readiness";
|
||||
}
|
||||
if (
|
||||
input.measurementState.viewportHeight <= 0 ||
|
||||
input.measurementState.viewportMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_viewport";
|
||||
}
|
||||
if (
|
||||
input.measurementState.contentHeight <= 0 ||
|
||||
input.measurementState.contentMeasuredForKey !== input.measurementState.containerKey
|
||||
) {
|
||||
return "waiting_for_measurable_content";
|
||||
}
|
||||
if (input.pendingVerificationRequestId === input.pendingRequest.id) {
|
||||
return "waiting_for_post_layout_verification";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveRetryDisposition(input: {
|
||||
mode: BottomAnchorMode;
|
||||
retries: number;
|
||||
verificationRetryMode: BottomAnchorTransportBehavior["verificationRetryMode"];
|
||||
}): "retry-scroll" | "retry-verify" | "fail" {
|
||||
if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) {
|
||||
return "fail";
|
||||
}
|
||||
return input.verificationRetryMode === "recheck"
|
||||
? "retry-verify"
|
||||
: "retry-scroll";
|
||||
}
|
||||
|
||||
function createBottomAnchorControllerDriver(
|
||||
input: CreateBottomAnchorControllerDriverInput
|
||||
): BottomAnchorControllerDriver {
|
||||
let requestSequence = 0;
|
||||
let mode: BottomAnchorMode = "sticky-bottom";
|
||||
let pendingRequest: BottomAnchorRequest | null = null;
|
||||
let pendingVerification: AttemptContext | null = null;
|
||||
let blockedReason: BottomAnchorBlockedReason | null = null;
|
||||
let attemptHandle: unknown = null;
|
||||
let verificationHandle: unknown = null;
|
||||
let lastRouteRequestKey: string | null = null;
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
|
||||
const getLogContext = (extra?: Record<string, unknown>) => {
|
||||
const measurementState = input.getMeasurementState();
|
||||
return {
|
||||
agentId: input.getAgentId(),
|
||||
requestReason: pendingRequest?.reason ?? null,
|
||||
authoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
contentHeight: measurementState.contentHeight,
|
||||
viewportHeight: measurementState.viewportHeight,
|
||||
offset: measurementState.offsetY,
|
||||
renderStrategy: input.getRenderStrategy(),
|
||||
blockedReason,
|
||||
mode,
|
||||
containerKey: measurementState.containerKey,
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
return;
|
||||
}
|
||||
blockedReason = nextBlockedReason;
|
||||
input.log(
|
||||
"blocked_reason_changed",
|
||||
getLogContext({ nextBlockedReason })
|
||||
);
|
||||
};
|
||||
|
||||
const setModeInternal = (nextMode: BottomAnchorMode) => {
|
||||
if (mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
mode = nextMode;
|
||||
input.onModeChange(nextMode);
|
||||
if (nextMode === "detached") {
|
||||
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
|
||||
}
|
||||
};
|
||||
|
||||
const markStickyMeasurementChanged = () => {
|
||||
stickyMeasurementRevision += 1;
|
||||
};
|
||||
|
||||
const markStickyMeasurementVerified = () => {
|
||||
lastVerifiedStickyMeasurementRevision = stickyMeasurementRevision;
|
||||
};
|
||||
|
||||
const cancelPendingAttempt = () => {
|
||||
if (attemptHandle) {
|
||||
input.cancelFrame(attemptHandle);
|
||||
attemptHandle = null;
|
||||
}
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
verificationHandle = null;
|
||||
}
|
||||
pendingVerification = null;
|
||||
};
|
||||
|
||||
const cancelPendingRequest = (reason: string) => {
|
||||
const currentRequest = pendingRequest;
|
||||
if (!currentRequest) {
|
||||
cancelPendingAttempt();
|
||||
setBlockedReason(null);
|
||||
return;
|
||||
}
|
||||
input.log(
|
||||
"request_cancelled",
|
||||
getLogContext({
|
||||
cancelledRequestReason: currentRequest.reason,
|
||||
cancelReason: reason,
|
||||
})
|
||||
);
|
||||
pendingRequest = null;
|
||||
cancelPendingAttempt();
|
||||
setBlockedReason(null);
|
||||
};
|
||||
|
||||
const scheduleVerification = (attemptContext: AttemptContext) => {
|
||||
if (verificationHandle) {
|
||||
input.cancelFrame(verificationHandle);
|
||||
}
|
||||
verificationHandle = input.scheduleFrame({
|
||||
kind: "verification",
|
||||
delayFrames: input.getTransportBehavior().verificationDelayFrames,
|
||||
callback: () => {
|
||||
verificationHandle = null;
|
||||
const currentRequest = pendingRequest;
|
||||
const isRequestAttempt =
|
||||
currentRequest && attemptContext.requestId === currentRequest.id;
|
||||
const measurementState = input.getMeasurementState();
|
||||
const verificationBlockedReason = deriveVerificationBlockedReason({
|
||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
measurementState,
|
||||
});
|
||||
|
||||
if (verificationBlockedReason) {
|
||||
pendingVerification = attemptContext;
|
||||
setBlockedReason(verificationBlockedReason);
|
||||
return;
|
||||
}
|
||||
|
||||
const verifiedNearBottom = input.isNearBottom();
|
||||
|
||||
input.log(
|
||||
"attempt_verified",
|
||||
getLogContext({
|
||||
verifiedNearBottom,
|
||||
retries: attemptContext.retries,
|
||||
})
|
||||
);
|
||||
|
||||
if (verifiedNearBottom) {
|
||||
pendingVerification = null;
|
||||
markStickyMeasurementVerified();
|
||||
if (isRequestAttempt) {
|
||||
input.log("request_fulfilled", getLogContext());
|
||||
pendingRequest = null;
|
||||
}
|
||||
setBlockedReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const retryDisposition = deriveRetryDisposition({
|
||||
mode,
|
||||
retries: attemptContext.retries,
|
||||
verificationRetryMode: input.getTransportBehavior().verificationRetryMode,
|
||||
});
|
||||
|
||||
if (retryDisposition === "retry-verify") {
|
||||
pendingVerification = {
|
||||
requestId: attemptContext.requestId,
|
||||
retries: attemptContext.retries + 1,
|
||||
};
|
||||
setBlockedReason("waiting_for_post_layout_verification");
|
||||
scheduleVerification(pendingVerification);
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryDisposition === "retry-scroll") {
|
||||
pendingVerification = {
|
||||
requestId: attemptContext.requestId,
|
||||
retries: attemptContext.retries + 1,
|
||||
};
|
||||
evaluate(false);
|
||||
return;
|
||||
}
|
||||
|
||||
input.log(
|
||||
"attempt_failed",
|
||||
getLogContext({ retries: attemptContext.retries })
|
||||
);
|
||||
pendingVerification = null;
|
||||
if (isRequestAttempt && currentRequest) {
|
||||
input.warn({
|
||||
agentId: input.getAgentId(),
|
||||
reason: currentRequest.reason,
|
||||
});
|
||||
}
|
||||
setBlockedReason(
|
||||
isRequestAttempt ? "waiting_for_post_layout_verification" : null
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runAttempt = (animated: boolean) => {
|
||||
const attemptContext: AttemptContext = {
|
||||
requestId: pendingRequest?.id ?? null,
|
||||
retries: pendingVerification?.retries ?? 0,
|
||||
};
|
||||
pendingVerification = attemptContext;
|
||||
input.log(
|
||||
"attempt_started",
|
||||
getLogContext({ animated, retries: attemptContext.retries })
|
||||
);
|
||||
input.scrollToBottom(animated);
|
||||
scheduleVerification(attemptContext);
|
||||
setBlockedReason(
|
||||
deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
measurementState: input.getMeasurementState(),
|
||||
pendingVerificationRequestId: attemptContext.requestId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const evaluate = (animated: boolean) => {
|
||||
if (attemptHandle) {
|
||||
return;
|
||||
}
|
||||
attemptHandle = input.scheduleFrame({
|
||||
kind: "attempt",
|
||||
callback: () => {
|
||||
attemptHandle = null;
|
||||
const nextBlockedReason = deriveBottomAnchorBlockedReason({
|
||||
pendingRequest,
|
||||
isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(),
|
||||
measurementState: input.getMeasurementState(),
|
||||
pendingVerificationRequestId: pendingVerification?.requestId ?? null,
|
||||
});
|
||||
setBlockedReason(nextBlockedReason);
|
||||
|
||||
const shouldAttemptForPendingRequest =
|
||||
pendingRequest !== null && nextBlockedReason === null;
|
||||
const shouldAttemptForStickyVerification =
|
||||
mode === "sticky-bottom" &&
|
||||
pendingVerification !== null &&
|
||||
nextBlockedReason === null;
|
||||
|
||||
if (
|
||||
!shouldAttemptForPendingRequest &&
|
||||
!shouldAttemptForStickyVerification
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
runAttempt(animated);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createRequest = (request: BottomAnchorRouteRequest | BottomAnchorLocalRequest) => {
|
||||
const existing = pendingRequest;
|
||||
if (existing) {
|
||||
input.log(
|
||||
"request_cancelled",
|
||||
getLogContext({
|
||||
cancelledRequestReason: existing.reason,
|
||||
cancelReason: "replaced_by_new_request",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
cancelPendingAttempt();
|
||||
const nextRequest: BottomAnchorRequest = {
|
||||
id: requestSequence + 1,
|
||||
agentId: request.agentId,
|
||||
reason: request.reason,
|
||||
requestKey:
|
||||
"requestKey" in request
|
||||
? request.requestKey
|
||||
: `${request.agentId}:${request.reason}:${requestSequence + 1}`,
|
||||
};
|
||||
requestSequence = nextRequest.id;
|
||||
pendingRequest = nextRequest;
|
||||
pendingVerification = null;
|
||||
setModeInternal(
|
||||
"requestKey" in request
|
||||
? "sticky-bottom"
|
||||
: __private__.deriveModeForLocalRequest({ reason: request.reason })
|
||||
);
|
||||
input.log(
|
||||
"request_created",
|
||||
getLogContext({ requestReason: request.reason })
|
||||
);
|
||||
evaluate(request.reason === "jump-to-bottom");
|
||||
};
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
cancelPendingAttempt();
|
||||
},
|
||||
getSnapshot() {
|
||||
return {
|
||||
mode,
|
||||
pendingRequest,
|
||||
pendingVerification,
|
||||
blockedReason,
|
||||
};
|
||||
},
|
||||
resetForAgent() {
|
||||
lastRouteRequestKey = null;
|
||||
pendingRequest = null;
|
||||
blockedReason = null;
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
applyRouteRequest(request) {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
if (lastRouteRequestKey === request.requestKey) {
|
||||
return;
|
||||
}
|
||||
lastRouteRequestKey = request.requestKey;
|
||||
createRequest(request);
|
||||
},
|
||||
requestLocalAnchor(request) {
|
||||
createRequest(request);
|
||||
},
|
||||
detachByUser() {
|
||||
if (mode === "detached") {
|
||||
return;
|
||||
}
|
||||
cancelPendingRequest("user_scrolled_away");
|
||||
setModeInternal("detached");
|
||||
input.log("detached_by_user", getLogContext());
|
||||
},
|
||||
handleViewportMetricsChange(params) {
|
||||
if (
|
||||
params.previousViewportWidth !== params.viewportWidth ||
|
||||
params.previousViewportHeight !== params.viewportHeight
|
||||
) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: params.previousViewportWidth,
|
||||
viewportWidth: params.viewportWidth,
|
||||
previousViewportHeight: params.previousViewportHeight,
|
||||
viewportHeight: params.viewportHeight,
|
||||
});
|
||||
if (shouldRestick && !pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
}
|
||||
if (shouldRestick || pendingRequest) {
|
||||
evaluate(false);
|
||||
}
|
||||
},
|
||||
handleContentSizeChange(params) {
|
||||
if (params.previousContentHeight !== params.contentHeight) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: params.previousContentHeight,
|
||||
contentHeight: params.contentHeight,
|
||||
});
|
||||
if (shouldRestick && !pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
}
|
||||
if (shouldRestick || pendingRequest) {
|
||||
evaluate(false);
|
||||
}
|
||||
},
|
||||
prepareForStickyViewportChange() {
|
||||
if (mode !== "sticky-bottom") {
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
},
|
||||
prepareForStickyContentChange() {
|
||||
if (mode !== "sticky-bottom") {
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
if (
|
||||
nextIsNearBottom &&
|
||||
mode === "sticky-bottom" &&
|
||||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision
|
||||
) {
|
||||
markStickyMeasurementVerified();
|
||||
}
|
||||
const hasUnverifiedStickyMeasurementChange =
|
||||
stickyMeasurementRevision !== lastVerifiedStickyMeasurementRevision;
|
||||
if (
|
||||
__private__.shouldDetachFromScrollAway({
|
||||
mode,
|
||||
nextIsNearBottom,
|
||||
scrollDelta,
|
||||
hasPendingRequest: pendingRequest !== null,
|
||||
hasPendingVerification: pendingVerification !== null,
|
||||
hasUnverifiedStickyMeasurementChange,
|
||||
})
|
||||
) {
|
||||
this.detachByUser();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
mode === "sticky-bottom" &&
|
||||
!nextIsNearBottom &&
|
||||
hasUnverifiedStickyMeasurementChange
|
||||
) {
|
||||
if (!pendingRequest && !pendingVerification) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
}
|
||||
evaluate(false);
|
||||
return;
|
||||
}
|
||||
if (nextIsNearBottom && pendingRequest) {
|
||||
evaluate(false);
|
||||
}
|
||||
},
|
||||
notifyAuthoritativeHistoryMaybeChanged() {
|
||||
if (!pendingVerification && !pendingRequest) {
|
||||
return;
|
||||
}
|
||||
evaluate(false);
|
||||
},
|
||||
reevaluate(animated = false) {
|
||||
evaluate(animated);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
createBottomAnchorControllerDriver,
|
||||
deriveBottomAnchorBlockedReason,
|
||||
deriveVerificationBlockedReason,
|
||||
deriveRetryDisposition,
|
||||
deriveModeForLocalRequest(input: {
|
||||
reason: BottomAnchorLocalRequest["reason"];
|
||||
}): BottomAnchorMode {
|
||||
return "sticky-bottom";
|
||||
},
|
||||
shouldRestickOnViewportChange(input: {
|
||||
mode: BottomAnchorMode;
|
||||
previousViewportWidth: number;
|
||||
viewportWidth: number;
|
||||
previousViewportHeight: number;
|
||||
viewportHeight: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
((input.previousViewportHeight > 0 &&
|
||||
input.viewportHeight > 0 &&
|
||||
input.previousViewportHeight !== input.viewportHeight) ||
|
||||
(input.previousViewportWidth > 0 &&
|
||||
input.viewportWidth > 0 &&
|
||||
input.previousViewportWidth !== input.viewportWidth))
|
||||
);
|
||||
},
|
||||
shouldRestickOnContentChange(input: {
|
||||
mode: BottomAnchorMode;
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
input.previousContentHeight > 0 &&
|
||||
input.contentHeight > input.previousContentHeight
|
||||
);
|
||||
},
|
||||
shouldDetachFromScrollAway(input: {
|
||||
mode: BottomAnchorMode;
|
||||
nextIsNearBottom: boolean;
|
||||
scrollDelta: number;
|
||||
hasPendingRequest: boolean;
|
||||
hasPendingVerification: boolean;
|
||||
hasUnverifiedStickyMeasurementChange: boolean;
|
||||
}): boolean {
|
||||
const scrolledAwayIntentionally =
|
||||
Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX;
|
||||
return (
|
||||
input.mode === "sticky-bottom" &&
|
||||
!input.nextIsNearBottom &&
|
||||
!input.hasPendingRequest &&
|
||||
!input.hasPendingVerification &&
|
||||
(!input.hasUnverifiedStickyMeasurementChange || scrolledAwayIntentionally)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function useBottomAnchorController(input: {
|
||||
agentId: string;
|
||||
routeRequest: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady: boolean;
|
||||
renderStrategy: string;
|
||||
transportBehavior: BottomAnchorTransportBehavior;
|
||||
getMeasurementState: () => ControllerMeasurementState;
|
||||
isNearBottom: () => boolean;
|
||||
scrollToBottom: (animated: boolean) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<BottomAnchorMode>("sticky-bottom");
|
||||
const agentIdRef = useRef(input.agentId);
|
||||
const readinessRef = useRef(input.isAuthoritativeHistoryReady);
|
||||
const renderStrategyRef = useRef(input.renderStrategy);
|
||||
const transportBehaviorRef = useRef(input.transportBehavior);
|
||||
const getMeasurementStateRef = useRef(input.getMeasurementState);
|
||||
const isNearBottomRef = useRef(input.isNearBottom);
|
||||
const scrollToBottomRef = useRef(input.scrollToBottom);
|
||||
const driverRef = useRef<BottomAnchorControllerDriver | null>(null);
|
||||
|
||||
agentIdRef.current = input.agentId;
|
||||
readinessRef.current = input.isAuthoritativeHistoryReady;
|
||||
renderStrategyRef.current = input.renderStrategy;
|
||||
transportBehaviorRef.current = input.transportBehavior;
|
||||
getMeasurementStateRef.current = input.getMeasurementState;
|
||||
isNearBottomRef.current = input.isNearBottom;
|
||||
scrollToBottomRef.current = input.scrollToBottom;
|
||||
|
||||
if (!driverRef.current) {
|
||||
driverRef.current = __private__.createBottomAnchorControllerDriver({
|
||||
getAgentId: () => agentIdRef.current,
|
||||
getIsAuthoritativeHistoryReady: () => readinessRef.current,
|
||||
getRenderStrategy: () => renderStrategyRef.current,
|
||||
getTransportBehavior: () => transportBehaviorRef.current,
|
||||
getMeasurementState: () => getMeasurementStateRef.current(),
|
||||
isNearBottom: () => isNearBottomRef.current(),
|
||||
scrollToBottom: (animated) => scrollToBottomRef.current(animated),
|
||||
onModeChange: (nextMode) => setMode(nextMode),
|
||||
log: (event, details) => logBottomAnchorEvent(event, details),
|
||||
warn: (details) => {
|
||||
console.warn("[BottomAnchor] request could not be fulfilled", details);
|
||||
},
|
||||
scheduleFrame: ({ callback, delayFrames }) =>
|
||||
scheduleAnimationFrameWithDelay({ callback, delayFrames }),
|
||||
cancelFrame: (handle) => cancelScheduledAnimationFrame(handle),
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.resetForAgent();
|
||||
}, [input.agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.applyRouteRequest(input.routeRequest);
|
||||
}, [input.routeRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
driverRef.current?.notifyAuthoritativeHistoryMaybeChanged();
|
||||
}, [input.isAuthoritativeHistoryReady]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
driverRef.current?.destroy();
|
||||
driverRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mode,
|
||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||
driverRef.current?.requestLocalAnchor(request);
|
||||
},
|
||||
detachByUser() {
|
||||
driverRef.current?.detachByUser();
|
||||
},
|
||||
handleViewportLayout() {},
|
||||
handleViewportMetricsChange(params: {
|
||||
previousViewportWidth: number;
|
||||
viewportWidth: number;
|
||||
previousViewportHeight: number;
|
||||
viewportHeight: number;
|
||||
}) {
|
||||
driverRef.current?.handleViewportMetricsChange(params);
|
||||
},
|
||||
handleContentSizeChange(params: {
|
||||
previousContentHeight: number;
|
||||
contentHeight: number;
|
||||
}) {
|
||||
driverRef.current?.handleContentSizeChange(params);
|
||||
},
|
||||
prepareForStickyViewportChange() {
|
||||
driverRef.current?.prepareForStickyViewportChange();
|
||||
},
|
||||
prepareForStickyContentChange() {
|
||||
driverRef.current?.prepareForStickyContentChange();
|
||||
},
|
||||
handleScrollNearBottomChange(params: {
|
||||
nextIsNearBottom: boolean;
|
||||
scrollDelta: number;
|
||||
}) {
|
||||
driverRef.current?.handleScrollNearBottomChange(params);
|
||||
},
|
||||
reevaluate(animated = false) {
|
||||
driverRef.current?.reevaluate(animated);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -240,6 +240,9 @@ function SessionProviderInternal({
|
||||
const markAgentHistorySynchronized = useSessionStore(
|
||||
(state) => state.markAgentHistorySynchronized
|
||||
);
|
||||
const setAgentAuthoritativeHistoryApplied = useSessionStore(
|
||||
(state) => state.setAgentAuthoritativeHistoryApplied
|
||||
);
|
||||
const setHasHydratedAgents = useSessionStore(
|
||||
(state) => state.setHasHydratedAgents
|
||||
);
|
||||
@@ -719,6 +722,7 @@ function SessionProviderInternal({
|
||||
next.delete(agentId);
|
||||
return next;
|
||||
});
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -771,6 +775,8 @@ function SessionProviderInternal({
|
||||
) => {
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldMarkAuthoritativeHistoryApplied =
|
||||
payload.direction === "tail" || payload.direction === "after";
|
||||
|
||||
// Read current store state
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
@@ -890,6 +896,9 @@ function SessionProviderInternal({
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldMarkAuthoritativeHistoryApplied) {
|
||||
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||
}
|
||||
if (result.initResolution === "resolve") {
|
||||
resolveInitDeferred(initKey);
|
||||
}
|
||||
@@ -904,6 +913,7 @@ function SessionProviderInternal({
|
||||
markAgentHistorySynchronized,
|
||||
requestCanonicalCatchUp,
|
||||
serverId,
|
||||
setAgentAuthoritativeHistoryApplied,
|
||||
setAgentStreamTail,
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
|
||||
@@ -64,4 +64,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose an RPC-success init fallback", () => {
|
||||
expect(
|
||||
"shouldResolveInitFromRpcSuccess" in __private__
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useAgentInitialization({
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
const hasAuthoritativeHistory =
|
||||
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor
|
||||
? { epoch: cursor.epoch, seq: cursor.endSeq }
|
||||
@@ -95,10 +95,6 @@ export function useAgentInitialization({
|
||||
|
||||
client
|
||||
.fetchAgentTimeline(agentId, timelineRequest)
|
||||
.then(() => {
|
||||
// No-op: hydration completion is handled by SessionContext
|
||||
// when it processes fetch_agent_timeline_response.
|
||||
})
|
||||
.catch((error) => {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
|
||||
@@ -342,6 +342,46 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(ready.sync.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps first route entry blocked until authoritative history is applied", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
agent: createAgent("agent-1"),
|
||||
needsAuthoritativeSync: true,
|
||||
isHistorySyncing: true,
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
|
||||
expect(result.state).toEqual({
|
||||
tag: "boot",
|
||||
reason: "loading",
|
||||
source: "none",
|
||||
});
|
||||
expect(result.memory.hasRenderedReady).toBe(false);
|
||||
expect(result.memory.lastReadyAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("still allows optimistic create flow to render before authoritative history arrives", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
|
||||
placeholderAgent: createAgent("agent-1"),
|
||||
shouldUseOptimisticStream: true,
|
||||
needsAuthoritativeSync: true,
|
||||
isHistorySyncing: true,
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(ready.source).toBe("optimistic");
|
||||
expect(ready.agent.status).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
|
||||
const initialMemory = createBaseMemory();
|
||||
const optimisticInput: AgentScreenMachineInput = {
|
||||
|
||||
@@ -19,6 +19,16 @@ export interface AgentScreenMachineInput {
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
}
|
||||
|
||||
function shouldBlockInitialAuthoritativeReadyState(
|
||||
input: AgentScreenMachineInput
|
||||
): boolean {
|
||||
return (
|
||||
!input.shouldUseOptimisticStream &&
|
||||
!input.hasHydratedHistoryBefore &&
|
||||
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
||||
);
|
||||
}
|
||||
|
||||
export type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||
|
||||
export interface AgentScreenMachineMemory {
|
||||
@@ -104,10 +114,7 @@ export function deriveAgentScreenViewState({
|
||||
input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent
|
||||
? { ...input.agent, status: input.placeholderAgent.status }
|
||||
: input.agent ?? input.placeholderAgent;
|
||||
if (candidateAgent) {
|
||||
nextMemory.hasRenderedReady = true;
|
||||
nextMemory.lastReadyAgent = candidateAgent;
|
||||
}
|
||||
const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input);
|
||||
|
||||
if (input.missingAgentState.kind === "not_found") {
|
||||
return {
|
||||
@@ -129,6 +136,22 @@ export function deriveAgentScreenViewState({
|
||||
};
|
||||
}
|
||||
|
||||
if (candidateAgent && shouldBlockReadyState) {
|
||||
return {
|
||||
state: {
|
||||
tag: "boot",
|
||||
reason: "loading",
|
||||
source: "none",
|
||||
},
|
||||
memory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
if (candidateAgent) {
|
||||
nextMemory.hasRenderedReady = true;
|
||||
nextMemory.lastReadyAgent = candidateAgent;
|
||||
}
|
||||
|
||||
const displayAgent =
|
||||
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
|
||||
if (!displayAgent) {
|
||||
|
||||
@@ -978,6 +978,68 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("re-subscribes agent directory updates after reconnect", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_resubscribe",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "direct",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
measureLatency: async () => 5,
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
useSessionStore.getState().initializeSession(
|
||||
host.serverId,
|
||||
fakeClient as unknown as DaemonClient,
|
||||
null as any
|
||||
);
|
||||
store.syncHosts([host]);
|
||||
|
||||
const initialTimeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length < 1 && Date.now() < initialTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
fakeClient.setConnectionState({
|
||||
status: "disconnected",
|
||||
reason: "client_closed",
|
||||
});
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
|
||||
const reconnectTimeoutAt = Date.now() + 200;
|
||||
while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < reconnectTimeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
expect(fakeClient.fetchAgentsCalls).toEqual([
|
||||
{
|
||||
filter: { includeArchived: true },
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||
page: { limit: 200 },
|
||||
},
|
||||
{
|
||||
filter: { includeArchived: true },
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
subscribe: { subscriptionId: "app:srv_resubscribe" },
|
||||
page: { limit: 200 },
|
||||
},
|
||||
]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("surfaces startup failures as error instead of leaving host idle", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -1043,16 +1043,19 @@ export class HostRuntimeStore {
|
||||
const snapshot = controller.getSnapshot();
|
||||
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
||||
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
||||
if (snapshot.connectionStatus === "online" && previousStatus !== "online") {
|
||||
const didTransitionOnline =
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
if (didTransitionOnline) {
|
||||
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||
}
|
||||
|
||||
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
||||
// session initialization races.
|
||||
if (
|
||||
snapshot.connectionStatus !== "online" ||
|
||||
snapshot.hasEverLoadedAgentDirectory
|
||||
) {
|
||||
if (snapshot.connectionStatus !== "online") {
|
||||
this.clearAgentDirectorySessionRetry(serverId);
|
||||
return;
|
||||
}
|
||||
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
|
||||
this.clearAgentDirectorySessionRetry(serverId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { BottomAnchorRouteRequest } from "@/components/use-bottom-anchor-controller";
|
||||
|
||||
export type RouteBottomAnchorIntent = {
|
||||
routeKey: string;
|
||||
reason: BottomAnchorRouteRequest["reason"];
|
||||
};
|
||||
|
||||
export function deriveRouteBottomAnchorIntent(input: {
|
||||
cachedIntent: RouteBottomAnchorIntent | null;
|
||||
routeKey: string | null;
|
||||
hasAppliedAuthoritativeHistoryAtEntry: boolean;
|
||||
}): RouteBottomAnchorIntent | null {
|
||||
if (!input.routeKey) {
|
||||
return null;
|
||||
}
|
||||
if (input.cachedIntent?.routeKey === input.routeKey) {
|
||||
return input.cachedIntent;
|
||||
}
|
||||
return {
|
||||
routeKey: input.routeKey,
|
||||
reason: input.hasAppliedAuthoritativeHistoryAtEntry ? "resume" : "initial-entry",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveRouteBottomAnchorRequest(input: {
|
||||
intent: RouteBottomAnchorIntent | null;
|
||||
effectiveAgentId: string | null;
|
||||
}): BottomAnchorRouteRequest | null {
|
||||
if (!input.intent || !input.effectiveAgentId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason: input.intent.reason,
|
||||
agentId: input.effectiveAgentId,
|
||||
requestKey: `${input.intent.routeKey}:${input.intent.reason}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "./agent-ready-screen-bottom-anchor";
|
||||
|
||||
describe("agent-ready-screen bottom anchor intent", () => {
|
||||
it("latches initial-entry on first route entry before authoritative history is applied", () => {
|
||||
const intentAtEntry = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: null,
|
||||
routeKey: "server-1:agent-1",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: false,
|
||||
});
|
||||
|
||||
const intentAfterHistoryApplies = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: intentAtEntry,
|
||||
routeKey: "server-1:agent-1",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: true,
|
||||
});
|
||||
|
||||
expect(intentAfterHistoryApplies).toEqual({
|
||||
routeKey: "server-1:agent-1",
|
||||
reason: "initial-entry",
|
||||
});
|
||||
expect(
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent: intentAfterHistoryApplies,
|
||||
effectiveAgentId: "agent-1",
|
||||
})
|
||||
).toEqual({
|
||||
agentId: "agent-1",
|
||||
reason: "initial-entry",
|
||||
requestKey: "server-1:agent-1:initial-entry",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates resume requests when revisiting an already-hydrated route", () => {
|
||||
const intent = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: null,
|
||||
routeKey: "server-1:agent-2",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent,
|
||||
effectiveAgentId: "agent-2",
|
||||
})
|
||||
).toEqual({
|
||||
agentId: "agent-2",
|
||||
reason: "resume",
|
||||
requestKey: "server-1:agent-2:resume",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a request until the effective agent exists", () => {
|
||||
const intent = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: null,
|
||||
routeKey: "server-1:agent-3",
|
||||
hasAppliedAuthoritativeHistoryAtEntry: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent,
|
||||
effectiveAgentId: null,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,10 @@ import { shouldClearAgentAttention } from "@/utils/agent-attention";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "./agent-ready-screen-bottom-anchor";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
@@ -390,12 +394,19 @@ function AgentScreenContent({
|
||||
const historySyncGeneration = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
|
||||
);
|
||||
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(
|
||||
resolvedAgentId
|
||||
) === true
|
||||
: false
|
||||
);
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
|
||||
: -1
|
||||
);
|
||||
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
|
||||
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
||||
|
||||
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
||||
const allPendingPermissions = useSessionStore(
|
||||
@@ -432,6 +443,10 @@ function AgentScreenContent({
|
||||
});
|
||||
const reconnectToastArmedRef = useRef(false);
|
||||
const initAttemptTokenRef = useRef(0);
|
||||
const routeBottomAnchorRequestRef = useRef<{
|
||||
routeKey: string;
|
||||
reason: "initial-entry" | "resume";
|
||||
} | null>(null);
|
||||
const attentionClientRef = useRef(client);
|
||||
const attentionConnectedRef = useRef(isConnected);
|
||||
const setFocusedAgentId = useCallback(
|
||||
@@ -658,6 +673,20 @@ function AgentScreenContent({
|
||||
});
|
||||
|
||||
const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null;
|
||||
const routeEntryKey = resolvedAgentId ? `${serverId}:${resolvedAgentId}` : null;
|
||||
routeBottomAnchorRequestRef.current = deriveRouteBottomAnchorIntent({
|
||||
cachedIntent: routeBottomAnchorRequestRef.current,
|
||||
routeKey: routeEntryKey,
|
||||
hasAppliedAuthoritativeHistoryAtEntry: hasAppliedAuthoritativeHistory,
|
||||
});
|
||||
const routeBottomAnchorRequest = useMemo(
|
||||
() =>
|
||||
deriveRouteBottomAnchorRequest({
|
||||
intent: routeBottomAnchorRequestRef.current,
|
||||
effectiveAgentId: effectiveAgent?.id ?? null,
|
||||
}),
|
||||
[effectiveAgent?.id]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isPendingCreateForRoute || !pendingCreate) {
|
||||
return;
|
||||
@@ -914,6 +943,8 @@ function AgentScreenContent({
|
||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||
}
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
@@ -926,7 +957,12 @@ function AgentScreenContent({
|
||||
autoFocus
|
||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onMessageSent={() => streamViewRef.current?.scrollToBottom()}
|
||||
onComposerHeightChange={() =>
|
||||
streamViewRef.current?.prepareForViewportChange()
|
||||
}
|
||||
onMessageSent={() =>
|
||||
streamViewRef.current?.scrollToBottom("message-sent")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -166,6 +166,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||
const tooltipLabel =
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
? "Loading agent title"
|
||||
: presentation.label;
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
const isFirstTab = index === 0;
|
||||
@@ -174,107 +178,114 @@ export function WorkspaceDesktopTabsRow({
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
<ContextMenuTrigger
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
isActive && styles.tabActive,
|
||||
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||
]}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPressIn={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={
|
||||
tab.kind === "agent" && tab.titleState === "loading"
|
||||
? "Loading agent title"
|
||||
: tab.label
|
||||
}
|
||||
>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID={`workspace-tab-tooltip-${tab.key}`}
|
||||
accessibilityRole="none"
|
||||
style={styles.tabTooltipTrigger}
|
||||
>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive && styles.tabLabelActive,
|
||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{renderedLabel}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{shouldShowCloseButton ? (
|
||||
<Pressable
|
||||
testID={
|
||||
tab.kind === "agent"
|
||||
? `workspace-agent-close-${tab.agentId}`
|
||||
: tab.kind === "terminal"
|
||||
? `workspace-terminal-close-${tab.terminalId}`
|
||||
: tab.kind === "draft"
|
||||
? `workspace-draft-close-${tab.draftId}`
|
||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||
}
|
||||
disabled={isClosingTab}
|
||||
<ContextMenuTrigger
|
||||
testID={`workspace-tab-${tab.key}`}
|
||||
enabledOnMobile={false}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tab,
|
||||
{
|
||||
minWidth: resolvedTabWidth,
|
||||
width: resolvedTabWidth,
|
||||
maxWidth: resolvedTabWidth,
|
||||
},
|
||||
isActive && styles.tabActive,
|
||||
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||
]}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation?.();
|
||||
void onCloseTab(tab.tabId);
|
||||
onPressIn={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
onNavigateTab(tab.tabId);
|
||||
}}
|
||||
accessibilityLabel={tooltipLabel}
|
||||
>
|
||||
{isClosingTab ? (
|
||||
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<X size={12} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive && styles.tabLabelActive,
|
||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{renderedLabel}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{shouldShowCloseButton ? (
|
||||
<Pressable
|
||||
testID={
|
||||
tab.kind === "agent"
|
||||
? `workspace-agent-close-${tab.agentId}`
|
||||
: tab.kind === "terminal"
|
||||
? `workspace-terminal-close-${tab.terminalId}`
|
||||
: tab.kind === "draft"
|
||||
? `workspace-draft-close-${tab.draftId}`
|
||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||
}
|
||||
disabled={isClosingTab}
|
||||
onHoverIn={() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}}
|
||||
onHoverOut={() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation?.();
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.tabCloseButton,
|
||||
styles.tabCloseButtonShown,
|
||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||
]}
|
||||
>
|
||||
{isClosingTab ? (
|
||||
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<X size={12} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{tab.kind === "agent" ? (
|
||||
@@ -426,6 +437,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
tabTooltipTrigger: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
tab: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
|
||||
@@ -12,8 +12,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Folder,
|
||||
GitBranch,
|
||||
MoreVertical,
|
||||
PanelRight,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
@@ -24,6 +26,12 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -1006,6 +1014,20 @@ function WorkspaceScreenContent({
|
||||
[sessionAgents, toast]
|
||||
);
|
||||
|
||||
const handleCopyWorkspacePath = useCallback(async () => {
|
||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Clipboard.setStringAsync(normalizedWorkspaceId);
|
||||
toast.copied("Workspace path");
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
}, [normalizedWorkspaceId, toast]);
|
||||
|
||||
const handleBulkCloseTabs = useCallback(
|
||||
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
||||
const { tabsToClose, title, logLabel } = input;
|
||||
@@ -1252,6 +1274,7 @@ function WorkspaceScreenContent({
|
||||
if (target.kind === "agent") {
|
||||
return (
|
||||
<AgentReadyScreen
|
||||
key={`${normalizedServerId}:${normalizedWorkspaceId}:${target.agentId}`}
|
||||
serverId={normalizedServerId}
|
||||
agentId={target.agentId}
|
||||
showExplorerSidebar={false}
|
||||
@@ -1331,13 +1354,45 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
right={
|
||||
<View style={styles.headerRight}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-header-menu-trigger"
|
||||
style={styles.headerActionButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
>
|
||||
<MoreVertical
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
width={220}
|
||||
testID="workspace-header-menu"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={
|
||||
<Copy
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
||||
onSelect={handleCopyWorkspacePath}
|
||||
>
|
||||
Copy workspace path
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<HeaderToggleButton
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
tooltipLabel="Toggle explorer"
|
||||
tooltipKeys={["mod", "E"]}
|
||||
tooltipSide="left"
|
||||
style={styles.menuButton}
|
||||
style={styles.headerActionButton}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||
@@ -1599,10 +1654,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
headerRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuButton: {
|
||||
padding: theme.spacing[3],
|
||||
headerActionButton: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
newTabActions: {
|
||||
|
||||
@@ -230,6 +230,7 @@ export interface SessionState {
|
||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||
historySyncGeneration: number;
|
||||
agentHistorySyncGeneration: Map<string, number>;
|
||||
agentAuthoritativeHistoryApplied: Map<string, boolean>;
|
||||
|
||||
// Initializing agents (used for UI loading state)
|
||||
initializingAgents: Map<string, boolean>;
|
||||
@@ -295,6 +296,11 @@ interface SessionStoreActions {
|
||||
) => void;
|
||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||
setAgentAuthoritativeHistoryApplied: (
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
applied: boolean
|
||||
) => void;
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId: string, state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>)) => void;
|
||||
@@ -391,6 +397,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient, audio
|
||||
agentTimelineCursor: new Map(),
|
||||
historySyncGeneration: 0,
|
||||
agentHistorySyncGeneration: new Map(),
|
||||
agentAuthoritativeHistoryApplied: new Map(),
|
||||
initializingAgents: new Map(),
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
@@ -855,6 +862,44 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const previousApplied =
|
||||
session.agentAuthoritativeHistoryApplied.get(agentId) ?? false;
|
||||
if (previousApplied === applied) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const nextApplied = new Map(session.agentAuthoritativeHistoryApplied);
|
||||
if (applied) {
|
||||
nextApplied.set(agentId, true);
|
||||
} else {
|
||||
nextApplied.delete(agentId);
|
||||
}
|
||||
|
||||
logSessionStoreUpdate("setAgentAuthoritativeHistoryApplied", serverId, {
|
||||
agentId,
|
||||
applied,
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
agentAuthoritativeHistoryApplied: nextApplied,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Initializing agents
|
||||
setInitializingAgents: (serverId, state) => {
|
||||
set((prev) => {
|
||||
|
||||
+355
-2
@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentStreamEvent,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
@@ -297,7 +300,7 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
expect(sdkMocks.firstQuery).toBeTruthy();
|
||||
expect(sdkMocks.secondQuery).toBeTruthy();
|
||||
expect(sdkMocks.firstQuery).not.toBe(sdkMocks.secondQuery);
|
||||
expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(2);
|
||||
expect(sdkMocks.firstQuery?.interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(sdkMocks.secondQuery?.next).toHaveBeenCalled();
|
||||
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
|
||||
expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE");
|
||||
@@ -979,6 +982,146 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("ignores unmatched resumed-session errors without starting an autonomous run", async () => {
|
||||
const logger = createTestLogger();
|
||||
const keepQueryAlive = deferred<void>();
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
session_id: "new-session-after-missing-conversation",
|
||||
errors: [
|
||||
"No conversation found with session ID: persisted-stale-session",
|
||||
],
|
||||
num_turns: 0,
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
is_error: true,
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: buildUsage(),
|
||||
},
|
||||
};
|
||||
}
|
||||
await keepQueryAlive.promise;
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "persisted-stale-session",
|
||||
nativeHandle: "persisted-stale-session",
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
const session = await client.resumeSession(handle, { cwd: process.cwd() });
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
const activeRuns = (
|
||||
session as unknown as {
|
||||
runTracker: { listActiveRuns: (owner?: "foreground" | "autonomous") => unknown[] };
|
||||
}
|
||||
).runTracker.listActiveRuns();
|
||||
|
||||
expect(activeRuns).toHaveLength(0);
|
||||
|
||||
keepQueryAlive.resolve(undefined);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("stops retrying live query pump when resumed Claude session no longer exists", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
queryCreateCount += 1;
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "error_during_execution",
|
||||
session_id: "new-session-after-missing-conversation",
|
||||
errors: [
|
||||
"No conversation found with session ID: persisted-stale-session",
|
||||
],
|
||||
num_turns: 0,
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
is_error: true,
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: buildUsage(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "claude",
|
||||
sessionId: "persisted-stale-session",
|
||||
nativeHandle: "persisted-stale-session",
|
||||
metadata: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
|
||||
const session = await client.resumeSession(handle, { cwd: process.cwd() });
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const liveNext = liveIterator.next();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
|
||||
expect(queryCreateCount).toBe(1);
|
||||
expect(session.describePersistence()).toBeNull();
|
||||
|
||||
await session.close();
|
||||
await liveNext;
|
||||
});
|
||||
|
||||
test("does not emit live autonomous turn events for local_agent task_started during a foreground run", async () => {
|
||||
const logger = createTestLogger();
|
||||
const keepQueryAlive = deferred<void>();
|
||||
@@ -1150,6 +1293,216 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("does not let task_notification reservations steal a foreground terminal result", async () => {
|
||||
const logger = createTestLogger();
|
||||
const keepQueryAlive = deferred<void>();
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return {
|
||||
next: vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "task-notification-foreground-session",
|
||||
permissionMode: "default",
|
||||
model: "opus",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 1) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "task-foreground-1",
|
||||
tool_use_id: "toolu_foreground_1",
|
||||
status: "completed",
|
||||
output_file: "/tmp/task-foreground-1.txt",
|
||||
summary: "Check Phase 1",
|
||||
session_id: "task-notification-foreground-session",
|
||||
uuid: "task-note-foreground-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 2) {
|
||||
step += 1;
|
||||
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "user",
|
||||
message: { role: "user", content: "verify prompt" },
|
||||
parent_tool_use_id: null,
|
||||
uuid: promptUuid,
|
||||
session_id: "task-notification-foreground-session",
|
||||
isReplay: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 3) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "foreground-tool-msg",
|
||||
role: "assistant",
|
||||
model: "opus",
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
session_id: "task-notification-foreground-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: "foreground-message-start",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 4) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_foreground_2",
|
||||
name: "Agent",
|
||||
input: {},
|
||||
caller: { type: "direct" },
|
||||
},
|
||||
},
|
||||
session_id: "task-notification-foreground-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: "foreground-tool-use-start",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 5) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "foreground-final-msg",
|
||||
content: "FOREGROUND_RESULT_STAYS_ATTACHED",
|
||||
},
|
||||
session_id: "task-notification-foreground-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 6) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
session_id: "task-notification-foreground-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: "foreground-message-delta",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 7) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "stream_event",
|
||||
event: { type: "message_stop" },
|
||||
session_id: "task-notification-foreground-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: "foreground-message-stop",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 8) {
|
||||
step += 1;
|
||||
return {
|
||||
done: false,
|
||||
value: {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: buildUsage(),
|
||||
total_cost_usd: 0,
|
||||
stop_reason: "end_turn",
|
||||
session_id: "task-notification-foreground-session",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (step === 9) {
|
||||
await keepQueryAlive.promise;
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
}),
|
||||
interrupt: vi.fn(async () => undefined),
|
||||
return: vi.fn(async () => undefined),
|
||||
setPermissionMode: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
} satisfies QueryMock;
|
||||
});
|
||||
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
const foregroundEvents = await collectUntilTerminal(session.stream("verify prompt"));
|
||||
const liveIterator = (
|
||||
session as unknown as {
|
||||
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
|
||||
}
|
||||
).streamLiveEvents();
|
||||
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const next = await timedReader.nextWithTimeout(25);
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
liveEvents.push(next.value);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(collectAssistantText(foregroundEvents)).toContain(
|
||||
"FOREGROUND_RESULT_STAYS_ATTACHED"
|
||||
);
|
||||
expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true);
|
||||
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(false);
|
||||
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(false);
|
||||
|
||||
keepQueryAlive.resolve(undefined);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
|
||||
const logger = createTestLogger();
|
||||
let queryCreateCount = 0;
|
||||
|
||||
@@ -74,6 +74,24 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||
|
||||
/*
|
||||
* Routing invariant:
|
||||
* While a foreground Claude turn is active, identifier-less assistant/stream/result
|
||||
* events must stay attached to that foreground run unless we have explicit evidence
|
||||
* that a distinct autonomous run has started.
|
||||
*
|
||||
* We previously allowed task_notification metadata to reserve autonomous ownership,
|
||||
* then consumed that reservation on the next unbound chunk. In practice Claude often
|
||||
* emits task_notification records and foreground tool-use stream chunks interleaved
|
||||
* within the same turn. That let a foreground turn's terminal result get misrouted
|
||||
* into an autonomous side run, which stranded the agent in "running" because the
|
||||
* foreground stream never received turn_completed.
|
||||
*
|
||||
* The rule below is intentionally conservative: foreground turns get first claim on
|
||||
* same-turn traffic, and autonomous wake reservations are only consumed once no
|
||||
* foreground turn is active. This keeps task_notification advisory instead of letting
|
||||
* it steal ownership from the active user turn.
|
||||
*/
|
||||
const fsPromises = promises;
|
||||
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
|
||||
"user",
|
||||
@@ -102,8 +120,9 @@ type RoutingReason =
|
||||
| "parent_message_id"
|
||||
| "message_id"
|
||||
| "unbound_autonomous"
|
||||
| "fallback"
|
||||
| "metadata";
|
||||
| "reserved_autonomous"
|
||||
| "metadata"
|
||||
| "ignored_unmatched";
|
||||
|
||||
type EventIdentifiers = {
|
||||
taskId: string | null;
|
||||
@@ -114,6 +133,7 @@ type EventIdentifiers = {
|
||||
type RunRoute = {
|
||||
run: RunRecord | null;
|
||||
reason: RoutingReason;
|
||||
dispatchWithoutRun?: boolean;
|
||||
};
|
||||
|
||||
type RunRecord = {
|
||||
@@ -2583,8 +2603,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
): RunRoute {
|
||||
if (normalized.metadataOnly) {
|
||||
if (
|
||||
normalized.message.type === "user" &&
|
||||
isTaskNotificationUserContent(normalized.message.message?.content)
|
||||
(normalized.message.type === "user" &&
|
||||
isTaskNotificationUserContent(normalized.message.message?.content)) ||
|
||||
(normalized.message.type === "system" &&
|
||||
normalized.message.subtype === "task_notification")
|
||||
) {
|
||||
this.reserveAutonomousWake("task_notification");
|
||||
}
|
||||
@@ -2603,13 +2625,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return byIdentifiers;
|
||||
}
|
||||
|
||||
if (this.turnState === "autonomous") {
|
||||
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
|
||||
if (activeAutonomousRun) {
|
||||
return { run: activeAutonomousRun, reason: "unbound_autonomous" };
|
||||
}
|
||||
}
|
||||
|
||||
const foregroundRun = this.activeForegroundTurn
|
||||
? this.runTracker.getRun(this.activeForegroundTurn.runId)
|
||||
: null;
|
||||
@@ -2648,32 +2663,62 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { run: foregroundRun, reason: "foreground" };
|
||||
}
|
||||
|
||||
if (
|
||||
this.pendingAutonomousWakeReservations > 0 &&
|
||||
!normalized.identifiers.taskId &&
|
||||
!normalized.identifiers.parentMessageId &&
|
||||
!normalized.identifiers.messageId
|
||||
) {
|
||||
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
|
||||
"reservation_unbound"
|
||||
);
|
||||
return {
|
||||
run: reservedAutonomousRun,
|
||||
reason: "reserved_autonomous",
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasIdentifiers) {
|
||||
if (this.pendingAutonomousWakeReservations > 0) {
|
||||
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
|
||||
"reservation_unbound"
|
||||
);
|
||||
return {
|
||||
run: reservedAutonomousRun,
|
||||
reason: "unbound_autonomous",
|
||||
};
|
||||
}
|
||||
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
|
||||
if (activeAutonomousRun) {
|
||||
return { run: activeAutonomousRun, reason: "unbound_autonomous" };
|
||||
}
|
||||
if (
|
||||
!foregroundRun &&
|
||||
(normalized.message.type === "assistant" ||
|
||||
normalized.message.type === "stream_event" ||
|
||||
normalized.message.type === "result" ||
|
||||
normalized.message.type === "tool_progress")
|
||||
) {
|
||||
const autonomousRun = this.claimOrCreateAutonomousRun("unbound_implicit");
|
||||
return { run: autonomousRun, reason: "unbound_autonomous" };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pendingAutonomousWakeReservations > 0) {
|
||||
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
|
||||
"reservation_fallback"
|
||||
);
|
||||
return { run: reservedAutonomousRun, reason: "fallback" };
|
||||
return { run: reservedAutonomousRun, reason: "reserved_autonomous" };
|
||||
}
|
||||
|
||||
const autonomousRun = this.createRun("autonomous", null);
|
||||
this.emitRunEvent(autonomousRun, { type: "turn_started", provider: "claude" });
|
||||
return { run: autonomousRun, reason: "fallback" };
|
||||
this.logger.debug(
|
||||
{
|
||||
messageType: normalized.message.type,
|
||||
hasIdentifiers,
|
||||
taskId: normalized.identifiers.taskId,
|
||||
parentMessageId: normalized.identifiers.parentMessageId,
|
||||
messageId: normalized.identifiers.messageId,
|
||||
turnState: this.turnState,
|
||||
pendingAutonomousWakeReservations: this.pendingAutonomousWakeReservations,
|
||||
},
|
||||
"Ignoring unmatched Claude SDK message without explicit run start signal"
|
||||
);
|
||||
return {
|
||||
run: null,
|
||||
reason: "ignored_unmatched",
|
||||
dispatchWithoutRun: false,
|
||||
};
|
||||
}
|
||||
|
||||
private shouldPreferForegroundRun(input: {
|
||||
@@ -2746,10 +2791,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!foregroundRun || foregroundRun.promptReplaySeen) {
|
||||
return;
|
||||
}
|
||||
// System metadata (init/hook callbacks/etc.) can precede the first prompt
|
||||
// replay for a legitimate foreground run. Treating that as churn strands
|
||||
// one-shot helper runs in autonomous fallback.
|
||||
if (message.type === "system") {
|
||||
// Most system metadata (init/hook callbacks/etc.) can precede the first prompt
|
||||
// replay for a legitimate foreground run. Treating all of it as churn strands
|
||||
// one-shot helper runs. task_notification is the exception: it represents
|
||||
// background agent activity and should suppress pre-replay foreground routing.
|
||||
if (
|
||||
message.type === "system" &&
|
||||
message.subtype !== "task_notification"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.preReplayMetadataSeen = true;
|
||||
@@ -2886,6 +2935,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await this.handleMissingResumedConversation(sdkMessage, q)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
this.routeSdkMessageFromPump(sdkMessage);
|
||||
} catch (error) {
|
||||
@@ -2954,6 +3007,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (!route.run) {
|
||||
if (route.dispatchWithoutRun === false) {
|
||||
return;
|
||||
}
|
||||
this.dispatchMetadataEvents(events);
|
||||
return;
|
||||
}
|
||||
@@ -2963,6 +3019,45 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissingResumedConversation(
|
||||
message: SDKMessage,
|
||||
query: Query
|
||||
): Promise<boolean> {
|
||||
const staleResumeError = this.readMissingResumedConversationError(message);
|
||||
if (!staleResumeError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
{
|
||||
claudeSessionId: this.claudeSessionId,
|
||||
error: staleResumeError,
|
||||
},
|
||||
"Claude resumed session no longer exists; invalidating persisted session"
|
||||
);
|
||||
|
||||
for (const run of this.runTracker.listActiveRuns()) {
|
||||
this.failRun(run, staleResumeError);
|
||||
}
|
||||
this.transitionTurnStateFromActiveRuns("missing resumed conversation");
|
||||
this.input?.end();
|
||||
await this.awaitWithTimeout(
|
||||
query.return?.(),
|
||||
"query pump return on missing resumed conversation"
|
||||
);
|
||||
if (this.query === query) {
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
}
|
||||
this.claudeSessionId = null;
|
||||
this.persistence = null;
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private shouldSuppressReplayResultTerminal(input: {
|
||||
run: RunRecord | null;
|
||||
message: SDKMessage;
|
||||
@@ -3691,6 +3786,30 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return threadStartedSessionId;
|
||||
}
|
||||
|
||||
private readMissingResumedConversationError(message: SDKMessage): string | null {
|
||||
if (message.type !== "result" || message.subtype !== "error_during_execution") {
|
||||
return null;
|
||||
}
|
||||
if (!this.claudeSessionId) {
|
||||
return null;
|
||||
}
|
||||
const errors =
|
||||
"errors" in message && Array.isArray(message.errors) ? message.errors : [];
|
||||
for (const entry of errors) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const match = entry.match(/^No conversation found with session ID:\s*(.+)$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (match[1]?.trim() === this.claudeSessionId) {
|
||||
return entry.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
||||
if (!message.usage) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude-agent.js";
|
||||
import { isCommandAvailable } from "../agent/provider-launch-config.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-autonomous-simple-"));
|
||||
}
|
||||
|
||||
function compactText(value: string): string {
|
||||
return value.replace(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude) - autonomous wake simple", () => {
|
||||
test.runIf(isCommandAvailable("claude"))(
|
||||
"hello + background sleep returns idle, then wakes once on completion",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-autonomous-simple-real" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-autonomous-simple-real",
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`;
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Hello.",
|
||||
"Use the Bash tool with run_in_background.",
|
||||
"Run exactly: sleep 5",
|
||||
"Do not wait for the task result.",
|
||||
"Reply immediately with exactly: SPAWNED",
|
||||
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
const firstFinish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(firstFinish.status).toBe("idle");
|
||||
|
||||
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const idleAssistantText = timelineAtIdle.entries
|
||||
.filter(
|
||||
(entry): entry is typeof entry & {
|
||||
item: { type: "assistant_message"; text: string };
|
||||
} => entry.item.type === "assistant_message"
|
||||
)
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
expect(compactText(idleAssistantText)).toContain("spawned");
|
||||
expect(
|
||||
timelineAtIdle.entries.some(
|
||||
(entry) =>
|
||||
entry.item.type === "tool_call" &&
|
||||
entry.item.name === "Bash"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
30_000
|
||||
);
|
||||
|
||||
const autonomousFinish = await client.waitForFinish(agent.id, 120_000);
|
||||
expect(autonomousFinish.status).toBe("idle");
|
||||
|
||||
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const finalAssistantText = finalTimeline.entries
|
||||
.filter(
|
||||
(entry): entry is typeof entry & {
|
||||
item: { type: "assistant_message"; text: string };
|
||||
} => entry.item.type === "assistant_message"
|
||||
)
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
expect(compactText(finalAssistantText)).toContain(
|
||||
autonomousWakeToken.toLowerCase()
|
||||
);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user