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.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 },
|
||||
|
||||
@@ -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<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.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 ({
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 { 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 = <ThemedSquareTerminal size={16} uniProps={mutedCo
|
||||
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
||||
const GATED_WORKSPACE_HEADER_LEFT = <SidebarMenuToggle />;
|
||||
|
||||
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 (
|
||||
<View style={styles.emptyState}>
|
||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isMissingWorkspaceExecutionAuthority) {
|
||||
return (
|
||||
<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({
|
||||
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 (
|
||||
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||
<View style={containerStyle}>
|
||||
{documentTitleEffectTab ? (
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={documentTitleEffectTab}
|
||||
gatedWorkspaceScreen ?? (
|
||||
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||
<View style={containerStyle}>
|
||||
<WorkspaceDocumentTitleEffectSlot
|
||||
tab={activeTabDescriptor}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => (
|
||||
<WorkspaceDocumentTitleEffect
|
||||
label={presentation.label}
|
||||
titleState={presentation.titleState}
|
||||
/>
|
||||
)}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
) : null}
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<WorkspaceHeaderTitleBar
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
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>
|
||||
isRouteFocused={isRouteFocused}
|
||||
/>
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{reconnectBanner}
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
left={
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
<WorkspaceHeaderTitleBar
|
||||
isLoading={isWorkspaceHeaderLoading}
|
||||
title={workspaceHeaderTitle}
|
||||
subtitle={workspaceHeaderSubtitle}
|
||||
showSubtitle={shouldShowWorkspaceHeaderSubtitle}
|
||||
currentBranchName={currentBranchName}
|
||||
isGitCheckout={isGitCheckout}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
showWorkspaceSetup={showWorkspaceSetup}
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={headerRight}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showExplorerSidebar && workspaceDirectory ? (
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
) : null}
|
||||
{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 ? (
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</WorkspaceFocusProvider>
|
||||
</WorkspaceFocusProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ export function activateNavigationWorkspaceSelection(
|
||||
next: ActiveWorkspaceSelection,
|
||||
options: ActivateWorkspaceSelectionOptions = {},
|
||||
) {
|
||||
setLastWorkspaceRouteSelection(next);
|
||||
writeBrowserWorkspaceUrl(next, options);
|
||||
emitIfChanged(next);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user