Add workspace setup streaming and setup tab

This commit is contained in:
Mohamed Boudra
2026-03-31 09:09:38 +07:00
parent 266d042528
commit 1f6ffa9e0e
28 changed files with 1645 additions and 165 deletions
@@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry";
import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
let panelsRegistered = false;
@@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void {
}
registerPanel(draftPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(filePanelRegistration);
panelsRegistered = true;
+309
View File
@@ -0,0 +1,309 @@
import { CheckCircle2, CircleAlert, SquareTerminal } from "lucide-react-native";
import { ScrollView, Text, View } from "react-native";
import invariant from "tiny-invariant";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
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,
};
}
function formatCommandStatus(status: "running" | "completed" | "failed"): string {
if (status === "running") {
return "Running";
}
if (status === "completed") {
return "Completed";
}
return "Failed";
}
function formatSetupStatus(status: "running" | "completed" | "failed" | null): string {
if (status === "running") {
return "Running";
}
if (status === "completed") {
return "Completed";
}
if (status === "failed") {
return "Failed";
}
return "Waiting for setup output";
}
function SetupPanel() {
const { theme } = useUnistyles();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const key = buildWorkspaceTabPersistenceKey({
serverId,
workspaceId: target.workspaceId,
});
const snapshot = useWorkspaceSetupStore((state) => (key ? state.snapshots[key] ?? null : null));
const commands = snapshot?.detail.commands ?? [];
const log = snapshot?.detail.log ?? "";
const statusLabel = formatSetupStatus(snapshot?.status ?? null);
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
return (
<View style={styles.container} testID="workspace-setup-panel">
<View
style={styles.header}
accessible
accessibilityLabel={`Workspace setup status: ${statusLabel}`}
testID="workspace-setup-status"
>
<Text style={styles.title}>Workspace setup</Text>
<View
style={[
styles.statusBadge,
snapshot?.status === "completed" && {
backgroundColor: theme.colors.palette.green[100],
},
snapshot?.status === "failed" && {
backgroundColor: theme.colors.palette.red[100],
},
]}
>
<Text
style={[
styles.statusBadgeText,
snapshot?.status === "completed" && {
color: theme.colors.palette.green[600],
},
snapshot?.status === "failed" && {
color: theme.colors.palette.red[600],
},
]}
>
{statusLabel}
</Text>
</View>
</View>
{snapshot?.error ? (
<View style={styles.errorCard}>
<Text style={styles.errorTitle}>Setup error</Text>
<Text selectable style={styles.errorBody}>
{snapshot.error}
</Text>
</View>
) : null}
{commands.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Commands</Text>
<View style={styles.commandList}>
{commands.map((command) => (
<View key={`${command.index}:${command.command}`} style={styles.commandRow}>
<Text style={styles.commandIndex}>{command.index}.</Text>
<View style={styles.commandTextColumn}>
<Text selectable style={styles.commandText}>
{command.command}
</Text>
<Text style={styles.commandMeta}>
{formatCommandStatus(command.status)}
{typeof command.exitCode === "number" ? ` · exit ${command.exitCode}` : ""}
</Text>
</View>
</View>
))}
</View>
</View>
) : null}
<View style={styles.sectionFill}>
<Text style={styles.sectionTitle}>Log</Text>
{hasNoSetupCommands ? (
<View style={styles.emptyCard}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel="No setup commands ran for this workspace"
>
No setup commands ran for this workspace.
</Text>
</View>
) : (
<ScrollView
style={styles.logContainer}
contentContainerStyle={styles.logContent}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{log.trim().length > 0 ? log : "Waiting for setup output..."}
</Text>
</ScrollView>
)}
</View>
</View>
);
}
export const setupPanelRegistration: PanelRegistration<"setup"> = {
kind: "setup",
component: SetupPanel,
useDescriptor: useSetupPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
padding: theme.spacing[4],
gap: theme.spacing[4],
backgroundColor: theme.colors.surface0,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
},
title: {
fontSize: theme.fontSize.lg,
fontWeight: "600",
color: theme.colors.foreground,
},
statusBadge: {
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
backgroundColor: theme.colors.surface2,
},
statusBadgeText: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.foregroundMuted,
},
errorCard: {
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.palette.red[200],
backgroundColor: theme.colors.palette.red[100],
padding: theme.spacing[3],
gap: theme.spacing[2],
},
errorTitle: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.palette.red[800],
},
errorBody: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.red[800],
},
section: {
gap: theme.spacing[2],
},
sectionFill: {
flex: 1,
minHeight: 0,
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: "600",
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
},
commandList: {
gap: theme.spacing[2],
},
commandRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
},
commandIndex: {
width: 18,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
commandTextColumn: {
flex: 1,
gap: theme.spacing[1],
},
commandText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
commandMeta: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
logContainer: {
flex: 1,
minHeight: 0,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
},
logContent: {
padding: theme.spacing[3],
},
logText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: 20,
color: theme.colors.foreground,
},
emptyCard: {
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[3],
},
emptyText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}));