import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; import { ActivityIndicator, Pressable, type PressableStateCallbackType, ScrollView, Text, View, } from "react-native"; import invariant from "tiny-invariant"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import type { Theme } from "@/styles/theme"; import { useWorkspaceSetupStore, type WorkspaceSetupSnapshot, } from "@/stores/workspace-setup-store"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; function useSetupPanelDescriptor( target: { kind: "setup"; workspaceId: string }, context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const key = buildWorkspaceTabPersistenceKey({ serverId: context.serverId, workspaceId: target.workspaceId, }); const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); if (snapshot?.status === "completed") { return { label: "Setup", subtitle: "Setup completed", titleState: "ready", icon: CheckCircle2, statusBucket: null, }; } if (snapshot?.status === "failed") { return { label: "Setup", subtitle: "Setup failed", titleState: "ready", icon: CircleAlert, statusBucket: null, }; } return { label: "Setup", subtitle: "Workspace setup", titleState: "ready", icon: SquareTerminal, statusBucket: snapshot?.status === "running" ? "running" : null, }; } type CommandStatus = "running" | "completed" | "failed"; function CommandStatusIcon({ status }: { status: CommandStatus }) { if (status === "running") { return ; } if (status === "completed") { return ; } return ; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } /** * Process carriage returns in log text so progress-bar output renders cleanly. * Splits on \r, keeps only the last segment per CR-delimited group (unless followed by \n). */ function processCarriageReturns(text: string): string { if (!text.includes("\r")) return text; return text .split("\n") .map((line) => { if (!line.includes("\r")) return line; const segments = line.split("\r"); return segments[segments.length - 1]; }) .join("\n"); } type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number]; const EMPTY_COMMANDS: SetupCommand[] = []; function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null { const running = commands.find((c) => c.status === "running"); if (running) return running.index; if (commands.length > 0) return commands[commands.length - 1].index; return null; } function resolveSetupStatusLabel(status: string | undefined): string { if (status === "running") return "Running"; if (status === "completed") return "Completed"; if (status === "failed") return "Failed"; return "Waiting for setup output"; } function resolveCommandLog( command: SetupCommand, autoExpandIndex: number | null, log: string, ): string { if ("log" in command && typeof command.log === "string") { return command.log; } if (command.index === autoExpandIndex) return log; return ""; } interface BuildCommandRowPropsArgs { command: SetupCommand; autoExpandIndex: number | null; log: string; expandedIndices: Set; manuallyCollapsed: Set; snapshotError: string | null | undefined; } function buildCommandRowState(args: BuildCommandRowPropsArgs) { const { command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError } = args; const isExpanded = expandedIndices.has(command.index); const hasError = command.status === "failed" && Boolean(snapshotError); const commandLog = resolveCommandLog(command, autoExpandIndex, log); const hasLog = commandLog.trim().length > 0; const isExpandable = command.status !== "running" || hasLog || hasError; const isAutoExpanded = command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); const showDetail = isExpanded || isAutoExpanded; const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog }; } function SetupPanel() { const { serverId, target } = usePaneContext(); invariant(target.kind === "setup", "SetupPanel requires setup target"); const client = useHostRuntimeClient(serverId); const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId: target.workspaceId, }); const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // On mount, if no snapshot in the store, request cached status from server const requestedRef = useRef(false); useEffect(() => { if (snapshot || requestedRef.current || !client) return; requestedRef.current = true; client .fetchWorkspaceSetupStatus(target.workspaceId) .then((response) => { if (response.snapshot) { upsertProgress({ serverId, payload: { workspaceId: response.workspaceId, ...response.snapshot }, }); } return; }) .catch(() => { // Server may not support this yet — ignore }); }, [client, snapshot, serverId, target.workspaceId, upsertProgress]); const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS; const log = snapshot?.detail.log ?? ""; const hasNoSetupCommands = snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0); const [expandedIndices, setExpandedIndices] = useState>(new Set()); const [manuallyCollapsed, setManuallyCollapsed] = useState>(new Set()); const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => { setExpandedIndices((prev) => { const next = new Set(prev); if (next.has(index) || isAutoExpanded) { next.delete(index); // If this was auto-expanded, record that the user manually collapsed it if (isAutoExpanded) { setManuallyCollapsed((mc) => new Set(mc).add(index)); } } else { next.add(index); // If the user re-expands, remove from manually collapsed setManuallyCollapsed((mc) => { const updated = new Set(mc); updated.delete(index); return updated; }); } return next; }); }, []); const autoExpandIndex = resolveAutoExpandIndex(commands); const statusLabel = resolveSetupStatusLabel(snapshot?.status); return ( {/* Hidden element for status — preserves testID for E2E */} {statusLabel} {isWaiting ? ( Setting up workspace... ) : null} {!isWaiting && hasNoSetupCommands ? ( No setup commands ran for this workspace. ) : null} {!isWaiting && !hasNoSetupCommands ? ( {commands.map((command) => { const rowState = buildCommandRowState({ command, autoExpandIndex, log, expandedIndices, manuallyCollapsed, snapshotError: snapshot?.error, }); return ( ); })} ) : null} ); } interface SetupCommandRowProps { command: SetupCommand; showDetail: boolean; isAutoExpanded: boolean; isExpandable: boolean; hasLog: boolean; hasError: boolean; processedLog: string; errorMessage: string | null; onToggle: (index: number, isAutoExpanded: boolean) => void; } function SetupCommandRow({ command, showDetail, isAutoExpanded, isExpandable, hasLog, hasError, processedLog, errorMessage, onToggle, }: SetupCommandRowProps) { const handlePress = useCallback(() => { if (!isExpandable) return; onToggle(command.index, isAutoExpanded); }, [command.index, isAutoExpanded, isExpandable, onToggle]); const pressableStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.commandRow, showDetail && styles.commandRowExpanded, pressed && styles.commandRowPressed, ], [showDetail], ); const accessibilityState = useMemo(() => ({ expanded: showDetail }), [showDetail]); return ( {command.command} {command.durationMs != null ? ( {formatDuration(command.durationMs)} ) : null} {showDetail ? ( {hasLog ? ( {processedLog} ) : ( No output )} {hasError && errorMessage ? ( {errorMessage} ) : null} ) : null} ); } export const setupPanelRegistration: PanelRegistration<"setup"> = { kind: "setup", component: SetupPanel, useDescriptor: useSetupPanelDescriptor, }; function SetupCommandChevron({ showDetail }: { showDetail: boolean }) { const chevronStyle = useMemo( () => [styles.chevron, showDetail && styles.chevronExpanded], [showDetail], ); return ( ); } function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: string }) { if (commands.length !== 0 || log.trim().length === 0) return null; return ( {log} ); } function TopLevelSetupError({ snapshotError, commands, }: { snapshotError: string | null; commands: SetupCommand[]; }) { if (!snapshotError) return null; if (commands.some((c) => c.status === "failed")) return null; return ( {snapshotError} ); } const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCheckCircle2 = withUnistyles(CheckCircle2); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedChevronRight = withUnistyles(ChevronRight); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500], }); const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500], }); const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surface0, }, contentContainer: { padding: theme.spacing[4], flexGrow: 1, }, hiddenStatus: { position: "absolute", width: 1, height: 1, overflow: "hidden", opacity: 0, }, waitingContainer: { flex: 1, alignItems: "center", justifyContent: "center", gap: theme.spacing[3], }, waitingText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, emptyContainer: { flex: 1, alignItems: "center", justifyContent: "center", }, emptyText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, commandList: { gap: theme.spacing[2], }, commandItem: { borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, overflow: "hidden", }, commandRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, }, commandRowExpanded: { borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, }, commandRowPressed: { opacity: 0.8, }, commandStatusIcon: { width: 18, height: 18, alignItems: "center", justifyContent: "center", flexShrink: 0, }, commandText: { flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, commandDuration: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, flexShrink: 0, }, chevron: { flexShrink: 0, }, chevronExpanded: { transform: [{ rotate: "90deg" }], }, commandDetail: { backgroundColor: theme.colors.surface0, }, logScroll: { maxHeight: 400, }, logScrollContent: { padding: theme.spacing[3], }, logText: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, lineHeight: 20, color: theme.colors.foreground, }, emptyLogText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, fontStyle: "italic", }, errorCard: { padding: theme.spacing[3], backgroundColor: theme.colors.palette.red[100], }, errorText: { fontSize: theme.fontSize.sm, color: theme.colors.palette.red[800], }, }));