mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Simplify workspace reconnect gating
This commit is contained in:
@@ -68,6 +68,22 @@ terminalPerfDescribe("Terminal keystroke stress", () => {
|
|||||||
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||||
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
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({
|
const appSmallChunksReport = await measureAppBurstEcho({
|
||||||
page,
|
page,
|
||||||
harness,
|
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(
|
async function emitRapidAgentStreamUpdates(
|
||||||
harness: TerminalE2EHarness,
|
harness: TerminalE2EHarness,
|
||||||
input: { agentId: string; count: number },
|
input: { agentId: string; count: number },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
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 { gotoAppShell } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
@@ -27,9 +28,203 @@ import {
|
|||||||
waitForSidebarHydration,
|
waitForSidebarHydration,
|
||||||
} from "./helpers/workspace-ui";
|
} 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<void> {
|
||||||
|
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<WebSocketRoute>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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("Workspace navigation regression", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
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<string>();
|
||||||
|
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 ({
|
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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 <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||||
|
case "unreachable":
|
||||||
|
return (
|
||||||
|
<WorkspaceUnreachable
|
||||||
|
state={input.state}
|
||||||
|
onRetry={input.actions.onRetryHost}
|
||||||
|
onManageHost={input.actions.onManageHost}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "missing":
|
||||||
|
return (
|
||||||
|
<WorkspaceMissing
|
||||||
|
hostName={input.state.hostName}
|
||||||
|
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "ready":
|
||||||
|
case "reconnecting":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWorkspaceReconnectIndicator(input: {
|
||||||
|
state: WorkspaceRouteState;
|
||||||
|
onRetryHost: () => void;
|
||||||
|
}): React.ReactNode {
|
||||||
|
if (input.state.kind !== "reconnecting") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <WorkspaceReconnectBanner state={input.state} onRetry={input.onRetryHost} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceHostStateTitle(
|
||||||
|
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
|
||||||
|
): 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<WorkspaceRouteState, { kind: "reconnecting" }>,
|
||||||
|
): 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 (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<View style={styles.textStack}>
|
||||||
|
<Text style={styles.title}>Loading workspace</Text>
|
||||||
|
<Text style={styles.description}>{hostName}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceUnreachable({
|
||||||
|
state,
|
||||||
|
onRetry,
|
||||||
|
onManageHost,
|
||||||
|
}: {
|
||||||
|
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>;
|
||||||
|
onRetry: () => void;
|
||||||
|
onManageHost: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
{state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? (
|
||||||
|
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
|
<View style={styles.textStack}>
|
||||||
|
<Text style={styles.title}>{getWorkspaceHostStateTitle(state)}</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Host status: {formatConnectionStatus(state.connectionStatus)}
|
||||||
|
</Text>
|
||||||
|
{state.lastError ? (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Text style={styles.error} numberOfLines={3}>
|
||||||
|
{state.lastError}
|
||||||
|
</Text>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<Text style={styles.errorTooltip}>{state.lastError}</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{canRetry ? (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
|
||||||
|
Manage host
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceReconnectBanner({
|
||||||
|
state,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>;
|
||||||
|
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 (
|
||||||
|
<View style={styles.reconnectBanner} testID="workspace-reconnect-banner">
|
||||||
|
{showSpinner ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
|
||||||
|
<Text style={isErrorState ? styles.reconnectTextDestructive : styles.reconnectText}>
|
||||||
|
{getReconnectBannerCopy(state)}
|
||||||
|
</Text>
|
||||||
|
{canRetry ? (
|
||||||
|
<Button size="sm" variant="outline" leftIcon={RotateCw} onPress={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<View style={styles.textStack}>
|
||||||
|
<Text style={styles.title}>Workspace not found</Text>
|
||||||
|
<Text style={styles.description}>{hostName}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
109
packages/app/src/screens/workspace/workspace-route-state.test.ts
Normal file
109
packages/app/src/screens/workspace/workspace-route-state.test.ts
Normal file
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
55
packages/app/src/screens/workspace/workspace-route-state.ts
Normal file
55
packages/app/src/screens/workspace/workspace-route-state.ts
Normal file
@@ -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<HostRuntimeConnectionStatus, "online">;
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "unreachable";
|
||||||
|
hostName: string;
|
||||||
|
connectionStatus: Exclude<HostRuntimeConnectionStatus, "online">;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter, type Href } from "expo-router";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +77,13 @@ import {
|
|||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
} from "@/utils/workspace-tab-identity";
|
} 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 { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
@@ -106,9 +122,16 @@ import {
|
|||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import {
|
import {
|
||||||
resolveWorkspaceHeaderRenderState,
|
resolveWorkspaceHeaderRenderState,
|
||||||
shouldRenderMissingWorkspaceDescriptor,
|
|
||||||
type WorkspaceHeaderCheckoutState,
|
type WorkspaceHeaderCheckoutState,
|
||||||
} from "@/screens/workspace/workspace-header-source";
|
} 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 {
|
import {
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
workspaceAgentVisibilityEqual,
|
workspaceAgentVisibilityEqual,
|
||||||
@@ -132,6 +155,7 @@ import { isAbsolutePath } from "@/utils/path";
|
|||||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||||
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||||
@@ -166,6 +190,7 @@ const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedCo
|
|||||||
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
|
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
|
||||||
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||||
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
||||||
|
const GATED_WORKSPACE_HEADER_LEFT = <SidebarMenuToggle />;
|
||||||
|
|
||||||
interface WorkspaceScreenProps {
|
interface WorkspaceScreenProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -971,7 +996,6 @@ function parsePaneDirection(actionId: string): PaneDirection | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RenderWorkspaceContentInput {
|
interface RenderWorkspaceContentInput {
|
||||||
showMissingWorkspaceDescriptor: boolean;
|
|
||||||
isMissingWorkspaceExecutionAuthority: boolean;
|
isMissingWorkspaceExecutionAuthority: boolean;
|
||||||
activeTabDescriptor: WorkspaceTabDescriptor | null;
|
activeTabDescriptor: WorkspaceTabDescriptor | null;
|
||||||
hasHydratedAgents: boolean;
|
hasHydratedAgents: boolean;
|
||||||
@@ -987,7 +1011,6 @@ interface RenderWorkspaceContentInput {
|
|||||||
|
|
||||||
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
|
function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.ReactNode {
|
||||||
const {
|
const {
|
||||||
showMissingWorkspaceDescriptor,
|
|
||||||
isMissingWorkspaceExecutionAuthority,
|
isMissingWorkspaceExecutionAuthority,
|
||||||
activeTabDescriptor,
|
activeTabDescriptor,
|
||||||
hasHydratedAgents,
|
hasHydratedAgents,
|
||||||
@@ -998,13 +1021,6 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
|||||||
buildMobilePaneContentModel,
|
buildMobilePaneContentModel,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
if (showMissingWorkspaceDescriptor) {
|
|
||||||
return (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isMissingWorkspaceExecutionAuthority) {
|
if (isMissingWorkspaceExecutionAuthority) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<ScreenHeader left={GATED_WORKSPACE_HEADER_LEFT} />
|
||||||
|
<View style={styles.centerContent}>{children}</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkspaceScreenGateShell(input: {
|
||||||
|
gate: ReactNode;
|
||||||
|
workspaceKey: string | null;
|
||||||
|
}): ReactElement | null {
|
||||||
|
if (!input.gate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceFocusProvider workspaceKey={input.workspaceKey}>
|
||||||
|
<View style={containerWithWorkspaceBackgroundStyle}>
|
||||||
|
<View style={styles.threePaneRow}>
|
||||||
|
<View style={styles.centerColumn}>
|
||||||
|
<WorkspaceScreenGateFrame>{input.gate}</WorkspaceScreenGateFrame>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</WorkspaceFocusProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDocumentTitleEffectSlot({
|
||||||
|
tab,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
isRouteFocused,
|
||||||
|
}: {
|
||||||
|
tab: WorkspaceTabDescriptor | null;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
isRouteFocused: boolean;
|
||||||
|
}) {
|
||||||
|
if (!isRouteFocused || !isWeb || !tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceTabPresentationResolver tab={tab} serverId={serverId} workspaceId={workspaceId}>
|
||||||
|
{(presentation) => (
|
||||||
|
<WorkspaceDocumentTitleEffect
|
||||||
|
label={presentation.label}
|
||||||
|
titleState={presentation.titleState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WorkspaceTabPresentationResolver>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -1169,12 +1349,11 @@ function WorkspaceScreenContent({
|
|||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
||||||
|
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
|
||||||
|
useWorkspaceRouteActions(normalizedServerId);
|
||||||
|
|
||||||
const workspaceTerminalScopeKey = useMemo(
|
const workspaceTerminalScopeKey = useMemo(
|
||||||
() =>
|
() => buildWorkspaceTerminalScopeKey(normalizedServerId, normalizedWorkspaceId),
|
||||||
normalizedServerId && normalizedWorkspaceId
|
|
||||||
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
|
||||||
: null,
|
|
||||||
[normalizedServerId, normalizedWorkspaceId],
|
[normalizedServerId, normalizedWorkspaceId],
|
||||||
);
|
);
|
||||||
useWorkspaceTerminalSessionRetention({
|
useWorkspaceTerminalSessionRetention({
|
||||||
@@ -1202,7 +1381,7 @@ function WorkspaceScreenContent({
|
|||||||
paneId?: string;
|
paneId?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const canCreateTerminalNow = useMemo(
|
const canCreateTerminalNow = useMemo(
|
||||||
() => Boolean(isRouteFocused && client && isConnected && workspaceDirectory),
|
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1351,7 +1530,7 @@ function WorkspaceScreenContent({
|
|||||||
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
|
}, [client, isConnected, isRouteFocused, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||||
|
|
||||||
const isCheckoutQueryEnabled = useMemo(
|
const isCheckoutQueryEnabled = useMemo(
|
||||||
() => Boolean(isRouteFocused && client && isConnected && workspaceDirectory),
|
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||||
);
|
);
|
||||||
const checkoutQuery = useQuery({
|
const checkoutQuery = useQuery({
|
||||||
@@ -1381,6 +1560,11 @@ function WorkspaceScreenContent({
|
|||||||
const hasHydratedAgents = useSessionStore(
|
const hasHydratedAgents = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
||||||
);
|
);
|
||||||
|
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspace: workspaceDescriptor,
|
||||||
|
hasHydratedWorkspaces,
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingTerminalCreateInput) {
|
if (!pendingTerminalCreateInput) {
|
||||||
return;
|
return;
|
||||||
@@ -2602,12 +2786,7 @@ function WorkspaceScreenContent({
|
|||||||
},
|
},
|
||||||
[buildPaneContentModel],
|
[buildPaneContentModel],
|
||||||
);
|
);
|
||||||
const showMissingWorkspaceDescriptor = shouldRenderMissingWorkspaceDescriptor({
|
|
||||||
workspace: workspaceDescriptor,
|
|
||||||
hasHydratedWorkspaces,
|
|
||||||
});
|
|
||||||
const content = renderWorkspaceContent({
|
const content = renderWorkspaceContent({
|
||||||
showMissingWorkspaceDescriptor,
|
|
||||||
isMissingWorkspaceExecutionAuthority,
|
isMissingWorkspaceExecutionAuthority,
|
||||||
activeTabDescriptor,
|
activeTabDescriptor,
|
||||||
hasHydratedAgents,
|
hasHydratedAgents,
|
||||||
@@ -2718,6 +2897,22 @@ function WorkspaceScreenContent({
|
|||||||
const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
|
const menuNewTerminalIcon = MENU_NEW_TERMINAL_ICON;
|
||||||
const menuCopyIcon = MENU_COPY_ICON;
|
const menuCopyIcon = MENU_COPY_ICON;
|
||||||
const menuSettingsIcon = MENU_SETTINGS_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(
|
const headerRight = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -2855,16 +3050,12 @@ function WorkspaceScreenContent({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const documentTitleEffectTab = useMemo(
|
|
||||||
() => (isRouteFocused && isWeb ? activeTabDescriptor : null),
|
|
||||||
[isRouteFocused, activeTabDescriptor],
|
|
||||||
);
|
|
||||||
const showScreenHeader = useMemo(
|
const showScreenHeader = useMemo(
|
||||||
() => !isFocusModeEnabled || isMobile,
|
() => shouldShowWorkspaceScreenHeader({ isFocusModeEnabled, isMobile }),
|
||||||
[isFocusModeEnabled, isMobile],
|
[isFocusModeEnabled, isMobile],
|
||||||
);
|
);
|
||||||
const showExplorerSidebar = useMemo(
|
const showExplorerSidebar = useMemo(
|
||||||
() => isRouteFocused && (!isFocusModeEnabled || isMobile),
|
() => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }),
|
||||||
[isRouteFocused, isFocusModeEnabled, isMobile],
|
[isRouteFocused, isFocusModeEnabled, isMobile],
|
||||||
);
|
);
|
||||||
const createTerminalDisabled = useMemo(
|
const createTerminalDisabled = useMemo(
|
||||||
@@ -2951,134 +3142,129 @@ function WorkspaceScreenContent({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
gatedWorkspaceScreen ?? (
|
||||||
<View style={containerStyle}>
|
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||||
{documentTitleEffectTab ? (
|
<View style={containerStyle}>
|
||||||
<WorkspaceTabPresentationResolver
|
<WorkspaceDocumentTitleEffectSlot
|
||||||
tab={documentTitleEffectTab}
|
tab={activeTabDescriptor}
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
>
|
isRouteFocused={isRouteFocused}
|
||||||
{(presentation) => (
|
/>
|
||||||
<WorkspaceDocumentTitleEffect
|
<View style={styles.threePaneRow}>
|
||||||
label={presentation.label}
|
<View style={styles.centerColumn}>
|
||||||
titleState={presentation.titleState}
|
{reconnectBanner}
|
||||||
/>
|
{showScreenHeader && (
|
||||||
)}
|
<ScreenHeader
|
||||||
</WorkspaceTabPresentationResolver>
|
onRowLayout={onHeaderLayout}
|
||||||
) : null}
|
left={
|
||||||
<View style={styles.threePaneRow}>
|
<>
|
||||||
<View style={styles.centerColumn}>
|
<SidebarMenuToggle />
|
||||||
{showScreenHeader && (
|
<WorkspaceHeaderTitleBar
|
||||||
<ScreenHeader
|
isLoading={isWorkspaceHeaderLoading}
|
||||||
onRowLayout={onHeaderLayout}
|
title={workspaceHeaderTitle}
|
||||||
left={
|
subtitle={workspaceHeaderSubtitle}
|
||||||
<>
|
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||||
<SidebarMenuToggle />
|
currentBranchName={currentBranchName}
|
||||||
<WorkspaceHeaderTitleBar
|
isGitCheckout={isGitCheckout}
|
||||||
isLoading={isWorkspaceHeaderLoading}
|
normalizedServerId={normalizedServerId}
|
||||||
title={workspaceHeaderTitle}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
subtitle={workspaceHeaderSubtitle}
|
showWorkspaceSetup={showWorkspaceSetup}
|
||||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
showCreateBrowserTab={showCreateBrowserTab}
|
||||||
currentBranchName={currentBranchName}
|
isMobile={isMobile}
|
||||||
isGitCheckout={isGitCheckout}
|
createTerminalDisabled={createTerminalDisabled}
|
||||||
normalizedServerId={normalizedServerId}
|
menuNewAgentIcon={menuNewAgentIcon}
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||||
showWorkspaceSetup={showWorkspaceSetup}
|
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||||
showCreateBrowserTab={showCreateBrowserTab}
|
menuCopyIcon={menuCopyIcon}
|
||||||
isMobile={isMobile}
|
menuSettingsIcon={menuSettingsIcon}
|
||||||
createTerminalDisabled={createTerminalDisabled}
|
onCreateDraftTab={handleCreateDraftTab}
|
||||||
menuNewAgentIcon={menuNewAgentIcon}
|
onCreateTerminal={handleCreateTerminal}
|
||||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
onCreateBrowser={handleCreateBrowserTab}
|
||||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||||
menuCopyIcon={menuCopyIcon}
|
onCopyBranchName={handleCopyBranchName}
|
||||||
menuSettingsIcon={menuSettingsIcon}
|
onOpenSetupTab={handleOpenSetupTab}
|
||||||
onCreateDraftTab={handleCreateDraftTab}
|
/>
|
||||||
onCreateTerminal={handleCreateTerminal}
|
</>
|
||||||
onCreateBrowser={handleCreateBrowserTab}
|
}
|
||||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
right={headerRight}
|
||||||
onCopyBranchName={handleCopyBranchName}
|
/>
|
||||||
onOpenSetupTab={handleOpenSetupTab}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
right={headerRight}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMobile ? (
|
|
||||||
<MobileWorkspaceTabSwitcher
|
|
||||||
tabs={tabs}
|
|
||||||
activeTabKey={activeTabKey}
|
|
||||||
activeTab={activeTabDescriptor}
|
|
||||||
tabSwitcherOptions={tabSwitcherOptions}
|
|
||||||
tabByKey={tabByKey}
|
|
||||||
normalizedServerId={normalizedServerId}
|
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
|
||||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
|
||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
|
||||||
onCopyAgentId={handleCopyAgentId}
|
|
||||||
onReloadAgent={handleReloadAgent}
|
|
||||||
onCloseTab={handleCloseTabById}
|
|
||||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
|
||||||
onCloseTabsBelow={handleCloseTabsToRight}
|
|
||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{shouldRenderDesktopPaneFallback ? (
|
|
||||||
<WorkspaceDesktopTabsRow
|
|
||||||
paneId={focusedPaneIdOrUndefined}
|
|
||||||
isFocused={isRouteFocused}
|
|
||||||
tabs={desktopTabRowItems}
|
|
||||||
normalizedServerId={normalizedServerId}
|
|
||||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
|
||||||
setHoveredTabKey={setHoveredTabKey}
|
|
||||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
|
||||||
onNavigateTab={navigateToTabId}
|
|
||||||
onCloseTab={handleCloseTabById}
|
|
||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
|
||||||
onCopyAgentId={handleCopyAgentId}
|
|
||||||
onReloadAgent={handleReloadAgent}
|
|
||||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
|
||||||
onCloseTabsToRight={handleCloseTabsToRight}
|
|
||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
|
||||||
onCreateDraftTab={handleCreateDraftTab}
|
|
||||||
onCreateTerminalTab={handleCreateTerminal}
|
|
||||||
onCreateBrowserTab={handleCreateBrowserTab}
|
|
||||||
showCreateBrowserTab={showCreateBrowserTab}
|
|
||||||
disableCreateTerminal={createTerminalMutation.isPending}
|
|
||||||
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
|
||||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
|
||||||
onSplitRight={noop}
|
|
||||||
onSplitDown={noop}
|
|
||||||
showPaneSplitActions={false}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.centerContent}>
|
|
||||||
{isMobile ? (
|
|
||||||
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
|
||||||
<View style={styles.content}>{content}</View>
|
|
||||||
</GestureDetector>
|
|
||||||
) : (
|
|
||||||
<View style={styles.content}>{desktopContent}</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{showExplorerSidebar && workspaceDirectory ? (
|
{isMobile ? (
|
||||||
<ExplorerSidebar
|
<MobileWorkspaceTabSwitcher
|
||||||
serverId={normalizedServerId}
|
tabs={tabs}
|
||||||
workspaceId={normalizedWorkspaceId}
|
activeTabKey={activeTabKey}
|
||||||
workspaceRoot={workspaceDirectory}
|
activeTab={activeTabDescriptor}
|
||||||
isGit={isGitCheckout}
|
tabSwitcherOptions={tabSwitcherOptions}
|
||||||
onOpenFile={handleOpenFileFromExplorer}
|
tabByKey={tabByKey}
|
||||||
/>
|
normalizedServerId={normalizedServerId}
|
||||||
) : null}
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||||
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
|
onCopyAgentId={handleCopyAgentId}
|
||||||
|
onReloadAgent={handleReloadAgent}
|
||||||
|
onCloseTab={handleCloseTabById}
|
||||||
|
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||||
|
onCloseTabsBelow={handleCloseTabsToRight}
|
||||||
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shouldRenderDesktopPaneFallback ? (
|
||||||
|
<WorkspaceDesktopTabsRow
|
||||||
|
paneId={focusedPaneIdOrUndefined}
|
||||||
|
isFocused={isRouteFocused}
|
||||||
|
tabs={desktopTabRowItems}
|
||||||
|
normalizedServerId={normalizedServerId}
|
||||||
|
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||||
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
|
onNavigateTab={navigateToTabId}
|
||||||
|
onCloseTab={handleCloseTabById}
|
||||||
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
|
onCopyAgentId={handleCopyAgentId}
|
||||||
|
onReloadAgent={handleReloadAgent}
|
||||||
|
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||||
|
onCloseTabsToRight={handleCloseTabsToRight}
|
||||||
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
|
onCreateDraftTab={handleCreateDraftTab}
|
||||||
|
onCreateTerminalTab={handleCreateTerminal}
|
||||||
|
onCreateBrowserTab={handleCreateBrowserTab}
|
||||||
|
showCreateBrowserTab={showCreateBrowserTab}
|
||||||
|
disableCreateTerminal={createTerminalMutation.isPending}
|
||||||
|
isWaitingOnTerminalReadiness={pendingTerminalCreateInput !== null}
|
||||||
|
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||||
|
onSplitRight={noop}
|
||||||
|
onSplitDown={noop}
|
||||||
|
showPaneSplitActions={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.centerContent}>
|
||||||
|
{isMobile ? (
|
||||||
|
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||||
|
<View style={styles.content}>{content}</View>
|
||||||
|
</GestureDetector>
|
||||||
|
) : (
|
||||||
|
<View style={styles.content}>{desktopContent}</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{showExplorerSidebar && workspaceDirectory ? (
|
||||||
|
<ExplorerSidebar
|
||||||
|
serverId={normalizedServerId}
|
||||||
|
workspaceId={normalizedWorkspaceId}
|
||||||
|
workspaceRoot={workspaceDirectory}
|
||||||
|
isGit={isGitCheckout}
|
||||||
|
onOpenFile={handleOpenFileFromExplorer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</WorkspaceFocusProvider>
|
||||||
</WorkspaceFocusProvider>
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export function activateNavigationWorkspaceSelection(
|
|||||||
next: ActiveWorkspaceSelection,
|
next: ActiveWorkspaceSelection,
|
||||||
options: ActivateWorkspaceSelectionOptions = {},
|
options: ActivateWorkspaceSelectionOptions = {},
|
||||||
) {
|
) {
|
||||||
|
setLastWorkspaceRouteSelection(next);
|
||||||
writeBrowserWorkspaceUrl(next, options);
|
writeBrowserWorkspaceUrl(next, options);
|
||||||
emitIfChanged(next);
|
emitIfChanged(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
vi.useRealTimers();
|
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 client = new MockLoadTestAgentClient();
|
||||||
|
|
||||||
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
|
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
|
||||||
@@ -26,12 +26,12 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
isDefault: true,
|
isDefault: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
durationMs: 300_000,
|
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();
|
vi.useFakeTimers();
|
||||||
const client = new MockLoadTestAgentClient();
|
const client = new MockLoadTestAgentClient();
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
@@ -65,39 +65,43 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
const timelineItems = events.flatMap((event): AgentTimelineItem[] =>
|
const timelineItems = events.flatMap((event): AgentTimelineItem[] =>
|
||||||
event.type === "timeline" ? [event.item] : [],
|
event.type === "timeline" ? [event.item] : [],
|
||||||
);
|
);
|
||||||
expect(timelineItems.find((item) => item.type === "reasoning")).toMatchObject({
|
|
||||||
type: "reasoning",
|
const assistantTokens = timelineItems.filter((item) => item.type === "assistant_message");
|
||||||
text: expect.stringContaining("Thinking chunk 1"),
|
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(
|
// Sequential tool calls fire: read, grep, edit, bash.
|
||||||
(item) =>
|
const toolNames = toolCalls
|
||||||
item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle 1\n"),
|
.filter((item) => item.type === "tool_call" && item.status === "running")
|
||||||
),
|
.map((item) => (item.type === "tool_call" ? item.name : ""));
|
||||||
).toHaveLength(1);
|
expect(toolNames.slice(0, 4)).toEqual(["read", "grep", "edit", "bash"]);
|
||||||
expect(
|
|
||||||
timelineItems.filter((item) => item.type === "assistant_message" && item.text.length > 0)
|
// Each tool transitions running → completed.
|
||||||
.length,
|
const completedNames = toolCalls
|
||||||
).toBeGreaterThan(4);
|
.filter((item) => item.type === "tool_call" && item.status === "completed")
|
||||||
expect(
|
.map((item) => (item.type === "tool_call" ? item.name : ""));
|
||||||
timelineItems.filter(
|
expect(completedNames).toContain("read");
|
||||||
(item) =>
|
expect(completedNames).toContain("grep");
|
||||||
item.type === "tool_call" &&
|
expect(completedNames).toContain("edit");
|
||||||
item.name === "edit" &&
|
expect(completedNames).toContain("bash");
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("interrupt cancels the active foreground turn and stops future chunks", async () => {
|
test("interrupt cancels the active foreground turn and stops future chunks", async () => {
|
||||||
@@ -127,7 +131,7 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
expect(events).toHaveLength(eventCountAfterInterrupt);
|
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();
|
vi.useFakeTimers();
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
||||||
try {
|
try {
|
||||||
@@ -155,20 +159,36 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
await resultPromise;
|
await resultPromise;
|
||||||
|
|
||||||
const timeline = manager.getTimeline(agent.id);
|
const timeline = manager.getTimeline(agent.id);
|
||||||
const loadCycleDocuments = timeline.filter(
|
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
const toolCalls = timeline.filter((item) => item.type === "tool_call");
|
||||||
item.type === "assistant_message" && item.text.startsWith("## Synthetic Load Cycle "),
|
|
||||||
);
|
|
||||||
const editToolCalls = timeline.filter(
|
|
||||||
(item) => item.type === "tool_call" && item.name === "edit",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(loadCycleDocuments).toHaveLength(40);
|
// The provider streams sub-word tokens; the coalescer batches them within
|
||||||
expect(loadCycleDocuments[0]?.text).toContain("This paragraph is intentionally stable");
|
// its flush window, so the timeline must contain materially fewer messages
|
||||||
expect(editToolCalls).toHaveLength(40);
|
// than the underlying token deltas would suggest, and each message must
|
||||||
expect(
|
// hold multiple tokens worth of text.
|
||||||
editToolCalls.every((item) => item.type === "tool_call" && item.status === "completed"),
|
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||||
).toBe(true);
|
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 {
|
} finally {
|
||||||
rmSync(workdir, { recursive: true, force: true });
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
|
ToolCallDetail,
|
||||||
ToolCallTimelineItem,
|
ToolCallTimelineItem,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import { getAgentProviderDefinition } from "../provider-manifest.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";
|
export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream";
|
||||||
const MOCK_LOAD_TEST_MODE_ID = "load-test";
|
const MOCK_LOAD_TEST_MODE_ID = "load-test";
|
||||||
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
|
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 = {
|
const CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -48,31 +49,42 @@ const MODELS: AgentModelDefinition[] = [
|
|||||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||||
label: "Five minute stream",
|
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,
|
isDefault: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
durationMs: MOCK_LOAD_TEST_DURATION_MS,
|
||||||
intervalMs: MOCK_LOAD_TEST_INTERVAL_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,
|
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||||
id: "one-minute-stream",
|
id: "one-minute-stream",
|
||||||
label: "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: {
|
metadata: {
|
||||||
durationMs: 60_000,
|
durationMs: 60_000,
|
||||||
intervalMs: 500,
|
intervalMs: 40,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||||
id: "ten-second-stream",
|
id: "ten-second-stream",
|
||||||
label: "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: {
|
metadata: {
|
||||||
durationMs: 10_000,
|
durationMs: 10_000,
|
||||||
intervalMs: 250,
|
intervalMs: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -81,14 +93,24 @@ interface ActiveTurn {
|
|||||||
turnId: string;
|
turnId: string;
|
||||||
prompt: AgentPromptInput;
|
prompt: AgentPromptInput;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
iteration: number;
|
cycle: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
timer: ReturnType<typeof setTimeout> | null;
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
resolve: (result: AgentRunResult) => void;
|
resolve: (result: AgentRunResult) => void;
|
||||||
completed: Promise<AgentRunResult>;
|
completed: Promise<AgentRunResult>;
|
||||||
|
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 {
|
interface LargeAgentStreamPayloadRequest {
|
||||||
bytes: number;
|
bytes: number;
|
||||||
kind: "diff" | "file" | "image";
|
kind: "diff" | "file" | "image";
|
||||||
@@ -175,47 +197,175 @@ function buildRepeatedPayload(bytes: number, prefix: string): string {
|
|||||||
return output.slice(0, bytes);
|
return output.slice(0, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMarkdownDocument(iteration: number, prompt: AgentPromptInput): string {
|
function tokenize(text: string): string[] {
|
||||||
const promptPreview = promptToText(prompt).slice(0, 160) || "No prompt text supplied.";
|
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 [
|
return [
|
||||||
`## Synthetic Load Cycle ${iteration}`,
|
`## Cycle ${cycle}`,
|
||||||
"",
|
|
||||||
`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.",
|
|
||||||
"",
|
"",
|
||||||
|
"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");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitText(text: string, chunks: number): string[] {
|
function buildReasoningText(): string {
|
||||||
const size = Math.ceil(text.length / chunks);
|
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.";
|
||||||
const parts: string[] = [];
|
}
|
||||||
for (let index = 0; index < text.length; index += size) {
|
|
||||||
parts.push(text.slice(index, index + size));
|
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<FlatList>(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<FlatList>(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: {
|
function createToolCall(input: {
|
||||||
turnId: string;
|
callId: string;
|
||||||
iteration: number;
|
|
||||||
name: string;
|
name: string;
|
||||||
status: ToolCallTimelineItem["status"];
|
status: ToolCallTimelineItem["status"];
|
||||||
detail: ToolCallTimelineItem["detail"];
|
detail: ToolCallDetail;
|
||||||
}): ToolCallTimelineItem {
|
}): ToolCallTimelineItem {
|
||||||
return {
|
return {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
callId: `${input.turnId}:${input.name}:${input.iteration}`,
|
callId: input.callId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -330,12 +480,15 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
turnId,
|
turnId,
|
||||||
prompt,
|
prompt,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
iteration: 0,
|
cycle: 0,
|
||||||
durationMs: profile.durationMs,
|
durationMs: profile.durationMs,
|
||||||
intervalMs: profile.intervalMs,
|
intervalMs: profile.intervalMs,
|
||||||
timer: null,
|
timer: null,
|
||||||
resolve,
|
resolve,
|
||||||
completed,
|
completed,
|
||||||
|
queue: [],
|
||||||
|
emittedTokens: 0,
|
||||||
|
turnStarted: false,
|
||||||
};
|
};
|
||||||
this.activeTurn = turn;
|
this.activeTurn = turn;
|
||||||
|
|
||||||
@@ -608,8 +761,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.emitTimeline(
|
this.emitTimeline(
|
||||||
turn.turnId,
|
turn.turnId,
|
||||||
createToolCall({
|
createToolCall({
|
||||||
turnId: turn.turnId,
|
callId: `${turn.turnId}:edit:large`,
|
||||||
iteration: 0,
|
|
||||||
name: "edit",
|
name: "edit",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: {
|
detail: {
|
||||||
@@ -623,8 +775,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.emitTimeline(
|
this.emitTimeline(
|
||||||
turn.turnId,
|
turn.turnId,
|
||||||
createToolCall({
|
createToolCall({
|
||||||
turnId: turn.turnId,
|
callId: `${turn.turnId}:read:large`,
|
||||||
iteration: 0,
|
|
||||||
name: "read",
|
name: "read",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: {
|
detail: {
|
||||||
@@ -669,7 +820,8 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearTurnTimer(turn);
|
this.clearTurnTimer(turn);
|
||||||
if (turn.iteration === 0) {
|
if (!turn.turnStarted) {
|
||||||
|
turn.turnStarted = true;
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "turn_started",
|
type: "turn_started",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -683,103 +835,71 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
turn.iteration += 1;
|
if (turn.queue.length === 0) {
|
||||||
this.emitIteration(turn);
|
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);
|
this.schedule(turn, turn.intervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitIteration(turn: ActiveTurn): void {
|
private dispatchCycleEvent(turn: ActiveTurn, event: CycleEvent): void {
|
||||||
const { turnId, iteration, prompt } = turn;
|
switch (event.kind) {
|
||||||
this.emitTimeline(turnId, {
|
case "assistant_token": {
|
||||||
type: "reasoning",
|
turn.emittedTokens += 1;
|
||||||
text: `Thinking chunk ${iteration}: simulate planning pressure before rendering markdown.\n`,
|
this.emitTimeline(turn.turnId, {
|
||||||
});
|
type: "assistant_message",
|
||||||
|
text: event.text,
|
||||||
for (const chunk of splitText(buildMarkdownDocument(iteration, prompt), 4)) {
|
});
|
||||||
this.emitTimeline(turnId, {
|
return;
|
||||||
type: "assistant_message",
|
}
|
||||||
text: chunk,
|
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 {
|
private finishTurn(turn: ActiveTurn): void {
|
||||||
this.emitTimeline(turn.turnId, {
|
this.emitTimeline(turn.turnId, {
|
||||||
type: "assistant_message",
|
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");
|
this.finishTurnWithText(turn, "Synthetic load test complete");
|
||||||
}
|
}
|
||||||
@@ -787,9 +907,9 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
private finishTurnWithText(turn: ActiveTurn, finalText: string): void {
|
private finishTurnWithText(turn: ActiveTurn, finalText: string): void {
|
||||||
this.activeTurn = null;
|
this.activeTurn = null;
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: turn.iteration * 32,
|
inputTokens: turn.cycle * 32,
|
||||||
outputTokens: turn.iteration * 128,
|
outputTokens: turn.emittedTokens,
|
||||||
contextWindowUsedTokens: turn.iteration * 160,
|
contextWindowUsedTokens: turn.emittedTokens * 2,
|
||||||
contextWindowMaxTokens: 128_000,
|
contextWindowMaxTokens: 128_000,
|
||||||
};
|
};
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
Reference in New Issue
Block a user