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
|
commandDraftConfig?: DraftCommandConfig
|
||||||
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
|
||||||
onMessageSent?: () => void
|
onMessageSent?: () => void
|
||||||
|
onComposerHeightChange?: (height: number) => void
|
||||||
/** Controlled status controls rendered in input area (draft flows). */
|
/** Controlled status controls rendered in input area (draft flows). */
|
||||||
statusControls?: DraftAgentStatusBarProps
|
statusControls?: DraftAgentStatusBarProps
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,7 @@ export function AgentInputArea({
|
|||||||
onAddImages,
|
onAddImages,
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
|
onComposerHeightChange,
|
||||||
statusControls,
|
statusControls,
|
||||||
}: AgentInputAreaProps) {
|
}: AgentInputAreaProps) {
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||||
@@ -823,6 +825,7 @@ export function AgentInputArea({
|
|||||||
setCursorIndex(selection.start)
|
setCursorIndex(selection.start)
|
||||||
}}
|
}}
|
||||||
onFocusChange={setIsMessageInputFocused}
|
onFocusChange={setIsMessageInputFocused}
|
||||||
|
onHeightChange={onComposerHeightChange}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isNearBottomForStreamRenderStrategy,
|
isNearBottomForStreamRenderStrategy,
|
||||||
orderHeadForStreamRenderStrategy,
|
orderHeadForStreamRenderStrategy,
|
||||||
orderTailForStreamRenderStrategy,
|
orderTailForStreamRenderStrategy,
|
||||||
|
resolveBottomAnchorTransportBehavior,
|
||||||
resolveStreamRenderStrategy,
|
resolveStreamRenderStrategy,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
|
|
||||||
@@ -45,6 +46,10 @@ describe("resolveStreamRenderStrategy", () => {
|
|||||||
expect(strategy.getFlatListInverted()).toBe(false);
|
expect(strategy.getFlatListInverted()).toBe(false);
|
||||||
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
|
expect(strategy.getOverlayScrollbarInverted()).toBe(false);
|
||||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
|
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(true);
|
||||||
|
expect(strategy.getBottomAnchorTransportBehavior()).toEqual({
|
||||||
|
verificationDelayFrames: 0,
|
||||||
|
verificationRetryMode: "rescroll",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses inverted_stream on native", () => {
|
it("uses inverted_stream on native", () => {
|
||||||
@@ -57,6 +62,44 @@ describe("resolveStreamRenderStrategy", () => {
|
|||||||
expect(strategy.getFlatListInverted()).toBe(true);
|
expect(strategy.getFlatListInverted()).toBe(true);
|
||||||
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
|
expect(strategy.getOverlayScrollbarInverted()).toBe(true);
|
||||||
expect(strategy.shouldAnchorBottomOnContentSizeChange()).toBe(false);
|
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;
|
autoscrollToTopThreshold: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type BottomAnchorTransportBehavior = Readonly<{
|
||||||
|
verificationDelayFrames: number;
|
||||||
|
verificationRetryMode: "rescroll" | "recheck";
|
||||||
|
}>;
|
||||||
|
|
||||||
export type StreamViewportMetrics = {
|
export type StreamViewportMetrics = {
|
||||||
contentHeight: number;
|
contentHeight: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
@@ -29,7 +34,7 @@ export type StreamEdgeSlotProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StreamRenderRefs = {
|
export type StreamRenderRefs = {
|
||||||
flatListRef: RefObject<FlatList<StreamItem> | null>;
|
flatListRef: RefObject<FlatList<any> | null>;
|
||||||
scrollViewRef: RefObject<ScrollView | null>;
|
scrollViewRef: RefObject<ScrollView | null>;
|
||||||
bottomAnchorRef: RefObject<View | null>;
|
bottomAnchorRef: RefObject<View | null>;
|
||||||
};
|
};
|
||||||
@@ -58,6 +63,7 @@ export interface StreamRenderStrategy {
|
|||||||
getMaintainVisibleContentPosition: () =>
|
getMaintainVisibleContentPosition: () =>
|
||||||
| MaintainVisibleContentPositionConfig
|
| MaintainVisibleContentPositionConfig
|
||||||
| undefined;
|
| undefined;
|
||||||
|
getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior;
|
||||||
getFlatListInverted: () => boolean;
|
getFlatListInverted: () => boolean;
|
||||||
getOverlayScrollbarInverted: () => boolean;
|
getOverlayScrollbarInverted: () => boolean;
|
||||||
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
shouldDisableParentScrollOnInlineDetailsExpansion: () => boolean;
|
||||||
@@ -76,6 +82,8 @@ export interface StreamRenderStrategy {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
|
||||||
|
|
||||||
type StreamRenderStrategyConfig = {
|
type StreamRenderStrategyConfig = {
|
||||||
orderTailReverse: boolean;
|
orderTailReverse: boolean;
|
||||||
orderHeadReverse: boolean;
|
orderHeadReverse: boolean;
|
||||||
@@ -84,6 +92,7 @@ type StreamRenderStrategyConfig = {
|
|||||||
flatListInverted: boolean;
|
flatListInverted: boolean;
|
||||||
overlayScrollbarInverted: boolean;
|
overlayScrollbarInverted: boolean;
|
||||||
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
maintainVisibleContentPosition?: MaintainVisibleContentPositionConfig;
|
||||||
|
bottomAnchorTransportBehavior: BottomAnchorTransportBehavior;
|
||||||
disableParentScrollOnInlineDetailsExpansion: boolean;
|
disableParentScrollOnInlineDetailsExpansion: boolean;
|
||||||
anchorBottomOnContentSizeChange: boolean;
|
anchorBottomOnContentSizeChange: boolean;
|
||||||
animateManualScrollToBottom: boolean;
|
animateManualScrollToBottom: boolean;
|
||||||
@@ -137,28 +146,26 @@ function scrollAnchorIntoView(params: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function forceScrollContainerToBottom(
|
function resolvePotentialScrollNode(input: unknown): HTMLElement | null {
|
||||||
refs: StreamRenderRefs,
|
if (!(input instanceof HTMLElement)) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
return null;
|
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
|
const scrollViewHandle = refs.scrollViewRef.current as
|
||||||
| {
|
| {
|
||||||
@@ -185,18 +192,43 @@ function forceScrollContainerToBottom(
|
|||||||
|
|
||||||
let scrollNode: HTMLElement | null = null;
|
let scrollNode: HTMLElement | null = null;
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
scrollNode = resolveNode(candidate);
|
scrollNode = resolvePotentialScrollNode(candidate);
|
||||||
if (scrollNode) {
|
if (scrollNode) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!scrollNode && typeof document !== "undefined") {
|
if (!scrollNode && typeof document !== "undefined") {
|
||||||
scrollNode = resolveNode(
|
scrollNode = resolvePotentialScrollNode(
|
||||||
document.querySelector("[data-testid='agent-chat-scroll']")
|
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) {
|
if (!scrollNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,6 +299,7 @@ function createStreamRenderStrategy(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
getMaintainVisibleContentPosition: () => config.maintainVisibleContentPosition,
|
||||||
|
getBottomAnchorTransportBehavior: () => config.bottomAnchorTransportBehavior,
|
||||||
getFlatListInverted: () => config.flatListInverted,
|
getFlatListInverted: () => config.flatListInverted,
|
||||||
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
getOverlayScrollbarInverted: () => config.overlayScrollbarInverted,
|
||||||
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
shouldDisableParentScrollOnInlineDetailsExpansion: () =>
|
||||||
@@ -289,6 +322,10 @@ function createInvertedStreamStrategy(): StreamRenderStrategy {
|
|||||||
flatListInverted: true,
|
flatListInverted: true,
|
||||||
overlayScrollbarInverted: true,
|
overlayScrollbarInverted: true,
|
||||||
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
maintainVisibleContentPosition: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION,
|
||||||
|
bottomAnchorTransportBehavior: {
|
||||||
|
verificationDelayFrames: 2,
|
||||||
|
verificationRetryMode: "recheck",
|
||||||
|
},
|
||||||
disableParentScrollOnInlineDetailsExpansion: false,
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
anchorBottomOnContentSizeChange: false,
|
anchorBottomOnContentSizeChange: false,
|
||||||
animateManualScrollToBottom: true,
|
animateManualScrollToBottom: true,
|
||||||
@@ -316,6 +353,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
|
|||||||
flatListInverted: false,
|
flatListInverted: false,
|
||||||
overlayScrollbarInverted: false,
|
overlayScrollbarInverted: false,
|
||||||
maintainVisibleContentPosition: undefined,
|
maintainVisibleContentPosition: undefined,
|
||||||
|
bottomAnchorTransportBehavior: {
|
||||||
|
verificationDelayFrames: 0,
|
||||||
|
verificationRetryMode: "rescroll",
|
||||||
|
},
|
||||||
disableParentScrollOnInlineDetailsExpansion: false,
|
disableParentScrollOnInlineDetailsExpansion: false,
|
||||||
anchorBottomOnContentSizeChange: true,
|
anchorBottomOnContentSizeChange: true,
|
||||||
animateManualScrollToBottom: false,
|
animateManualScrollToBottom: false,
|
||||||
@@ -335,6 +376,14 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
|
|||||||
0,
|
0,
|
||||||
metrics.contentHeight - metrics.viewportHeight
|
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 });
|
const usedAnchor = scrollAnchorIntoView({ refs, animated });
|
||||||
if (!usedAnchor) {
|
if (!usedAnchor) {
|
||||||
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
refs.scrollViewRef.current?.scrollToEnd?.({ animated });
|
||||||
@@ -347,6 +396,10 @@ function createForwardStreamStrategy(): StreamRenderStrategy {
|
|||||||
forceScrollContainerToBottom(refs, bottomOffset);
|
forceScrollContainerToBottom(refs, bottomOffset);
|
||||||
},
|
},
|
||||||
scrollToOffset: ({ refs, offset, animated }) => {
|
scrollToOffset: ({ refs, offset, animated }) => {
|
||||||
|
if (refs.flatListRef.current) {
|
||||||
|
refs.flatListRef.current.scrollToOffset?.({ offset, animated });
|
||||||
|
return;
|
||||||
|
}
|
||||||
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
refs.scrollViewRef.current?.scrollTo({ y: offset, animated });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import {
|
|||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
NativeScrollEvent,
|
NativeScrollEvent,
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
InteractionManager,
|
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Keyboard,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Markdown from "react-native-markdown-display";
|
import Markdown from "react-native-markdown-display";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -75,9 +75,22 @@ import {
|
|||||||
isNearBottomForStreamRenderStrategy,
|
isNearBottomForStreamRenderStrategy,
|
||||||
orderHeadForStreamRenderStrategy,
|
orderHeadForStreamRenderStrategy,
|
||||||
orderTailForStreamRenderStrategy,
|
orderTailForStreamRenderStrategy,
|
||||||
|
resolveBottomAnchorTransportBehavior,
|
||||||
|
resolveWebScrollContainerNode,
|
||||||
resolveStreamRenderStrategy,
|
resolveStreamRenderStrategy,
|
||||||
type StreamEdgeSlotProps,
|
type StreamEdgeSlotProps,
|
||||||
} from "./agent-stream-render-strategy";
|
} 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 { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
@@ -109,7 +122,8 @@ function renderStreamEdgeComponent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStreamViewHandle {
|
export interface AgentStreamViewHandle {
|
||||||
scrollToBottom(): void;
|
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||||
|
prepareForViewportChange(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
@@ -118,6 +132,8 @@ export interface AgentStreamViewProps {
|
|||||||
agent: Agent;
|
agent: Agent;
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
|
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||||
|
isAuthoritativeHistoryReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(function AgentStreamView({
|
||||||
@@ -126,8 +142,10 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
agent,
|
agent,
|
||||||
streamItems,
|
streamItems,
|
||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
|
routeBottomAnchorRequest = null,
|
||||||
|
isAuthoritativeHistoryReady = true,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
const flatListRef = useRef<FlatList<any>>(null);
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const bottomAnchorRef = useRef<View>(null);
|
const bottomAnchorRef = useRef<View>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -145,19 +163,23 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||||
const hasScrolledInitially = useRef(false);
|
|
||||||
const hasAutoScrolledOnce = useRef(false);
|
|
||||||
const isNearBottomRef = useRef(true);
|
const isNearBottomRef = useRef(true);
|
||||||
const pendingAnchorRequestRef = useRef(false);
|
|
||||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
|
||||||
const pendingAutoScrollAnimatedRef = useRef(false);
|
|
||||||
const scrollOffsetYRef = useRef(0);
|
const scrollOffsetYRef = useRef(0);
|
||||||
|
const programmaticScrollEventBudgetRef = useRef(0);
|
||||||
const streamItemCountRef = useRef(0);
|
const streamItemCountRef = useRef(0);
|
||||||
|
const previousStreamItemsRef = useRef<StreamItem[] | null>(null);
|
||||||
const streamViewportMetricsRef = useRef({
|
const streamViewportMetricsRef = useRef({
|
||||||
|
containerKey: "unknown",
|
||||||
contentHeight: 0,
|
contentHeight: 0,
|
||||||
|
viewportWidth: 0,
|
||||||
viewportHeight: 0,
|
viewportHeight: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
viewportMeasuredForKey: null as string | null,
|
||||||
|
contentMeasuredForKey: null as string | null,
|
||||||
});
|
});
|
||||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
|
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||||
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
@@ -165,6 +187,38 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
() => ({ flatListRef, scrollViewRef, bottomAnchorRef }),
|
() => ({ 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)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||||
@@ -194,12 +248,52 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
: FadeOut.duration(200);
|
: FadeOut.duration(200);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasScrolledInitially.current = false;
|
|
||||||
hasAutoScrolledOnce.current = false;
|
|
||||||
isNearBottomRef.current = true;
|
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());
|
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(
|
const handleInlinePathPress = useCallback(
|
||||||
(target: InlinePathTarget) => {
|
(target: InlinePathTarget) => {
|
||||||
@@ -252,23 +346,112 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
setIsNearBottom(value);
|
setIsNearBottom(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestAnchorToBottom = useCallback(() => {
|
const flatListData = useMemo(() => {
|
||||||
pendingAnchorRequestRef.current = true;
|
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(
|
const handleScroll = useCallback(
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||||
const previousOffsetY = scrollOffsetYRef.current;
|
const previousOffsetY = scrollOffsetYRef.current;
|
||||||
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
|
||||||
scrollOffsetYRef.current = contentOffset.y;
|
scrollOffsetYRef.current = contentOffset.y;
|
||||||
streamViewportMetricsRef.current = {
|
streamViewportMetricsRef.current = {
|
||||||
contentHeight: Math.max(0, contentSize.height),
|
contentHeight: Math.max(0, contentSize.height),
|
||||||
|
viewportWidth: Math.max(0, layoutMeasurement.width),
|
||||||
viewportHeight: Math.max(0, layoutMeasurement.height),
|
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 threshold = Math.max(insets.bottom, 32);
|
||||||
const nearBottom = isNearBottomForStreamRenderStrategy({
|
const nearBottom = isNearBottomForStreamRenderStrategy({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
@@ -277,23 +460,24 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
contentHeight: streamViewportMetricsRef.current.contentHeight,
|
||||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||||
});
|
});
|
||||||
|
updateNearBottom(nearBottom);
|
||||||
|
const expectedBottomOffset = streamRenderStrategy.getBottomOffset(
|
||||||
|
streamViewportMetricsRef.current
|
||||||
|
);
|
||||||
|
const isProgrammaticBottomEvent =
|
||||||
|
Math.abs(contentOffset.y - expectedBottomOffset) <= 8;
|
||||||
|
|
||||||
const pendingAnchorBefore = pendingAnchorRequestRef.current;
|
if (
|
||||||
const shouldSuppressFalseNearBottom =
|
programmaticScrollEventBudgetRef.current > 0 &&
|
||||||
pendingAnchorBefore &&
|
isProgrammaticBottomEvent
|
||||||
!nearBottom &&
|
) {
|
||||||
Math.abs(offsetDelta) <= 1 &&
|
programmaticScrollEventBudgetRef.current -= 1;
|
||||||
contentHeightDelta > 0;
|
|
||||||
if (shouldSuppressFalseNearBottom) {
|
|
||||||
updateNearBottom(true);
|
|
||||||
} else {
|
} else {
|
||||||
updateNearBottom(nearBottom);
|
programmaticScrollEventBudgetRef.current = 0;
|
||||||
}
|
bottomAnchorController.handleScrollNearBottomChange({
|
||||||
|
nextIsNearBottom: nearBottom,
|
||||||
const shouldClearPendingAnchor =
|
scrollDelta: contentOffset.y - previousOffsetY,
|
||||||
pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1;
|
});
|
||||||
if (shouldClearPendingAnchor) {
|
|
||||||
pendingAnchorRequestRef.current = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDesktopWebScrollbar) {
|
if (showDesktopWebScrollbar) {
|
||||||
@@ -305,162 +489,151 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
showDesktopWebScrollbar,
|
showDesktopWebScrollbar,
|
||||||
streamRenderStrategy,
|
streamRenderStrategy,
|
||||||
streamScrollbarMetrics,
|
streamScrollbarMetrics,
|
||||||
|
currentContainerKey,
|
||||||
|
bottomAnchorController,
|
||||||
updateNearBottom,
|
updateNearBottom,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleListLayout = useCallback(
|
const handleListLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(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 = {
|
||||||
...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) {
|
if (showDesktopWebScrollbar) {
|
||||||
streamScrollbarMetrics.onLayout(event);
|
streamScrollbarMetrics.onLayout(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showDesktopWebScrollbar, streamScrollbarMetrics]
|
[
|
||||||
);
|
showDesktopWebScrollbar,
|
||||||
|
streamScrollbarMetrics,
|
||||||
const scrollToBottomInternal = useCallback(
|
currentContainerKey,
|
||||||
({ animated }: { animated: boolean }) => {
|
markNativeViewportSettling,
|
||||||
const targetOffset = streamRenderStrategy.getBottomOffset(
|
bottomAnchorController,
|
||||||
streamViewportMetricsRef.current
|
]
|
||||||
);
|
|
||||||
streamRenderStrategy.scrollToBottom({
|
|
||||||
refs: streamRenderRefs,
|
|
||||||
metrics: streamViewportMetricsRef.current,
|
|
||||||
animated,
|
|
||||||
});
|
|
||||||
scrollOffsetYRef.current = targetOffset;
|
|
||||||
updateNearBottom(true);
|
|
||||||
},
|
|
||||||
[updateNearBottom, streamRenderRefs, streamRenderStrategy]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
scrollToBottom() {
|
scrollToBottom(reason = "jump-to-bottom") {
|
||||||
requestAnchorToBottom();
|
bottomAnchorController.requestLocalAnchor({ agentId, reason });
|
||||||
},
|
},
|
||||||
}), [requestAnchorToBottom]);
|
prepareForViewportChange() {
|
||||||
|
bottomAnchorController.prepareForStickyViewportChange();
|
||||||
|
},
|
||||||
|
}), [agentId, bottomAnchorController]);
|
||||||
|
|
||||||
const handleContentSizeChange = useCallback(
|
const handleContentSizeChange = useCallback(
|
||||||
(width: number, height: number) => {
|
(width: number, height: number) => {
|
||||||
const previousMetrics = streamViewportMetricsRef.current;
|
const previousContentHeight = streamViewportMetricsRef.current.contentHeight;
|
||||||
const threshold = Math.max(insets.bottom, 32);
|
const nextContentHeight = Math.max(0, height);
|
||||||
const wasNearBottom = isNearBottomForStreamRenderStrategy({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
offsetY: scrollOffsetYRef.current,
|
|
||||||
threshold,
|
|
||||||
contentHeight: previousMetrics.contentHeight,
|
|
||||||
viewportHeight: previousMetrics.viewportHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
streamViewportMetricsRef.current = {
|
streamViewportMetricsRef.current = {
|
||||||
...previousMetrics,
|
...streamViewportMetricsRef.current,
|
||||||
contentHeight: Math.max(0, height),
|
containerKey: currentContainerKey,
|
||||||
|
contentHeight: nextContentHeight,
|
||||||
|
contentMeasuredForKey: currentContainerKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
bottomAnchorController.handleContentSizeChange({
|
||||||
if (!hasAutoScrolledOnce.current) {
|
previousContentHeight,
|
||||||
scrollToBottomInternal({ animated: false });
|
contentHeight: nextContentHeight,
|
||||||
hasAutoScrolledOnce.current = true;
|
});
|
||||||
hasScrolledInitially.current = true;
|
|
||||||
} else if (
|
|
||||||
wasNearBottom ||
|
|
||||||
isNearBottomRef.current ||
|
|
||||||
pendingAnchorRequestRef.current
|
|
||||||
) {
|
|
||||||
scrollToBottomInternal({ animated: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDesktopWebScrollbar) {
|
if (showDesktopWebScrollbar) {
|
||||||
streamScrollbarMetrics.onContentSizeChange(width, height);
|
streamScrollbarMetrics.onContentSizeChange(width, height);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
insets.bottom,
|
|
||||||
scrollToBottomInternal,
|
|
||||||
showDesktopWebScrollbar,
|
showDesktopWebScrollbar,
|
||||||
streamRenderStrategy,
|
|
||||||
streamScrollbarMetrics,
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (
|
||||||
if (pendingAutoScrollFrameRef.current !== null) {
|
Platform.OS !== "web" ||
|
||||||
cancelAnimationFrame(pendingAutoScrollFrameRef.current);
|
typeof ResizeObserver === "undefined"
|
||||||
pendingAutoScrollFrameRef.current = null;
|
) {
|
||||||
}
|
return;
|
||||||
pendingAutoScrollAnimatedRef.current = false;
|
}
|
||||||
|
|
||||||
|
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(() => {
|
applyObservedMetrics();
|
||||||
if (streamItems.length === 0) {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
return;
|
applyObservedMetrics();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(scrollNode);
|
||||||
|
const contentNode = scrollNode.firstElementChild;
|
||||||
|
if (contentNode instanceof HTMLElement) {
|
||||||
|
resizeObserver.observe(contentNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamRenderStrategy.shouldAnchorBottomOnContentSizeChange()) {
|
return () => {
|
||||||
// Forward streams anchor from measurement updates in handleContentSizeChange.
|
resizeObserver.disconnect();
|
||||||
return;
|
};
|
||||||
}
|
}, [bottomAnchorController, currentContainerKey, streamRenderRefs]);
|
||||||
|
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom();
|
bottomAnchorController.requestLocalAnchor({
|
||||||
scrollToBottomInternal({ animated });
|
agentId,
|
||||||
}
|
reason: "jump-to-bottom",
|
||||||
|
|
||||||
const flatListData = useMemo(() => {
|
|
||||||
return orderTailForStreamRenderStrategy({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
streamItems,
|
|
||||||
});
|
});
|
||||||
}, [streamItems, streamRenderStrategy]);
|
}
|
||||||
|
|
||||||
const orderedStreamHead = useMemo(() => {
|
const orderedStreamHead = useMemo(() => {
|
||||||
return orderHeadForStreamRenderStrategy({
|
return orderHeadForStreamRenderStrategy({
|
||||||
@@ -790,7 +963,35 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
|
|
||||||
const showWorkingIndicator = agent.status === "running";
|
const showWorkingIndicator = agent.status === "running";
|
||||||
const showBottomBar = showWorkingIndicator;
|
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 listEdgeSlotComponent = useMemo(() => {
|
||||||
const hasPermissions = pendingPermissionItems.length > 0;
|
const hasPermissions = pendingPermissionItems.length > 0;
|
||||||
@@ -929,22 +1130,59 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
listEdgeSlotProps.ListFooterComponent
|
listEdgeSlotProps.ListFooterComponent
|
||||||
);
|
);
|
||||||
const nonVirtualizedItems = useMemo(() => {
|
const nonVirtualizedItems = useMemo(() => {
|
||||||
if (flatListData.length === 0) {
|
if (indexedFlatListData.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return flatListData.map((item, index) => {
|
return indexedFlatListData.map((entry) => {
|
||||||
const rendered = renderStreamItem({
|
const rendered = renderIndexedStreamItem(entry);
|
||||||
item,
|
|
||||||
index,
|
|
||||||
separators: NOOP_SEPARATORS,
|
|
||||||
});
|
|
||||||
if (!rendered) {
|
if (!rendered) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
@@ -957,6 +1195,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
renderItem={renderStreamItem}
|
renderItem={renderStreamItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
testID="agent-chat-scroll"
|
testID="agent-chat-scroll"
|
||||||
|
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
||||||
{...listEdgeSlotProps}
|
{...listEdgeSlotProps}
|
||||||
contentContainerStyle={listContentContainerStyle}
|
contentContainerStyle={listContentContainerStyle}
|
||||||
style={stylesheet.list}
|
style={stylesheet.list}
|
||||||
@@ -975,10 +1214,34 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
inverted={streamRenderStrategy.getFlatListInverted()}
|
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
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
testID="agent-chat-scroll"
|
testID="agent-chat-scroll"
|
||||||
|
nativeID={`agent-chat-scroll-${currentContainerKey}`}
|
||||||
contentContainerStyle={listContentContainerStyle}
|
contentContainerStyle={listContentContainerStyle}
|
||||||
style={stylesheet.list}
|
style={stylesheet.list}
|
||||||
onLayout={handleListLayout}
|
onLayout={handleListLayout}
|
||||||
@@ -1028,6 +1291,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={stylesheet.scrollToBottomButton}
|
style={stylesheet.scrollToBottomButton}
|
||||||
onPress={scrollToBottom}
|
onPress={scrollToBottom}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Scroll to bottom"
|
||||||
|
testID="scroll-to-bottom-button"
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={24}
|
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. */
|
/** Reports cursor selection updates from the underlying input. */
|
||||||
onSelectionChange?: (selection: { start: number; end: number }) => void
|
onSelectionChange?: (selection: { start: number; end: number }) => void
|
||||||
onFocusChange?: (focused: boolean) => void
|
onFocusChange?: (focused: boolean) => void
|
||||||
|
onHeightChange?: (height: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
@@ -153,6 +154,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
onSelectionChange: onSelectionChangeCallback,
|
onSelectionChange: onSelectionChangeCallback,
|
||||||
onFocusChange,
|
onFocusChange,
|
||||||
|
onHeightChange,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -473,7 +475,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onSubmit(payload)
|
onSubmit(payload)
|
||||||
inputHeightRef.current = MIN_INPUT_HEIGHT
|
inputHeightRef.current = MIN_INPUT_HEIGHT
|
||||||
setInputHeight(MIN_INPUT_HEIGHT)
|
setInputHeight(MIN_INPUT_HEIGHT)
|
||||||
}, [value, images, onSubmit, isAgentRunning])
|
onHeightChange?.(MIN_INPUT_HEIGHT)
|
||||||
|
}, [value, images, onSubmit, isAgentRunning, onHeightChange])
|
||||||
|
|
||||||
const handleQueueMessage = useCallback(() => {
|
const handleQueueMessage = useCallback(() => {
|
||||||
if (!onQueue) return
|
if (!onQueue) return
|
||||||
@@ -487,7 +490,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onChangeText('')
|
onChangeText('')
|
||||||
inputHeightRef.current = MIN_INPUT_HEIGHT
|
inputHeightRef.current = MIN_INPUT_HEIGHT
|
||||||
setInputHeight(MIN_INPUT_HEIGHT)
|
setInputHeight(MIN_INPUT_HEIGHT)
|
||||||
}, [value, images, onQueue, onChangeText])
|
onHeightChange?.(MIN_INPUT_HEIGHT)
|
||||||
|
}, [value, images, onQueue, onChangeText, onHeightChange])
|
||||||
|
|
||||||
// Web input height measurement
|
// Web input height measurement
|
||||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
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) {
|
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
|
||||||
inputHeightRef.current = bounded
|
inputHeightRef.current = bounded
|
||||||
setInputHeight(bounded)
|
setInputHeight(bounded)
|
||||||
|
onHeightChange?.(bounded)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -597,6 +602,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return
|
if (Math.abs(inputHeightRef.current - bounded) < 1) return
|
||||||
inputHeightRef.current = bounded
|
inputHeightRef.current = bounded
|
||||||
setInputHeight(bounded)
|
setInputHeight(bounded)
|
||||||
|
onHeightChange?.(bounded)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContentSizeChange(
|
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(
|
const markAgentHistorySynchronized = useSessionStore(
|
||||||
(state) => state.markAgentHistorySynchronized
|
(state) => state.markAgentHistorySynchronized
|
||||||
);
|
);
|
||||||
|
const setAgentAuthoritativeHistoryApplied = useSessionStore(
|
||||||
|
(state) => state.setAgentAuthoritativeHistoryApplied
|
||||||
|
);
|
||||||
const setHasHydratedAgents = useSessionStore(
|
const setHasHydratedAgents = useSessionStore(
|
||||||
(state) => state.setHasHydratedAgents
|
(state) => state.setHasHydratedAgents
|
||||||
);
|
);
|
||||||
@@ -719,6 +722,7 @@ function SessionProviderInternal({
|
|||||||
next.delete(agentId);
|
next.delete(agentId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setAgentAuthoritativeHistoryApplied(serverId, agentId, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +775,8 @@ function SessionProviderInternal({
|
|||||||
) => {
|
) => {
|
||||||
const agentId = payload.agentId;
|
const agentId = payload.agentId;
|
||||||
const initKey = getInitKey(serverId, agentId);
|
const initKey = getInitKey(serverId, agentId);
|
||||||
|
const shouldMarkAuthoritativeHistoryApplied =
|
||||||
|
payload.direction === "tail" || payload.direction === "after";
|
||||||
|
|
||||||
// Read current store state
|
// Read current store state
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
@@ -890,6 +896,9 @@ function SessionProviderInternal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldMarkAuthoritativeHistoryApplied) {
|
||||||
|
setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||||
|
}
|
||||||
if (result.initResolution === "resolve") {
|
if (result.initResolution === "resolve") {
|
||||||
resolveInitDeferred(initKey);
|
resolveInitDeferred(initKey);
|
||||||
}
|
}
|
||||||
@@ -904,6 +913,7 @@ function SessionProviderInternal({
|
|||||||
markAgentHistorySynchronized,
|
markAgentHistorySynchronized,
|
||||||
requestCanonicalCatchUp,
|
requestCanonicalCatchUp,
|
||||||
serverId,
|
serverId,
|
||||||
|
setAgentAuthoritativeHistoryApplied,
|
||||||
setAgentStreamTail,
|
setAgentStreamTail,
|
||||||
setAgentTimelineCursor,
|
setAgentTimelineCursor,
|
||||||
setInitializingAgents,
|
setInitializingAgents,
|
||||||
|
|||||||
@@ -64,4 +64,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
projection: "canonical",
|
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 cursor = session?.agentTimelineCursor.get(agentId);
|
||||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||||
const hasAuthoritativeHistory =
|
const hasAuthoritativeHistory =
|
||||||
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
|
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||||
const timelineRequest = deriveInitialTimelineRequest({
|
const timelineRequest = deriveInitialTimelineRequest({
|
||||||
cursor: cursor
|
cursor: cursor
|
||||||
? { epoch: cursor.epoch, seq: cursor.endSeq }
|
? { epoch: cursor.epoch, seq: cursor.endSeq }
|
||||||
@@ -95,10 +95,6 @@ export function useAgentInitialization({
|
|||||||
|
|
||||||
client
|
client
|
||||||
.fetchAgentTimeline(agentId, timelineRequest)
|
.fetchAgentTimeline(agentId, timelineRequest)
|
||||||
.then(() => {
|
|
||||||
// No-op: hydration completion is handled by SessionContext
|
|
||||||
// when it processes fetch_agent_timeline_response.
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setAgentInitializing(agentId, false);
|
setAgentInitializing(agentId, false);
|
||||||
rejectInitDeferred(
|
rejectInitDeferred(
|
||||||
|
|||||||
@@ -342,6 +342,46 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
expect(ready.sync.status).toBe("idle");
|
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", () => {
|
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
|
||||||
const initialMemory = createBaseMemory();
|
const initialMemory = createBaseMemory();
|
||||||
const optimisticInput: AgentScreenMachineInput = {
|
const optimisticInput: AgentScreenMachineInput = {
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export interface AgentScreenMachineInput {
|
|||||||
hasHydratedHistoryBefore: boolean;
|
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 type AgentScreenToastLatch = "none" | "history_refresh" | "sync_error";
|
||||||
|
|
||||||
export interface AgentScreenMachineMemory {
|
export interface AgentScreenMachineMemory {
|
||||||
@@ -104,10 +114,7 @@ export function deriveAgentScreenViewState({
|
|||||||
input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent
|
input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent
|
||||||
? { ...input.agent, status: input.placeholderAgent.status }
|
? { ...input.agent, status: input.placeholderAgent.status }
|
||||||
: input.agent ?? input.placeholderAgent;
|
: input.agent ?? input.placeholderAgent;
|
||||||
if (candidateAgent) {
|
const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input);
|
||||||
nextMemory.hasRenderedReady = true;
|
|
||||||
nextMemory.lastReadyAgent = candidateAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.missingAgentState.kind === "not_found") {
|
if (input.missingAgentState.kind === "not_found") {
|
||||||
return {
|
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 =
|
const displayAgent =
|
||||||
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
|
candidateAgent ?? (nextMemory.hasRenderedReady ? nextMemory.lastReadyAgent : null);
|
||||||
if (!displayAgent) {
|
if (!displayAgent) {
|
||||||
|
|||||||
@@ -978,6 +978,68 @@ describe("HostRuntimeStore", () => {
|
|||||||
useSessionStore.getState().clearSession(host.serverId);
|
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 () => {
|
it("surfaces startup failures as error instead of leaving host idle", async () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
connections: [
|
connections: [
|
||||||
|
|||||||
@@ -1043,16 +1043,19 @@ export class HostRuntimeStore {
|
|||||||
const snapshot = controller.getSnapshot();
|
const snapshot = controller.getSnapshot();
|
||||||
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
||||||
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
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);
|
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
||||||
// session initialization races.
|
// session initialization races.
|
||||||
if (
|
if (snapshot.connectionStatus !== "online") {
|
||||||
snapshot.connectionStatus !== "online" ||
|
this.clearAgentDirectorySessionRetry(serverId);
|
||||||
snapshot.hasEverLoadedAgentDirectory
|
return;
|
||||||
) {
|
}
|
||||||
|
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
|
||||||
this.clearAgentDirectorySessionRetry(serverId);
|
this.clearAgentDirectorySessionRetry(serverId);
|
||||||
return;
|
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 type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
|
import {
|
||||||
|
deriveRouteBottomAnchorIntent,
|
||||||
|
deriveRouteBottomAnchorRequest,
|
||||||
|
} from "./agent-ready-screen-bottom-anchor";
|
||||||
|
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||||
@@ -390,12 +394,19 @@ function AgentScreenContent({
|
|||||||
const historySyncGeneration = useSessionStore(
|
const historySyncGeneration = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
|
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0
|
||||||
);
|
);
|
||||||
|
const hasAppliedAuthoritativeHistory = useSessionStore((state) =>
|
||||||
|
resolvedAgentId
|
||||||
|
? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(
|
||||||
|
resolvedAgentId
|
||||||
|
) === true
|
||||||
|
: false
|
||||||
|
);
|
||||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||||
resolvedAgentId
|
resolvedAgentId
|
||||||
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
|
? state.sessions[serverId]?.agentHistorySyncGeneration?.get(resolvedAgentId) ?? -1
|
||||||
: -1
|
: -1
|
||||||
);
|
);
|
||||||
const hasHydratedHistoryBefore = agentHistorySyncGeneration >= 0;
|
const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory;
|
||||||
|
|
||||||
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
|
||||||
const allPendingPermissions = useSessionStore(
|
const allPendingPermissions = useSessionStore(
|
||||||
@@ -432,6 +443,10 @@ function AgentScreenContent({
|
|||||||
});
|
});
|
||||||
const reconnectToastArmedRef = useRef(false);
|
const reconnectToastArmedRef = useRef(false);
|
||||||
const initAttemptTokenRef = useRef(0);
|
const initAttemptTokenRef = useRef(0);
|
||||||
|
const routeBottomAnchorRequestRef = useRef<{
|
||||||
|
routeKey: string;
|
||||||
|
reason: "initial-entry" | "resume";
|
||||||
|
} | null>(null);
|
||||||
const attentionClientRef = useRef(client);
|
const attentionClientRef = useRef(client);
|
||||||
const attentionConnectedRef = useRef(isConnected);
|
const attentionConnectedRef = useRef(isConnected);
|
||||||
const setFocusedAgentId = useCallback(
|
const setFocusedAgentId = useCallback(
|
||||||
@@ -658,6 +673,20 @@ function AgentScreenContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const effectiveAgent = viewState.tag === "ready" ? viewState.agent : null;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isPendingCreateForRoute || !pendingCreate) {
|
if (!isPendingCreateForRoute || !pendingCreate) {
|
||||||
return;
|
return;
|
||||||
@@ -914,6 +943,8 @@ function AgentScreenContent({
|
|||||||
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
shouldUseOptimisticStream ? mergedStreamItems : streamItems
|
||||||
}
|
}
|
||||||
pendingPermissions={pendingPermissions}
|
pendingPermissions={pendingPermissions}
|
||||||
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
|
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||||
/>
|
/>
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
@@ -926,7 +957,12 @@ function AgentScreenContent({
|
|||||||
autoFocus
|
autoFocus
|
||||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
onAddImages={handleAddImagesCallback}
|
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 labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
||||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
||||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
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 contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||||
const isFirstTab = index === 0;
|
const isFirstTab = index === 0;
|
||||||
@@ -174,107 +178,114 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={tab.key}>
|
<ContextMenu key={tab.key}>
|
||||||
<ContextMenuTrigger
|
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
|
||||||
testID={`workspace-tab-${tab.key}`}
|
<TooltipTrigger
|
||||||
enabledOnMobile={false}
|
testID={`workspace-tab-tooltip-${tab.key}`}
|
||||||
style={({ hovered, pressed }) => [
|
accessibilityRole="none"
|
||||||
styles.tab,
|
style={styles.tabTooltipTrigger}
|
||||||
{
|
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<View style={styles.tabIcon}>
|
<ContextMenuTrigger
|
||||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
testID={`workspace-tab-${tab.key}`}
|
||||||
</View>
|
enabledOnMobile={false}
|
||||||
{showLabel ? (
|
style={({ hovered, pressed }) => [
|
||||||
presentation.titleState === "loading" ? (
|
styles.tab,
|
||||||
<View
|
{
|
||||||
style={[
|
minWidth: resolvedTabWidth,
|
||||||
styles.tabLabelSkeleton,
|
width: resolvedTabWidth,
|
||||||
shouldShowCloseButton && styles.tabLabelSkeletonWithCloseButton,
|
maxWidth: resolvedTabWidth,
|
||||||
]}
|
},
|
||||||
/>
|
isActive && styles.tabActive,
|
||||||
) : (
|
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||||
<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={() => {
|
onHoverIn={() => {
|
||||||
setHoveredTabKey(tab.key);
|
setHoveredTabKey(tab.key);
|
||||||
setHoveredCloseTabKey(tab.key);
|
|
||||||
}}
|
}}
|
||||||
onHoverOut={() => {
|
onHoverOut={() => {
|
||||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
|
||||||
}}
|
}}
|
||||||
onPress={(event) => {
|
onPressIn={() => {
|
||||||
event.stopPropagation?.();
|
onNavigateTab(tab.tabId);
|
||||||
void onCloseTab(tab.tabId);
|
|
||||||
}}
|
}}
|
||||||
style={({ hovered, pressed }) => [
|
onPress={() => {
|
||||||
styles.tabCloseButton,
|
onNavigateTab(tab.tabId);
|
||||||
styles.tabCloseButtonShown,
|
}}
|
||||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
accessibilityLabel={tooltipLabel}
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{isClosingTab ? (
|
<View
|
||||||
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
{...(dragHandleProps?.attributes as any)}
|
||||||
) : (
|
{...(dragHandleProps?.listeners as any)}
|
||||||
<X size={12} color={theme.colors.foregroundMuted} />
|
ref={dragHandleProps?.setActivatorNodeRef}
|
||||||
)}
|
style={styles.tabHandle}
|
||||||
</Pressable>
|
>
|
||||||
) : null}
|
<View style={styles.tabIcon}>
|
||||||
</ContextMenuTrigger>
|
<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}>
|
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||||
{tab.kind === "agent" ? (
|
{tab.kind === "agent" ? (
|
||||||
@@ -426,6 +437,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
tabTooltipTrigger: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
tab: {
|
tab: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
MoreVertical,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Plus,
|
Plus,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
@@ -24,6 +26,12 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
|||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -1006,6 +1014,20 @@ function WorkspaceScreenContent({
|
|||||||
[sessionAgents, toast]
|
[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(
|
const handleBulkCloseTabs = useCallback(
|
||||||
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
async (input: { tabsToClose: WorkspaceTabDescriptor[]; title: string; logLabel: string }) => {
|
||||||
const { tabsToClose, title, logLabel } = input;
|
const { tabsToClose, title, logLabel } = input;
|
||||||
@@ -1252,6 +1274,7 @@ function WorkspaceScreenContent({
|
|||||||
if (target.kind === "agent") {
|
if (target.kind === "agent") {
|
||||||
return (
|
return (
|
||||||
<AgentReadyScreen
|
<AgentReadyScreen
|
||||||
|
key={`${normalizedServerId}:${normalizedWorkspaceId}:${target.agentId}`}
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
agentId={target.agentId}
|
agentId={target.agentId}
|
||||||
showExplorerSidebar={false}
|
showExplorerSidebar={false}
|
||||||
@@ -1331,13 +1354,45 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<View style={styles.headerRight}>
|
<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
|
<HeaderToggleButton
|
||||||
testID="workspace-explorer-toggle"
|
testID="workspace-explorer-toggle"
|
||||||
onPress={handleToggleExplorer}
|
onPress={handleToggleExplorer}
|
||||||
tooltipLabel="Toggle explorer"
|
tooltipLabel="Toggle explorer"
|
||||||
tooltipKeys={["mod", "E"]}
|
tooltipKeys={["mod", "E"]}
|
||||||
tooltipSide="left"
|
tooltipSide="left"
|
||||||
style={styles.menuButton}
|
style={styles.headerActionButton}
|
||||||
accessible
|
accessible
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
|
||||||
@@ -1599,10 +1654,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
headerRight: {
|
headerRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
menuButton: {
|
headerActionButton: {
|
||||||
padding: theme.spacing[3],
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
newTabActions: {
|
newTabActions: {
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export interface SessionState {
|
|||||||
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
||||||
historySyncGeneration: number;
|
historySyncGeneration: number;
|
||||||
agentHistorySyncGeneration: Map<string, number>;
|
agentHistorySyncGeneration: Map<string, number>;
|
||||||
|
agentAuthoritativeHistoryApplied: Map<string, boolean>;
|
||||||
|
|
||||||
// Initializing agents (used for UI loading state)
|
// Initializing agents (used for UI loading state)
|
||||||
initializingAgents: Map<string, boolean>;
|
initializingAgents: Map<string, boolean>;
|
||||||
@@ -295,6 +296,11 @@ interface SessionStoreActions {
|
|||||||
) => void;
|
) => void;
|
||||||
bumpHistorySyncGeneration: (serverId: string) => void;
|
bumpHistorySyncGeneration: (serverId: string) => void;
|
||||||
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
||||||
|
setAgentAuthoritativeHistoryApplied: (
|
||||||
|
serverId: string,
|
||||||
|
agentId: string,
|
||||||
|
applied: boolean
|
||||||
|
) => void;
|
||||||
|
|
||||||
// Initializing agents
|
// Initializing agents
|
||||||
setInitializingAgents: (serverId: string, state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>)) => void;
|
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(),
|
agentTimelineCursor: new Map(),
|
||||||
historySyncGeneration: 0,
|
historySyncGeneration: 0,
|
||||||
agentHistorySyncGeneration: new Map(),
|
agentHistorySyncGeneration: new Map(),
|
||||||
|
agentAuthoritativeHistoryApplied: new Map(),
|
||||||
initializingAgents: new Map(),
|
initializingAgents: new Map(),
|
||||||
agents: new Map(),
|
agents: new Map(),
|
||||||
workspaces: 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
|
// Initializing agents
|
||||||
setInitializingAgents: (serverId, state) => {
|
setInitializingAgents: (serverId, state) => {
|
||||||
set((prev) => {
|
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 { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.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> = {
|
type Deferred<T> = {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
@@ -297,7 +300,7 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
|||||||
expect(sdkMocks.firstQuery).toBeTruthy();
|
expect(sdkMocks.firstQuery).toBeTruthy();
|
||||||
expect(sdkMocks.secondQuery).toBeTruthy();
|
expect(sdkMocks.secondQuery).toBeTruthy();
|
||||||
expect(sdkMocks.firstQuery).not.toBe(sdkMocks.secondQuery);
|
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(sdkMocks.secondQuery?.next).toHaveBeenCalled();
|
||||||
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
|
expect(secondAssistantText).toContain("NEW_TURN_RESPONSE");
|
||||||
expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE");
|
expect(secondAssistantText).not.toContain("OLD_TURN_RESPONSE");
|
||||||
@@ -979,6 +982,146 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
|||||||
await session.close();
|
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 () => {
|
test("does not emit live autonomous turn events for local_agent task_started during a foreground run", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const keepQueryAlive = deferred<void>();
|
const keepQueryAlive = deferred<void>();
|
||||||
@@ -1150,6 +1293,216 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
|
|||||||
await session.close();
|
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 () => {
|
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
let queryCreateCount = 0;
|
let queryCreateCount = 0;
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ import {
|
|||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.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 fsPromises = promises;
|
||||||
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
|
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
|
||||||
"user",
|
"user",
|
||||||
@@ -102,8 +120,9 @@ type RoutingReason =
|
|||||||
| "parent_message_id"
|
| "parent_message_id"
|
||||||
| "message_id"
|
| "message_id"
|
||||||
| "unbound_autonomous"
|
| "unbound_autonomous"
|
||||||
| "fallback"
|
| "reserved_autonomous"
|
||||||
| "metadata";
|
| "metadata"
|
||||||
|
| "ignored_unmatched";
|
||||||
|
|
||||||
type EventIdentifiers = {
|
type EventIdentifiers = {
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
@@ -114,6 +133,7 @@ type EventIdentifiers = {
|
|||||||
type RunRoute = {
|
type RunRoute = {
|
||||||
run: RunRecord | null;
|
run: RunRecord | null;
|
||||||
reason: RoutingReason;
|
reason: RoutingReason;
|
||||||
|
dispatchWithoutRun?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunRecord = {
|
type RunRecord = {
|
||||||
@@ -2583,8 +2603,10 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
): RunRoute {
|
): RunRoute {
|
||||||
if (normalized.metadataOnly) {
|
if (normalized.metadataOnly) {
|
||||||
if (
|
if (
|
||||||
normalized.message.type === "user" &&
|
(normalized.message.type === "user" &&
|
||||||
isTaskNotificationUserContent(normalized.message.message?.content)
|
isTaskNotificationUserContent(normalized.message.message?.content)) ||
|
||||||
|
(normalized.message.type === "system" &&
|
||||||
|
normalized.message.subtype === "task_notification")
|
||||||
) {
|
) {
|
||||||
this.reserveAutonomousWake("task_notification");
|
this.reserveAutonomousWake("task_notification");
|
||||||
}
|
}
|
||||||
@@ -2603,13 +2625,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return byIdentifiers;
|
return byIdentifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.turnState === "autonomous") {
|
|
||||||
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
|
|
||||||
if (activeAutonomousRun) {
|
|
||||||
return { run: activeAutonomousRun, reason: "unbound_autonomous" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const foregroundRun = this.activeForegroundTurn
|
const foregroundRun = this.activeForegroundTurn
|
||||||
? this.runTracker.getRun(this.activeForegroundTurn.runId)
|
? this.runTracker.getRun(this.activeForegroundTurn.runId)
|
||||||
: null;
|
: null;
|
||||||
@@ -2648,32 +2663,62 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return { run: foregroundRun, reason: "foreground" };
|
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 (!hasIdentifiers) {
|
||||||
if (this.pendingAutonomousWakeReservations > 0) {
|
|
||||||
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
|
|
||||||
"reservation_unbound"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
run: reservedAutonomousRun,
|
|
||||||
reason: "unbound_autonomous",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
|
const activeAutonomousRun = this.runTracker.getLatestActiveRun("autonomous");
|
||||||
if (activeAutonomousRun) {
|
if (activeAutonomousRun) {
|
||||||
return { run: activeAutonomousRun, reason: "unbound_autonomous" };
|
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) {
|
if (this.pendingAutonomousWakeReservations > 0) {
|
||||||
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
|
const reservedAutonomousRun = this.claimOrCreateAutonomousRun(
|
||||||
"reservation_fallback"
|
"reservation_fallback"
|
||||||
);
|
);
|
||||||
return { run: reservedAutonomousRun, reason: "fallback" };
|
return { run: reservedAutonomousRun, reason: "reserved_autonomous" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const autonomousRun = this.createRun("autonomous", null);
|
this.logger.debug(
|
||||||
this.emitRunEvent(autonomousRun, { type: "turn_started", provider: "claude" });
|
{
|
||||||
return { run: autonomousRun, reason: "fallback" };
|
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: {
|
private shouldPreferForegroundRun(input: {
|
||||||
@@ -2746,10 +2791,14 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (!foregroundRun || foregroundRun.promptReplaySeen) {
|
if (!foregroundRun || foregroundRun.promptReplaySeen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// System metadata (init/hook callbacks/etc.) can precede the first prompt
|
// Most system metadata (init/hook callbacks/etc.) can precede the first prompt
|
||||||
// replay for a legitimate foreground run. Treating that as churn strands
|
// replay for a legitimate foreground run. Treating all of it as churn strands
|
||||||
// one-shot helper runs in autonomous fallback.
|
// one-shot helper runs. task_notification is the exception: it represents
|
||||||
if (message.type === "system") {
|
// background agent activity and should suppress pre-replay foreground routing.
|
||||||
|
if (
|
||||||
|
message.type === "system" &&
|
||||||
|
message.subtype !== "task_notification"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.preReplayMetadataSeen = true;
|
this.preReplayMetadataSeen = true;
|
||||||
@@ -2886,6 +2935,10 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await this.handleMissingResumedConversation(sdkMessage, q)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.routeSdkMessageFromPump(sdkMessage);
|
this.routeSdkMessageFromPump(sdkMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2954,6 +3007,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!route.run) {
|
if (!route.run) {
|
||||||
|
if (route.dispatchWithoutRun === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.dispatchMetadataEvents(events);
|
this.dispatchMetadataEvents(events);
|
||||||
return;
|
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: {
|
private shouldSuppressReplayResultTerminal(input: {
|
||||||
run: RunRecord | null;
|
run: RunRecord | null;
|
||||||
message: SDKMessage;
|
message: SDKMessage;
|
||||||
@@ -3691,6 +3786,30 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return threadStartedSessionId;
|
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 {
|
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
|
||||||
if (!message.usage) {
|
if (!message.usage) {
|
||||||
return undefined;
|
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