From fa20f313d3eb4047a97efb940da4b74fd8b1181d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 3 May 2026 13:50:20 +0700 Subject: [PATCH] Simplify workspace reconnect gating --- .../app/e2e/terminal-keystroke-stress.spec.ts | 44 ++ .../workspace-navigation-regression.spec.ts | 197 ++++++- .../workspace/workspace-route-state-views.tsx | 247 +++++++++ .../workspace/workspace-route-state.test.ts | 109 ++++ .../workspace/workspace-route-state.ts | 55 ++ .../screens/workspace/workspace-screen.tsx | 490 ++++++++++++------ .../navigation-active-workspace-store.ts | 1 + .../providers/mock-load-test-agent.test.ts | 118 +++-- .../agent/providers/mock-load-test-agent.ts | 382 +++++++++----- 9 files changed, 1310 insertions(+), 333 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-route-state-views.tsx create mode 100644 packages/app/src/screens/workspace/workspace-route-state.test.ts create mode 100644 packages/app/src/screens/workspace/workspace-route-state.ts diff --git a/packages/app/e2e/terminal-keystroke-stress.spec.ts b/packages/app/e2e/terminal-keystroke-stress.spec.ts index da7ae0191..8e14a9ee8 100644 --- a/packages/app/e2e/terminal-keystroke-stress.spec.ts +++ b/packages/app/e2e/terminal-keystroke-stress.spec.ts @@ -68,6 +68,22 @@ terminalPerfDescribe("Terminal keystroke stress", () => { expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length); expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0); + const appObserveNodeBurstReport = await measureAppObservingNodeBurstEcho({ + page, + harness, + terminalName: "app-observe-node-burst", + }); + await attachJson(testInfo, "app-observe-node-burst", appObserveNodeBurstReport); + console.log( + "[terminal-stress-app-observe-node-burst]", + JSON.stringify(appObserveNodeBurstReport), + ); + + expect(appObserveNodeBurstReport.outputFramePayloadBytes).toBeGreaterThanOrEqual( + INPUT_TEXT.length, + ); + expect(appObserveNodeBurstReport.xtermWriteCount).toBeGreaterThan(0); + const appSmallChunksReport = await measureAppBurstEcho({ page, harness, @@ -172,6 +188,34 @@ async function measureAppBurstEcho(input: { } } +async function measureAppObservingNodeBurstEcho(input: { + page: Page; + harness: TerminalE2EHarness; + terminalName: string; +}) { + const appTerminal = await input.harness.createTerminal({ name: input.terminalName }); + try { + await input.harness.openTerminal(input.page, { terminalId: appTerminal.id }); + await input.harness.setupPrompt(input.page); + + await resetTerminalKeystrokeStressProbe(input.page); + + for (const char of INPUT_TEXT) { + input.harness.client.sendTerminalInput(appTerminal.id, { + type: "input", + data: char, + }); + } + + await waitForAppStressEcho(input.page, INPUT_TEXT); + await waitForAppProbePayload(input.page, INPUT_TEXT.length); + + return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT); + } finally { + await input.harness.killTerminal(appTerminal.id); + } +} + async function emitRapidAgentStreamUpdates( harness: TerminalE2EHarness, input: { agentId: string; count: number }, diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 33de88b32..55a4eaf0c 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -1,5 +1,6 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test } from "./fixtures"; +import type { WebSocketRoute } from "@playwright/test"; +import { expect, test, type Page } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { archiveAgentFromDaemon, @@ -27,9 +28,203 @@ import { waitForSidebarHydration, } from "./helpers/workspace-ui"; +const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function expectNoLoadingWorkspacePane( + page: Page, + input: { label: string; durationMs?: number }, +): Promise { + const durationMs = input.durationMs ?? 2000; + const startedAt = Date.now(); + const samples: string[] = []; + + while (Date.now() - startedAt < durationMs) { + const url = page.url(); + const text = await page + .locator("body") + .innerText({ timeout: 250 }) + .catch((error) => `[body unavailable: ${error instanceof Error ? error.message : error}]`); + samples.push(`${Date.now() - startedAt}ms ${url}\n${text.slice(0, 1000)}`); + + if (LOADING_WORKSPACE_TEXT_PATTERN.test(text)) { + throw new Error( + `${input.label}: loading workspace pane appeared during reconnect window.\n\n${samples.join( + "\n\n---\n\n", + )}`, + ); + } + + await page.waitForTimeout(100); + } +} + +async function installDaemonWebSocketGate(page: Page, daemonPort: string) { + let acceptingConnections = true; + const activeSockets = new Set(); + + await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => { + if (!acceptingConnections) { + void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." }); + return; + } + + activeSockets.add(ws); + const server = ws.connectToServer(); + + ws.onMessage((message) => { + if (!acceptingConnections) { + return; + } + try { + server.send(message); + } catch { + activeSockets.delete(ws); + } + }); + + server.onMessage((message) => { + if (!acceptingConnections) { + return; + } + try { + ws.send(message); + } catch { + activeSockets.delete(ws); + } + }); + }); + + return { + async drop(): Promise { + acceptingConnections = false; + const sockets = Array.from(activeSockets); + activeSockets.clear(); + await Promise.all( + sockets.map((ws) => + ws + .close({ code: 1008, reason: "Dropped by workspace reconnect regression test." }) + .catch(() => undefined), + ), + ); + }, + restore(): void { + acceptingConnections = true; + }, + }; +} + test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); + test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => { + const serverId = process.env.E2E_SERVER_ID; + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + + const daemonGate = await installDaemonWebSocketGate(page, daemonPort); + + const workspaceClient = await connectNewWorkspaceDaemonClient(); + const workspaceIds = new Set(); + const repo = await createTempGitRepo("workspace-reconnect-"); + + try { + const workspace = await openProjectViaDaemon(workspaceClient, repo.path); + workspaceIds.add(workspace.workspaceId); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: workspace.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: workspace.workspaceName, + subtitle: workspace.projectDisplayName, + }); + await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({ + timeout: 30_000, + }); + + await daemonGate.drop(); + await expect(page.getByTestId("workspace-reconnect-banner")).toBeVisible({ + timeout: 30_000, + }); + await expectWorkspaceHeader(page, { + title: workspace.workspaceName, + subtitle: workspace.projectDisplayName, + }); + await expect(page.getByTestId("workspace-tabs-row")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0); + + const monitorReconnect = expectNoLoadingWorkspacePane(page, { + label: "host reconnect", + }); + daemonGate.restore(); + await expect(page.getByTestId("workspace-reconnect-banner")).toHaveCount(0, { + timeout: 30_000, + }); + await monitorReconnect; + await expectWorkspaceHeader(page, { + title: workspace.workspaceName, + subtitle: workspace.projectDisplayName, + }); + await expect(page.getByTestId("workspace-tabs-row")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + } finally { + daemonGate.restore(); + for (const workspaceId of workspaceIds) { + await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined); + } + await workspaceClient.close().catch(() => undefined); + await repo.cleanup(); + } + }); + + test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({ + page, + }) => { + const serverId = process.env.E2E_SERVER_ID; + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + + await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => { + await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." }); + }); + + await page.goto( + `/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`, + ); + + await expect( + page.getByText(/Connecting to localhost|localhost is offline|Cannot reach localhost/i), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("menu-button")).toBeVisible(); + await expect(page.getByTestId("workspace-header-title")).toHaveCount(0); + await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0); + + const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first(); + await expect(settingsButton).toBeVisible({ timeout: 30_000 }); + await settingsButton.click(); + await expect(page).toHaveURL(/\/settings\/general$/); + }); + test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ page, }) => { diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx new file mode 100644 index 000000000..dbb27c0c0 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -0,0 +1,247 @@ +import { Text, View } from "react-native"; +import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { formatConnectionStatus } from "@/utils/daemons"; +import type { WorkspaceRouteState } from "@/screens/workspace/workspace-route-state"; + +interface WorkspaceRouteStateActions { + onRetryHost: () => void; + onManageHost: () => void; + onDismissMissingWorkspace: () => void; +} + +export function renderWorkspaceRouteGate(input: { + state: WorkspaceRouteState; + actions: WorkspaceRouteStateActions; +}): React.ReactNode { + switch (input.state.kind) { + case "loading": + return ; + case "unreachable": + return ( + + ); + case "missing": + return ( + + ); + case "ready": + case "reconnecting": + return null; + } +} + +export function renderWorkspaceReconnectIndicator(input: { + state: WorkspaceRouteState; + onRetryHost: () => void; +}): React.ReactNode { + if (input.state.kind !== "reconnecting") { + return null; + } + return ; +} + +function getWorkspaceHostStateTitle( + state: Extract, +): string { + if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") { + return `Connecting to ${state.hostName}`; + } + if (state.connectionStatus === "offline") { + return `${state.hostName} is offline`; + } + return `Cannot reach ${state.hostName}`; +} + +function getReconnectBannerCopy( + state: Extract, +): string { + if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") { + return `Reconnecting to ${state.hostName}...`; + } + return `${state.hostName} is offline`; +} + +function WorkspaceConnecting({ hostName }: { hostName: string }) { + const { theme } = useUnistyles(); + + return ( + + + + Loading workspace + {hostName} + + + ); +} + +function WorkspaceUnreachable({ + state, + onRetry, + onManageHost, +}: { + state: Extract; + onRetry: () => void; + onManageHost: () => void; +}) { + const { theme } = useUnistyles(); + const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error"; + + return ( + + {state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? ( + + ) : null} + + {getWorkspaceHostStateTitle(state)} + + Host status: {formatConnectionStatus(state.connectionStatus)} + + {state.lastError ? ( + + + + {state.lastError} + + + + {state.lastError} + + + ) : null} + + {canRetry ? ( + + + + + ) : null} + + ); +} + +function WorkspaceReconnectBanner({ + state, + onRetry, +}: { + state: Extract; + onRetry: () => void; +}) { + const { theme } = useUnistyles(); + const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error"; + const showSpinner = state.connectionStatus === "connecting" || state.connectionStatus === "idle"; + const isErrorState = state.connectionStatus === "offline" || state.connectionStatus === "error"; + + return ( + + {showSpinner ? : null} + + {getReconnectBannerCopy(state)} + + {canRetry ? ( + + ) : null} + + ); +} + +function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) { + return ( + + + Workspace not found + {hostName} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + }, + textStack: { + alignItems: "center", + gap: theme.spacing[2], + maxWidth: 520, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + textAlign: "center", + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + lineHeight: Math.round(theme.fontSize.sm * 1.4), + textAlign: "center", + }, + errorTooltip: { + color: theme.colors.popoverForeground, + fontSize: theme.fontSize.sm, + maxWidth: 420, + }, + actions: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + reconnectBanner: { + minHeight: 32, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + }, + reconnectText: { + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + flexShrink: 1, + }, + reconnectTextDestructive: { + minWidth: 0, + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + flexShrink: 1, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts new file mode 100644 index 000000000..bd3ba6a29 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { resolveWorkspaceRouteState } from "./workspace-route-state"; + +function createWorkspaceDescriptor(): WorkspaceDescriptor { + return { + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Project", + projectRootPath: "/repo/project", + workspaceDirectory: "/repo/project", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + diffStat: null, + scripts: [], + archivingAt: null, + }; +} + +describe("resolveWorkspaceRouteState", () => { + it("returns unreachable when no descriptor is cached and the host is offline", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "offline", + lastError: "transport closed", + workspace: null, + hasHydratedWorkspaces: false, + }), + ).toEqual({ + kind: "unreachable", + hostName: "Laptop", + connectionStatus: "offline", + lastError: "transport closed", + }); + }); + + it("keeps offline routes unreachable after workspace hydration", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "offline", + lastError: "transport closed", + workspace: null, + hasHydratedWorkspaces: true, + }), + ).toEqual({ + kind: "unreachable", + hostName: "Laptop", + connectionStatus: "offline", + lastError: "transport closed", + }); + }); + + it("returns reconnecting when the descriptor is cached and the host is offline", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "offline", + lastError: "transport closed", + workspace: createWorkspaceDescriptor(), + hasHydratedWorkspaces: true, + }), + ).toEqual({ + kind: "reconnecting", + hostName: "Laptop", + connectionStatus: "offline", + lastError: "transport closed", + }); + }); + + it("returns missing after workspace hydration when the host is online", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: null, + hasHydratedWorkspaces: true, + }), + ).toEqual({ kind: "missing", hostName: "Laptop" }); + }); + + it("returns loading before workspace hydration when the host is online", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: null, + hasHydratedWorkspaces: false, + }), + ).toEqual({ kind: "loading", hostName: "Laptop" }); + }); + + it("returns ready when the host is online and the descriptor exists", () => { + expect( + resolveWorkspaceRouteState({ + hostName: "Laptop", + connectionStatus: "online", + lastError: null, + workspace: createWorkspaceDescriptor(), + hasHydratedWorkspaces: true, + }), + ).toEqual({ kind: "ready" }); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts new file mode 100644 index 000000000..0c4e2ef89 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-route-state.ts @@ -0,0 +1,55 @@ +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +export type WorkspaceRouteState = + | { kind: "ready" } + | { + kind: "reconnecting"; + hostName: string; + connectionStatus: Exclude; + lastError: string | null; + } + | { + kind: "unreachable"; + hostName: string; + connectionStatus: Exclude; + lastError: string | null; + } + | { kind: "loading"; hostName: string } + | { kind: "missing"; hostName: string }; + +export function resolveWorkspaceRouteState(input: { + hostName: string; + connectionStatus: HostRuntimeConnectionStatus; + lastError: string | null; + workspace: WorkspaceDescriptor | null; + hasHydratedWorkspaces: boolean; +}): WorkspaceRouteState { + if (input.workspace) { + if (input.connectionStatus === "online") { + return { kind: "ready" }; + } + + return { + kind: "reconnecting", + hostName: input.hostName, + connectionStatus: input.connectionStatus, + lastError: input.lastError, + }; + } + + if (input.connectionStatus === "online") { + if (input.hasHydratedWorkspaces) { + return { kind: "missing", hostName: input.hostName }; + } + + return { kind: "loading", hostName: input.hostName }; + } + + return { + kind: "unreachable", + hostName: input.hostName, + connectionStatus: input.connectionStatus, + lastError: input.lastError, + }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f38de49d3..e30af940a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,8 +1,18 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; import { @@ -67,7 +77,13 @@ import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + useHostRuntimeClient, + useHostRuntimeIsConnected, + useHostRuntimeSnapshot, + useHosts, +} from "@/runtime/host-runtime"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspace } from "@/stores/session-store-hooks"; @@ -106,9 +122,16 @@ import { import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceHeaderRenderState, - shouldRenderMissingWorkspaceDescriptor, type WorkspaceHeaderCheckoutState, } from "@/screens/workspace/workspace-header-source"; +import { + resolveWorkspaceRouteState, + type WorkspaceRouteState, +} from "@/screens/workspace/workspace-route-state"; +import { + renderWorkspaceReconnectIndicator, + renderWorkspaceRouteGate, +} from "@/screens/workspace/workspace-route-state-views"; import { deriveWorkspaceAgentVisibility, workspaceAgentVisibilityEqual, @@ -132,6 +155,7 @@ import { isAbsolutePath } from "@/utils/path"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; +import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; const TERMINALS_QUERY_STALE_TIME = 5_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; @@ -166,6 +190,7 @@ const MENU_NEW_TERMINAL_ICON = ; const MENU_COPY_ICON = ; const MENU_SETTINGS_ICON = ; +const GATED_WORKSPACE_HEADER_LEFT = ; interface WorkspaceScreenProps { serverId: string; @@ -971,7 +996,6 @@ function parsePaneDirection(actionId: string): PaneDirection | null { } interface RenderWorkspaceContentInput { - showMissingWorkspaceDescriptor: boolean; isMissingWorkspaceExecutionAuthority: boolean; activeTabDescriptor: WorkspaceTabDescriptor | null; hasHydratedAgents: boolean; @@ -987,7 +1011,6 @@ interface RenderWorkspaceContentInput { function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode { const { - showMissingWorkspaceDescriptor, isMissingWorkspaceExecutionAuthority, activeTabDescriptor, hasHydratedAgents, @@ -998,13 +1021,6 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React buildMobilePaneContentModel, } = input; - if (showMissingWorkspaceDescriptor) { - return ( - - - - ); - } if (isMissingWorkspaceExecutionAuthority) { return ( @@ -1152,6 +1168,170 @@ function removeTerminalFromPayload(terminalId: string) { }; } +function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string { + const trimmed = host?.label?.trim(); + return trimmed ? trimmed : fallback; +} + +function useWorkspaceRouteActions(normalizedServerId: string): { + handleRetryHost: () => void; + handleManageHost: () => void; + handleDismissMissingWorkspace: () => void; +} { + const router = useRouter(); + const handleRetryHost = useCallback(() => { + if (!normalizedServerId) { + return; + } + void getHostRuntimeStore().runProbeCycleNow(normalizedServerId); + }, [normalizedServerId]); + const handleManageHost = useCallback(() => { + if (!normalizedServerId) { + return; + } + router.push(buildSettingsHostRoute(normalizedServerId) as Href); + }, [normalizedServerId, router]); + const handleDismissMissingWorkspace = useCallback(() => { + if (router.canGoBack()) { + router.back(); + return; + } + if (normalizedServerId) { + router.replace(buildHostRootRoute(normalizedServerId) as Href); + return; + } + router.replace("/" as Href); + }, [normalizedServerId, router]); + + return { + handleRetryHost, + handleManageHost, + handleDismissMissingWorkspace, + }; +} + +function useResolvedWorkspaceRouteState(input: { + serverId: string; + workspace: WorkspaceDescriptor | null; + hasHydratedWorkspaces: boolean; +}): WorkspaceRouteState { + const hosts = useHosts(); + const host = useMemo( + () => hosts.find((entry) => entry.serverId === input.serverId) ?? null, + [hosts, input.serverId], + ); + const hostSnapshot = useHostRuntimeSnapshot(input.serverId); + const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]); + + return useMemo( + () => + resolveWorkspaceRouteState({ + hostName, + connectionStatus: hostSnapshot?.connectionStatus ?? "connecting", + lastError: hostSnapshot?.lastError ?? null, + workspace: input.workspace, + hasHydratedWorkspaces: input.hasHydratedWorkspaces, + }), + [ + hostName, + hostSnapshot?.connectionStatus, + hostSnapshot?.lastError, + input.workspace, + input.hasHydratedWorkspaces, + ], + ); +} + +function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} + +function renderWorkspaceScreenGateShell(input: { + gate: ReactNode; + workspaceKey: string | null; +}): ReactElement | null { + if (!input.gate) { + return null; + } + + return ( + + + + + {input.gate} + + + + + ); +} + +function WorkspaceDocumentTitleEffectSlot({ + tab, + serverId, + workspaceId, + isRouteFocused, +}: { + tab: WorkspaceTabDescriptor | null; + serverId: string; + workspaceId: string; + isRouteFocused: boolean; +}) { + if (!isRouteFocused || !isWeb || !tab) { + return null; + } + + return ( + + {(presentation) => ( + + )} + + ); +} + +function shouldShowWorkspaceScreenHeader(input: { + isFocusModeEnabled: boolean; + isMobile: boolean; +}): boolean { + return !input.isFocusModeEnabled || input.isMobile; +} + +function shouldShowWorkspaceExplorerSidebar(input: { + isRouteFocused: boolean; + isFocusModeEnabled: boolean; + isMobile: boolean; +}): boolean { + return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input); +} + +function canCreateWorkspaceTerminal(input: { + isRouteFocused: boolean; + client: unknown; + isConnected: boolean; + workspaceDirectory: string | null; +}): boolean { + return Boolean( + input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory, + ); +} + +function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null { + if (!serverId || !workspaceId) { + return null; + } + return `${serverId}:${workspaceId}`; +} + function WorkspaceScreenContent({ serverId, workspaceId, @@ -1169,12 +1349,11 @@ function WorkspaceScreenContent({ [workspaceId], ); const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); + const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = + useWorkspaceRouteActions(normalizedServerId); const workspaceTerminalScopeKey = useMemo( - () => - normalizedServerId && normalizedWorkspaceId - ? `${normalizedServerId}:${normalizedWorkspaceId}` - : null, + () => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId), [normalizedServerId, normalizedWorkspaceId], ); useWorkspaceTerminalSessionRetention({ @@ -1202,7 +1381,7 @@ function WorkspaceScreenContent({ paneId?: string; } | null>(null); const canCreateTerminalNow = useMemo( - () => Boolean(isRouteFocused && client && isConnected && workspaceDirectory), + () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }), [isRouteFocused, client, isConnected, workspaceDirectory], ); @@ -1351,7 +1530,7 @@ function WorkspaceScreenContent({ }, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]); const isCheckoutQueryEnabled = useMemo( - () => Boolean(isRouteFocused && client && isConnected && workspaceDirectory), + () => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }), [isRouteFocused, client, isConnected, workspaceDirectory], ); const checkoutQuery = useQuery({ @@ -1381,6 +1560,11 @@ function WorkspaceScreenContent({ const hasHydratedAgents = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, ); + const workspaceRouteState = useResolvedWorkspaceRouteState({ + serverId: normalizedServerId, + workspace: workspaceDescriptor, + hasHydratedWorkspaces, + }); useEffect(() => { if (!pendingTerminalCreateInput) { return; @@ -2602,12 +2786,7 @@ function WorkspaceScreenContent({ }, [buildPaneContentModel], ); - const showMissingWorkspaceDescriptor = shouldRenderMissingWorkspaceDescriptor({ - workspace: workspaceDescriptor, - hasHydratedWorkspaces, - }); const content = renderWorkspaceContent({ - showMissingWorkspaceDescriptor, isMissingWorkspaceExecutionAuthority, activeTabDescriptor, hasHydratedAgents, @@ -2718,6 +2897,22 @@ function WorkspaceScreenContent({ const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON; const menuCopyIcon = MENU_COPY_ICON; const menuSettingsIcon = MENU_SETTINGS_ICON; + const workspaceScreenGate = renderWorkspaceRouteGate({ + state: workspaceRouteState, + actions: { + onRetryHost: handleRetryHost, + onManageHost: handleManageHost, + onDismissMissingWorkspace: handleDismissMissingWorkspace, + }, + }); + const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({ + gate: workspaceScreenGate, + workspaceKey: persistenceKey, + }); + const reconnectBanner = renderWorkspaceReconnectIndicator({ + state: workspaceRouteState, + onRetryHost: handleRetryHost, + }); const headerRight = useMemo( () => ( @@ -2855,16 +3050,12 @@ function WorkspaceScreenContent({ ], ); - const documentTitleEffectTab = useMemo( - () => (isRouteFocused && isWeb ? activeTabDescriptor : null), - [isRouteFocused, activeTabDescriptor], - ); const showScreenHeader = useMemo( - () => !isFocusModeEnabled || isMobile, + () => shouldShowWorkspaceScreenHeader({ isFocusModeEnabled, isMobile }), [isFocusModeEnabled, isMobile], ); const showExplorerSidebar = useMemo( - () => isRouteFocused && (!isFocusModeEnabled || isMobile), + () => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }), [isRouteFocused, isFocusModeEnabled, isMobile], ); const createTerminalDisabled = useMemo( @@ -2951,134 +3142,129 @@ function WorkspaceScreenContent({ ]); return ( - - - {documentTitleEffectTab ? ( - + + - {(presentation) => ( - - )} - - ) : null} - - - {showScreenHeader && ( - - - - - } - right={headerRight} - /> - )} - - {isMobile ? ( - - ) : null} - - {shouldRenderDesktopPaneFallback ? ( - - ) : null} - - - {isMobile ? ( - - {content} - - ) : ( - {desktopContent} + isRouteFocused={isRouteFocused} + /> + + + {reconnectBanner} + {showScreenHeader && ( + + + + + } + right={headerRight} + /> )} - - - {showExplorerSidebar && workspaceDirectory ? ( - - ) : null} + {isMobile ? ( + + ) : null} + + {shouldRenderDesktopPaneFallback ? ( + + ) : null} + + + {isMobile ? ( + + {content} + + ) : ( + {desktopContent} + )} + + + + {showExplorerSidebar && workspaceDirectory ? ( + + ) : null} + - - + + ) ); } diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 1546fae21..aba0b5ba5 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -265,6 +265,7 @@ export function activateNavigationWorkspaceSelection( next: ActiveWorkspaceSelection, options: ActivateWorkspaceSelectionOptions = {}, ) { + setLastWorkspaceRouteSelection(next); writeBrowserWorkspaceUrl(next, options); emitIfChanged(next); } diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index a71f2057d..63c782d75 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -16,7 +16,7 @@ describe("MockLoadTestAgentClient", () => { vi.useRealTimers(); }); - test("default model is a five minute foreground stream", async () => { + test("default model is a five minute foreground stream with token-rate intervals", async () => { const client = new MockLoadTestAgentClient(); const models = await client.listModels({ cwd: "/tmp/mock-models", force: false }); @@ -26,12 +26,12 @@ describe("MockLoadTestAgentClient", () => { isDefault: true, metadata: { durationMs: 300_000, - intervalMs: 1000, + intervalMs: 40, }, }); }); - test("emits repeated markdown, reasoning, and tool calls during a foreground turn", async () => { + test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); const session = await client.createSession({ @@ -65,39 +65,43 @@ describe("MockLoadTestAgentClient", () => { const timelineItems = events.flatMap((event): AgentTimelineItem[] => event.type === "timeline" ? [event.item] : [], ); - expect(timelineItems.find((item) => item.type === "reasoning")).toMatchObject({ - type: "reasoning", - text: expect.stringContaining("Thinking chunk 1"), + + const assistantTokens = timelineItems.filter((item) => item.type === "assistant_message"); + const reasoningTokens = timelineItems.filter((item) => item.type === "reasoning"); + const toolCalls = timelineItems.filter((item) => item.type === "tool_call"); + + // Many small token deltas, not a few big chunks. + expect(assistantTokens.length).toBeGreaterThan(200); + expect(reasoningTokens.length).toBeGreaterThan(20); + + // Average token length should be sub-word (a few characters). + const avgTokenLength = + assistantTokens.reduce( + (sum, item) => sum + (item.type === "assistant_message" ? item.text.length : 0), + 0, + ) / assistantTokens.length; + expect(avgTokenLength).toBeLessThan(10); + + // First assistant token starts the cycle header. + expect(assistantTokens[0]).toMatchObject({ + type: "assistant_message", + text: expect.stringContaining("##"), }); - expect( - timelineItems.filter( - (item) => - item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle 1\n"), - ), - ).toHaveLength(1); - expect( - timelineItems.filter((item) => item.type === "assistant_message" && item.text.length > 0) - .length, - ).toBeGreaterThan(4); - expect( - timelineItems.filter( - (item) => - item.type === "tool_call" && - item.name === "edit" && - item.status === "running" && - item.detail.type === "edit", - ), - ).toHaveLength(40); - expect( - timelineItems.filter( - (item) => - item.type === "tool_call" && - item.name === "bash" && - item.status === "completed" && - item.detail.type === "shell" && - item.detail.output?.includes("mock load cycle"), - ), - ).toHaveLength(40); + + // Sequential tool calls fire: read, grep, edit, bash. + const toolNames = toolCalls + .filter((item) => item.type === "tool_call" && item.status === "running") + .map((item) => (item.type === "tool_call" ? item.name : "")); + expect(toolNames.slice(0, 4)).toEqual(["read", "grep", "edit", "bash"]); + + // Each tool transitions running → completed. + const completedNames = toolCalls + .filter((item) => item.type === "tool_call" && item.status === "completed") + .map((item) => (item.type === "tool_call" ? item.name : "")); + expect(completedNames).toContain("read"); + expect(completedNames).toContain("grep"); + expect(completedNames).toContain("edit"); + expect(completedNames).toContain("bash"); }); test("interrupt cancels the active foreground turn and stops future chunks", async () => { @@ -127,7 +131,7 @@ describe("MockLoadTestAgentClient", () => { expect(events).toHaveLength(eventCountAfterInterrupt); }); - test("agent manager coalesces adjacent markdown chunks from the mock provider", async () => { + test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => { vi.useFakeTimers(); const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-")); try { @@ -155,20 +159,36 @@ describe("MockLoadTestAgentClient", () => { await resultPromise; const timeline = manager.getTimeline(agent.id); - const loadCycleDocuments = timeline.filter( - (item): item is Extract => - item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle "), - ); - const editToolCalls = timeline.filter( - (item) => item.type === "tool_call" && item.name === "edit", - ); + const assistantMessages = timeline.filter((item) => item.type === "assistant_message"); + const toolCalls = timeline.filter((item) => item.type === "tool_call"); - expect(loadCycleDocuments).toHaveLength(40); - expect(loadCycleDocuments[0]?.text).toContain("This paragraph is intentionally stable"); - expect(editToolCalls).toHaveLength(40); - expect( - editToolCalls.every((item) => item.type === "tool_call" && item.status === "completed"), - ).toBe(true); + // The provider streams sub-word tokens; the coalescer batches them within + // its flush window, so the timeline must contain materially fewer messages + // than the underlying token deltas would suggest, and each message must + // hold multiple tokens worth of text. + expect(assistantMessages.length).toBeGreaterThan(0); + const totalAssistantChars = assistantMessages.reduce( + (sum, item) => sum + (item.type === "assistant_message" ? item.text.length : 0), + 0, + ); + const avgMessageLength = totalAssistantChars / assistantMessages.length; + expect(avgMessageLength).toBeGreaterThan(8); + const longestMessage = assistantMessages + .map((item) => (item.type === "assistant_message" ? item.text.length : 0)) + .reduce((max, length) => Math.max(max, length), 0); + expect(longestMessage).toBeGreaterThan(20); + + // First message includes the cycle header. + expect(assistantMessages[0]).toMatchObject({ + type: "assistant_message", + text: expect.stringContaining("## Cycle 1"), + }); + + // Tool calls land in expected order at least once. + const runningTools = toolCalls + .filter((item) => item.type === "tool_call" && item.status === "completed") + .map((item) => (item.type === "tool_call" ? item.name : "")); + expect(runningTools).toEqual(expect.arrayContaining(["read", "grep", "edit", "bash"])); } finally { rmSync(workdir, { recursive: true, force: true }); } diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 1e44aa3a3..161d3648a 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -24,6 +24,7 @@ import type { ListModelsOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, + ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { getAgentProviderDefinition } from "../provider-manifest.js"; @@ -32,7 +33,7 @@ export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream"; const MOCK_LOAD_TEST_MODE_ID = "load-test"; const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000; -const MOCK_LOAD_TEST_INTERVAL_MS = 1000; +const MOCK_LOAD_TEST_INTERVAL_MS = 40; const CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -48,31 +49,42 @@ const MODELS: AgentModelDefinition[] = [ provider: MOCK_LOAD_TEST_PROVIDER_ID, id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, label: "Five minute stream", - description: "Repeats synthetic markdown and tool-call timeline traffic for five minutes.", + description: + "Realistic agent flow streamed as sub-word tokens for five minutes (good for scroll/coalesce debugging).", isDefault: true, metadata: { durationMs: MOCK_LOAD_TEST_DURATION_MS, intervalMs: MOCK_LOAD_TEST_INTERVAL_MS, }, }, + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: "thirty-minute-stream", + label: "Thirty minute stream", + description: "Long-running realistic stream for extended scroll-anchor debugging.", + metadata: { + durationMs: 30 * 60 * 1000, + intervalMs: 50, + }, + }, { provider: MOCK_LOAD_TEST_PROVIDER_ID, id: "one-minute-stream", label: "One minute stream", - description: "Shorter synthetic load stream for quick manual checks.", + description: "Shorter realistic stream for quick manual checks.", metadata: { durationMs: 60_000, - intervalMs: 500, + intervalMs: 40, }, }, { provider: MOCK_LOAD_TEST_PROVIDER_ID, id: "ten-second-stream", label: "Ten second stream", - description: "Fast synthetic load stream for tests and smoke checks.", + description: "Fast realistic stream for tests and smoke checks.", metadata: { durationMs: 10_000, - intervalMs: 250, + intervalMs: 5, }, }, ]; @@ -81,14 +93,24 @@ interface ActiveTurn { turnId: string; prompt: AgentPromptInput; startedAt: number; - iteration: number; + cycle: number; durationMs: number; intervalMs: number; timer: ReturnType | null; resolve: (result: AgentRunResult) => void; completed: Promise; + queue: CycleEvent[]; + emittedTokens: number; + turnStarted: boolean; } +type CycleEvent = + | { kind: "assistant_token"; text: string } + | { kind: "reasoning_token"; text: string } + | { kind: "tool_running"; callId: string; name: string; detail: ToolCallDetail } + | { kind: "tool_completed"; callId: string; name: string; detail: ToolCallDetail } + | { kind: "usage" }; + interface LargeAgentStreamPayloadRequest { bytes: number; kind: "diff" | "file" | "image"; @@ -175,47 +197,175 @@ function buildRepeatedPayload(bytes: number, prefix: string): string { return output.slice(0, bytes); } -function buildMarkdownDocument(iteration: number, prompt: AgentPromptInput): string { - const promptPreview = promptToText(prompt).slice(0, 160) || "No prompt text supplied."; +function tokenize(text: string): string[] { + const tokens: string[] = []; + const regex = /(\s*)(\S+)|(\s+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + const [, leadingWs, word, lonelyWs] = match; + if (lonelyWs !== undefined) { + tokens.push(lonelyWs); + continue; + } + const ws = leadingWs ?? ""; + const w = word ?? ""; + if (w.length <= 5) { + tokens.push(ws + w); + continue; + } + tokens.push(ws + w.slice(0, 4)); + for (let i = 4; i < w.length; i += 4) { + tokens.push(w.slice(i, i + 4)); + } + } + return tokens; +} + +function buildIntroParagraph(cycle: number): string { return [ - `## Synthetic Load Cycle ${iteration}`, - "", - `Prompt preview: ${promptPreview}`, - "", - "| Signal | Value |", - "| --- | --- |", - `| cycle | ${iteration} |`, - "| stream | markdown + reasoning + tools |", - "", - "```ts", - `const cycle = ${iteration};`, - 'const purpose = "exercise app parsing and terminal rendering under load";', - "```", - "", - "This paragraph is intentionally stable so repeated cycles produce predictable UI pressure.", + `## Cycle ${cycle}`, "", + "I'll take a look at the scroll anchor behavior you described. Let me start by walking through how the conversation list currently handles streaming updates and where the auto-scroll logic actually lives. My instinct is that the anchor is supposed to pin to the bottom only when the user is already there, but I want to confirm that against the code rather than guess. This kind of behavior is usually a thin layout effect over a ref, and the bugs tend to come from event ordering rather than the math itself, so the first useful step is to read the relevant files.", ].join("\n"); } -function splitText(text: string, chunks: number): string[] { - const size = Math.ceil(text.length / chunks); - const parts: string[] = []; - for (let index = 0; index < text.length; index += size) { - parts.push(text.slice(index, index + size)); +function buildReasoningText(): string { + return "Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold."; +} + +function buildMidParagraph(): string { + return [ + "Now I have a clearer picture. The auto-scroll uses a ref on the FlatList and tracks whether the user has scrolled away from the bottom by comparing the offset against the content size. There are a few subtle issues worth flagging before we change anything:", + "", + "- The threshold for 'near the bottom' is hardcoded at 80px, which feels too tight on dense content where headers and tool calls take up a lot of vertical space.", + "- We rely on `onContentSizeChange` to detect new content, but that fires after layout, not as the streaming delta arrives, so we end up scrolling one frame late on fast streams.", + "- The gesture handler does not pause scroll-to-bottom while the user is actively dragging, which means a drag in progress can be visually overridden mid-frame.", + "- Coalescing happens upstream, so the FlatList sees fewer updates than the wire — but each batch can still cause a relayout.", + "", + "Let me make a small adjustment to the threshold and add a flag for active gestures, then run a quick command to confirm the order of events when streaming is fast.", + ].join("\n"); +} + +function buildClosingParagraph(): string { + return "The change should keep scroll-to-bottom working when the user is at the bottom while not yanking the viewport when they are reading earlier messages. The bash output confirms the `userIsAtBottom` flag flips correctly during a simulated streaming burst, and the gesture flag suppresses scroll while a drag is active. If you want, I can follow up with a regression test that drives the FlatList with synthetic deltas at high frequency to lock in the behavior. For now, I'll stop here so you can take a look."; +} + +function buildEditDiff(filePath: string): string { + return [ + `diff --git a/${filePath} b/${filePath}`, + `--- a/${filePath}`, + `+++ b/${filePath}`, + "@@ -42,7 +42,9 @@", + " const ref = useRef(null);", + " const [userIsAtBottom, setUserIsAtBottom] = useState(true);", + "- const NEAR_BOTTOM_PX = 80;", + "+ const NEAR_BOTTOM_PX = 160;", + "+ const isDraggingRef = useRef(false);", + "", + "- if (userIsAtBottom) ref.current?.scrollToEnd({ animated: true });", + "+ if (userIsAtBottom && !isDraggingRef.current) {", + "+ ref.current?.scrollToEnd({ animated: true });", + "+ }", + ].join("\n"); +} + +function buildCycleQueue(turnId: string, cycle: number): CycleEvent[] { + const queue: CycleEvent[] = []; + + for (const tok of tokenize(buildIntroParagraph(cycle))) { + queue.push({ kind: "assistant_token", text: tok }); } - return parts; + + for (const tok of tokenize(buildReasoningText())) { + queue.push({ kind: "reasoning_token", text: tok }); + } + + const readDetail: ToolCallDetail = { + type: "read", + filePath: "packages/app/src/components/conversation-list.tsx", + }; + const readId = `${turnId}:read:${cycle}`; + queue.push({ kind: "tool_running", callId: readId, name: "read", detail: readDetail }); + queue.push({ + kind: "tool_completed", + callId: readId, + name: "read", + detail: { + ...readDetail, + content: + "export function ConversationList() {\n const ref = useRef(null);\n // ...\n}", + }, + }); + + const grepDetail: ToolCallDetail = { + type: "search", + query: "scrollToEnd", + toolName: "grep", + mode: "files_with_matches", + }; + const grepId = `${turnId}:grep:${cycle}`; + queue.push({ kind: "tool_running", callId: grepId, name: "grep", detail: grepDetail }); + queue.push({ + kind: "tool_completed", + callId: grepId, + name: "grep", + detail: { + ...grepDetail, + filePaths: [ + "packages/app/src/components/conversation-list.tsx", + "packages/app/src/hooks/use-scroll-anchor.ts", + ], + numFiles: 2, + numMatches: 5, + }, + }); + + for (const tok of tokenize(buildMidParagraph())) { + queue.push({ kind: "assistant_token", text: tok }); + } + + const editFile = "packages/app/src/hooks/use-scroll-anchor.ts"; + const editDetail: ToolCallDetail = { + type: "edit", + filePath: editFile, + oldString: "const NEAR_BOTTOM_PX = 80;", + newString: "const NEAR_BOTTOM_PX = 160;", + unifiedDiff: buildEditDiff(editFile), + }; + const editId = `${turnId}:edit:${cycle}`; + queue.push({ kind: "tool_running", callId: editId, name: "edit", detail: editDetail }); + queue.push({ kind: "tool_completed", callId: editId, name: "edit", detail: editDetail }); + + const shellDetail: ToolCallDetail = { + type: "shell", + command: "node scripts/simulate-stream-burst.mjs", + cwd: "/tmp/paseo-mock-load", + output: + "[burst] tick 1 userIsAtBottom=true\n[burst] tick 2 userIsAtBottom=true\n[burst] drag-start isDragging=true\n[burst] tick 3 suppressed\n[burst] drag-end isDragging=false\n", + exitCode: 0, + }; + const shellId = `${turnId}:bash:${cycle}`; + queue.push({ kind: "tool_running", callId: shellId, name: "bash", detail: shellDetail }); + queue.push({ kind: "tool_completed", callId: shellId, name: "bash", detail: shellDetail }); + + for (const tok of tokenize(buildClosingParagraph())) { + queue.push({ kind: "assistant_token", text: tok }); + } + + queue.push({ kind: "usage" }); + + return queue; } function createToolCall(input: { - turnId: string; - iteration: number; + callId: string; name: string; status: ToolCallTimelineItem["status"]; - detail: ToolCallTimelineItem["detail"]; + detail: ToolCallDetail; }): ToolCallTimelineItem { return { type: "tool_call", - callId: `${input.turnId}:${input.name}:${input.iteration}`, + callId: input.callId, name: input.name, status: input.status, error: null, @@ -330,12 +480,15 @@ export class MockLoadTestAgentSession implements AgentSession { turnId, prompt, startedAt: Date.now(), - iteration: 0, + cycle: 0, durationMs: profile.durationMs, intervalMs: profile.intervalMs, timer: null, resolve, completed, + queue: [], + emittedTokens: 0, + turnStarted: false, }; this.activeTurn = turn; @@ -608,8 +761,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline( turn.turnId, createToolCall({ - turnId: turn.turnId, - iteration: 0, + callId: `${turn.turnId}:edit:large`, name: "edit", status: "completed", detail: { @@ -623,8 +775,7 @@ export class MockLoadTestAgentSession implements AgentSession { this.emitTimeline( turn.turnId, createToolCall({ - turnId: turn.turnId, - iteration: 0, + callId: `${turn.turnId}:read:large`, name: "read", status: "completed", detail: { @@ -669,7 +820,8 @@ export class MockLoadTestAgentSession implements AgentSession { } this.clearTurnTimer(turn); - if (turn.iteration === 0) { + if (!turn.turnStarted) { + turn.turnStarted = true; this.emit({ type: "turn_started", provider: this.provider, @@ -683,103 +835,71 @@ export class MockLoadTestAgentSession implements AgentSession { return; } - turn.iteration += 1; - this.emitIteration(turn); + if (turn.queue.length === 0) { + turn.cycle += 1; + turn.queue = buildCycleQueue(turn.turnId, turn.cycle); + } + + const event = turn.queue.shift(); + if (event) { + this.dispatchCycleEvent(turn, event); + } + this.schedule(turn, turn.intervalMs); } - private emitIteration(turn: ActiveTurn): void { - const { turnId, iteration, prompt } = turn; - this.emitTimeline(turnId, { - type: "reasoning", - text: `Thinking chunk ${iteration}: simulate planning pressure before rendering markdown.\n`, - }); - - for (const chunk of splitText(buildMarkdownDocument(iteration, prompt), 4)) { - this.emitTimeline(turnId, { - type: "assistant_message", - text: chunk, - }); + private dispatchCycleEvent(turn: ActiveTurn, event: CycleEvent): void { + switch (event.kind) { + case "assistant_token": { + turn.emittedTokens += 1; + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: event.text, + }); + return; + } + case "reasoning_token": { + turn.emittedTokens += 1; + this.emitTimeline(turn.turnId, { + type: "reasoning", + text: event.text, + }); + return; + } + case "tool_running": + case "tool_completed": { + this.emitTimeline( + turn.turnId, + createToolCall({ + callId: event.callId, + name: event.name, + status: event.kind === "tool_running" ? "running" : "completed", + detail: event.detail, + }), + ); + return; + } + case "usage": { + this.emit({ + type: "usage_updated", + provider: this.provider, + turnId: turn.turnId, + usage: { + inputTokens: turn.cycle * 32, + outputTokens: turn.emittedTokens, + contextWindowUsedTokens: turn.emittedTokens * 2, + contextWindowMaxTokens: 128_000, + }, + }); + return; + } } - - const editDetail = { - type: "edit" as const, - filePath: `src/load-test-${iteration}.ts`, - oldString: "before", - newString: "after", - unifiedDiff: [ - `diff --git a/src/load-test-${iteration}.ts b/src/load-test-${iteration}.ts`, - "@@", - "-before", - "+after", - ].join("\n"), - }; - this.emitTimeline( - turnId, - createToolCall({ - turnId, - iteration, - name: "edit", - status: "running", - detail: editDetail, - }), - ); - this.emitTimeline( - turnId, - createToolCall({ - turnId, - iteration, - name: "edit", - status: "completed", - detail: editDetail, - }), - ); - - const shellDetail = { - type: "shell" as const, - command: `printf 'mock load cycle ${iteration}\\n'`, - cwd: "/tmp/paseo-mock-load", - output: `mock load cycle ${iteration}\n`, - exitCode: 0, - }; - this.emitTimeline( - turnId, - createToolCall({ - turnId, - iteration, - name: "bash", - status: "running", - detail: shellDetail, - }), - ); - this.emitTimeline( - turnId, - createToolCall({ - turnId, - iteration, - name: "bash", - status: "completed", - detail: shellDetail, - }), - ); - - this.emit({ - type: "usage_updated", - provider: this.provider, - turnId, - usage: { - inputTokens: iteration * 32, - outputTokens: iteration * 128, - contextWindowUsedTokens: iteration * 160, - contextWindowMaxTokens: 128_000, - }, - }); } private finishTurn(turn: ActiveTurn): void { this.emitTimeline(turn.turnId, { type: "assistant_message", - text: "## Synthetic load test complete\n\nThe mock provider finished its foreground turn.\n", + text: "\n\n_(end of synthetic stream)_\n", }); this.finishTurnWithText(turn, "Synthetic load test complete"); } @@ -787,9 +907,9 @@ export class MockLoadTestAgentSession implements AgentSession { private finishTurnWithText(turn: ActiveTurn, finalText: string): void { this.activeTurn = null; const usage = { - inputTokens: turn.iteration * 32, - outputTokens: turn.iteration * 128, - contextWindowUsedTokens: turn.iteration * 160, + inputTokens: turn.cycle * 32, + outputTokens: turn.emittedTokens, + contextWindowUsedTokens: turn.emittedTokens * 2, contextWindowMaxTokens: 128_000, }; this.emit({