Merge branch 'investigate/portless' into dev

# Conflicts:
#	packages/app/e2e/helpers/workspace-setup.ts
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/contexts/session-context.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.test.ts
#	packages/app/src/screens/workspace/workspace-tab-menu.ts
#	packages/app/src/stores/workspace-setup-store.ts
#	packages/app/src/stores/workspace-tabs-store.ts
#	packages/app/src/utils/sidebar-project-row-model.test.ts
#	packages/app/src/utils/sidebar-shortcuts.test.ts
#	packages/app/src/utils/workspace-tab-identity.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/worktree-session.ts
This commit is contained in:
Mohamed Boudra
2026-04-01 22:06:39 +07:00
52 changed files with 5855 additions and 354 deletions
@@ -3,6 +3,7 @@ import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { launcherPanelRegistration } from "@/panels/launcher-panel";
import { registerPanel } from "@/panels/panel-registry";
import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
let panelsRegistered = false;
@@ -13,6 +14,7 @@ export function ensurePanelsRegistered(): void {
}
registerPanel(draftPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(filePanelRegistration);
registerPanel(launcherPanelRegistration);
+444
View File
@@ -0,0 +1,444 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { ActivityIndicator, Pressable, 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";
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 }) {
const { theme } = useUnistyles();
if (status === "running") {
return <ActivityIndicator size={14} color={theme.colors.foreground} />;
}
if (status === "completed") {
return <CheckCircle2 size={14} color={theme.colors.palette.green[500]} />;
}
return <CircleAlert size={14} color={theme.colors.palette.red[500]} />;
}
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");
}
function SetupPanel() {
const { theme } = useUnistyles();
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 },
});
}
})
.catch(() => {
// Server may not support this yet — ignore
});
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
const commands = snapshot?.detail.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<Set<number>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(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 next = new Set(mc);
next.delete(index);
return next;
});
}
return next;
});
}, []);
// Determine which command should auto-expand (running or last completed).
const autoExpandIndex = (() => {
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;
})();
const statusLabel = snapshot?.status === "running"
? "Running"
: snapshot?.status === "completed"
? "Completed"
: snapshot?.status === "failed"
? "Failed"
: "Waiting for setup output";
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
testID="workspace-setup-panel"
>
{/* Hidden element for status — preserves testID for E2E */}
<Text
style={styles.hiddenStatus}
testID="workspace-setup-status"
>{statusLabel}</Text>
{isWaiting ? (
<View style={styles.waitingContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.waitingText}>Setting up workspace...</Text>
</View>
) : hasNoSetupCommands ? (
<View style={styles.emptyContainer}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel="No setup commands ran for this workspace"
>
No setup commands ran for this workspace.
</Text>
</View>
) : (
<View style={styles.commandList}>
{commands.map((command) => {
const isExpanded = expandedIndices.has(command.index);
const hasError = command.status === "failed" && snapshot?.error;
// Per-command log: use command.log if available, fall back to detail.log for the auto-expand target
const commandLog = (() => {
if ("log" in command && typeof command.log === "string") {
return command.log;
}
// Fallback: show detail.log on the auto-expand target command
if (command.index === autoExpandIndex) return log;
return "";
})();
const hasLog = commandLog.trim().length > 0;
// All non-running commands are expandable (completed/failed)
const isExpandable = command.status !== "running" || hasLog || !!hasError;
// Auto-expand the active command unless the user manually collapsed it
const isAutoExpanded =
command.index === autoExpandIndex && !manuallyCollapsed.has(command.index);
const showDetail = isExpanded || isAutoExpanded;
const processedLog = hasLog ? processCarriageReturns(commandLog) : "";
return (
<View key={`${command.index}:${command.command}`} style={styles.commandItem}>
<Pressable
onPress={() => toggleExpanded(command.index, isAutoExpanded)}
style={({ pressed }) => [
styles.commandRow,
showDetail && styles.commandRowExpanded,
pressed && styles.commandRowPressed,
]}
accessibilityRole="button"
accessibilityState={{ expanded: showDetail }}
>
<View style={styles.commandStatusIcon}>
<CommandStatusIcon status={command.status} />
</View>
<Text style={styles.commandText} numberOfLines={1}>
{command.command}
</Text>
{command.durationMs != null ? (
<Text style={styles.commandDuration}>
{formatDuration(command.durationMs)}
</Text>
) : null}
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
style={[
styles.chevron,
showDetail && styles.chevronExpanded,
]}
/>
</Pressable>
{showDetail ? (
<View style={styles.commandDetail}>
{hasLog ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
horizontal={false}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
) : (
<View
style={styles.logScrollContent}
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text style={styles.emptyLogText}>No output</Text>
</View>
)}
{hasError ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{snapshot.error}
</Text>
</View>
) : null}
</View>
) : null}
</View>
);
})}
{/* If there's log but no commands yet, or log without a target command, show standalone */}
{commands.length === 0 && log.trim().length > 0 ? (
<ScrollView
style={styles.logScroll}
contentContainerStyle={styles.logScrollContent}
showsVerticalScrollIndicator
testID="workspace-setup-log"
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable style={styles.logText}>
{log}
</Text>
</ScrollView>
) : null}
{/* Show error at top level if no commands failed but there's a setup error */}
{snapshot?.error && !commands.some((c) => c.status === "failed") ? (
<View style={styles.errorCard}>
<Text selectable style={styles.errorText}>
{snapshot.error}
</Text>
</View>
) : null}
</View>
)}
</ScrollView>
);
}
export const setupPanelRegistration: PanelRegistration<"setup"> = {
kind: "setup",
component: SetupPanel,
useDescriptor: useSetupPanelDescriptor,
};
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,
fontFamily: Fonts.mono,
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: Fonts.mono,
fontSize: theme.fontSize.sm,
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],
},
}));