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:
@@ -31,6 +31,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
if (openIntent.kind === "setup") {
|
||||
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||
}
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
FolderPlus,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
@@ -90,6 +91,7 @@ import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -839,122 +841,132 @@ function WorkspaceRowInner({
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showGlobe = isDesktop && workspace.hasRunningServices;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
<WorkspaceHoverCard workspace={workspace} isDragging={isDragging}>
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
style={({ pressed }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{showGlobe ? (
|
||||
<View testID="workspace-globe-icon" accessibilityLabel="Services available">
|
||||
<Globe size={12} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
</WorkspaceHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, FolderGit2, GitPullRequest, Monitor } from "lucide-react-native";
|
||||
import { Pressable } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureElement(element: View): Promise<Rect> {
|
||||
return new Promise((resolve) => {
|
||||
element.measureInWindow((x, y, width, height) => {
|
||||
resolve({ x, y, width, height });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function computeHoverCardPosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
offset,
|
||||
}: {
|
||||
triggerRect: Rect;
|
||||
contentSize: { width: number; height: number };
|
||||
displayArea: Rect;
|
||||
offset: number;
|
||||
}): { x: number; y: number } {
|
||||
let x = triggerRect.x + triggerRect.width + offset;
|
||||
let y = triggerRect.y;
|
||||
|
||||
// If it overflows right, try left
|
||||
if (x + contentSize.width > displayArea.width - 8) {
|
||||
x = triggerRect.x - contentSize.width - offset;
|
||||
}
|
||||
|
||||
// Constrain to screen
|
||||
const padding = 8;
|
||||
x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x));
|
||||
y = Math.max(
|
||||
displayArea.y + padding,
|
||||
Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y),
|
||||
);
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const HOVER_GRACE_MS = 100;
|
||||
const HOVER_CARD_WIDTH = 260;
|
||||
|
||||
interface WorkspaceHoverCardProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceHoverCard({
|
||||
workspace,
|
||||
isDragging,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
// Desktop-only: skip on non-web platforms
|
||||
if (Platform.OS !== "web") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceHoverCardDesktop workspace={workspace} isDragging={isDragging}>
|
||||
{children}
|
||||
</WorkspaceHoverCardDesktop>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardDesktop({
|
||||
workspace,
|
||||
isDragging,
|
||||
children,
|
||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const triggerHoveredRef = useRef(false);
|
||||
const contentHoveredRef = useRef(false);
|
||||
|
||||
const hasServices = workspace.services.length > 0;
|
||||
|
||||
const clearGraceTimer = useCallback(() => {
|
||||
if (graceTimerRef.current) {
|
||||
clearTimeout(graceTimerRef.current);
|
||||
graceTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearGraceTimer();
|
||||
graceTimerRef.current = setTimeout(() => {
|
||||
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
|
||||
setOpen(false);
|
||||
}
|
||||
graceTimerRef.current = null;
|
||||
}, HOVER_GRACE_MS);
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const handleTriggerEnter = useCallback(() => {
|
||||
triggerHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
if (!isDragging && hasServices) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [clearGraceTimer, isDragging, hasServices]);
|
||||
|
||||
const handleTriggerLeave = useCallback(() => {
|
||||
triggerHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
|
||||
const handleContentEnter = useCallback(() => {
|
||||
contentHoveredRef.current = true;
|
||||
clearGraceTimer();
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
const handleContentLeave = useCallback(() => {
|
||||
contentHoveredRef.current = false;
|
||||
scheduleClose();
|
||||
}, [scheduleClose]);
|
||||
|
||||
// Close when drag starts
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
clearGraceTimer();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [isDragging, clearGraceTimer]);
|
||||
|
||||
// When hasServices becomes true while trigger is already hovered, open the card.
|
||||
useEffect(() => {
|
||||
if (!hasServices || isDragging) return;
|
||||
if (triggerHoveredRef.current) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [hasServices, isDragging]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearGraceTimer();
|
||||
};
|
||||
}, [clearGraceTimer]);
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={triggerRef}
|
||||
collapsable={false}
|
||||
onPointerEnter={handleTriggerEnter}
|
||||
onPointerLeave={handleTriggerLeave}
|
||||
>
|
||||
{children}
|
||||
{open && hasServices ? (
|
||||
<WorkspaceHoverCardContent
|
||||
workspace={workspace}
|
||||
triggerRef={triggerRef}
|
||||
onContentEnter={handleContentEnter}
|
||||
onContentLeave={handleContentLeave}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
open: "Open",
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
};
|
||||
|
||||
function HoverCardStatusIndicator({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const showSyncedLoader = shouldRenderSyncedStatusLoader({ bucket: workspace.statusBucket });
|
||||
|
||||
if (showSyncedLoader) {
|
||||
return <SyncedLoader size={11} color={theme.colors.palette.amber[500]} />;
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspace.workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspace.workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
if (!KindIcon) return null;
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket: workspace.statusBucket, showDoneAsInactive: false });
|
||||
|
||||
return (
|
||||
<View style={styles.hoverStatusIcon}>
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.hoverStatusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceHoverCardContent({
|
||||
workspace,
|
||||
triggerRef,
|
||||
onContentEnter,
|
||||
onContentLeave,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
triggerRef: React.RefObject<View | null>;
|
||||
onContentEnter: () => void;
|
||||
onContentLeave: () => void;
|
||||
}): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.workspaceKind !== "directory",
|
||||
});
|
||||
|
||||
// Measure trigger — same pattern as tooltip.tsx
|
||||
useEffect(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect(rect);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [triggerRef]);
|
||||
|
||||
// Compute position when both measurements are available
|
||||
useEffect(() => {
|
||||
if (!triggerRect || !contentSize) return;
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
|
||||
const result = computeHoverCardPosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
displayArea,
|
||||
offset: 4,
|
||||
});
|
||||
setPosition(result);
|
||||
}, [triggerRect, contentSize]);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setContentSize({ width, height });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
onLayout={handleLayout}
|
||||
onPointerEnter={onContentEnter}
|
||||
onPointerLeave={onContentLeave}
|
||||
accessibilityRole="menu"
|
||||
accessibilityLabel="Workspace services"
|
||||
testID="workspace-hover-card"
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
width: HOVER_CARD_WIDTH,
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<HoverCardStatusIndicator workspace={workspace} />
|
||||
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
{workspace.diffStat ? (
|
||||
<View style={styles.cardMetaRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{prHint ? (
|
||||
<Pressable
|
||||
style={styles.cardMetaRow}
|
||||
onPress={() => void openExternalUrl(prHint.url)}
|
||||
>
|
||||
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.prBadgeText} numberOfLines={1}>
|
||||
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.separator} />
|
||||
<View style={styles.serviceList} testID="hover-card-service-list">
|
||||
{workspace.services.map((service) => (
|
||||
<Pressable
|
||||
key={service.hostname}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${service.serviceName} service`}
|
||||
testID={`hover-card-service-${service.serviceName}`}
|
||||
style={({ hovered }) => [
|
||||
styles.serviceRow,
|
||||
hovered && styles.serviceRowHovered,
|
||||
]}
|
||||
onPress={() => {
|
||||
if (service.url) {
|
||||
void openExternalUrl(service.url);
|
||||
}
|
||||
}}
|
||||
disabled={!service.url}
|
||||
>
|
||||
<View
|
||||
testID={`hover-card-service-status-${service.serviceName}`}
|
||||
accessibilityLabel={service.status === "running" ? "Running" : "Stopped"}
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor:
|
||||
service.status === "running"
|
||||
? theme.colors.palette.green[500]
|
||||
: theme.colors.palette.red[500],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.serviceName} numberOfLines={1}>
|
||||
{service.serviceName}
|
||||
</Text>
|
||||
{service.url ? (
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
portalOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
zIndex: 1000,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
cardTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
cardMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
prBadgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
hoverStatusIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
},
|
||||
hoverStatusDotOverlay: {
|
||||
position: "absolute",
|
||||
bottom: -1,
|
||||
right: -1,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
borderWidth: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
serviceList: {
|
||||
paddingTop: theme.spacing[1],
|
||||
},
|
||||
serviceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 32,
|
||||
},
|
||||
serviceRowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
serviceName: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceServicePayload } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { patchWorkspaceServices } from "./session-workspace-services";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
services?: WorkspaceDescriptor["services"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const runningService: WorkspaceServicePayload = {
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
describe("patchWorkspaceServices", () => {
|
||||
it("patches only the matching workspace services", () => {
|
||||
const other = workspace({ id: "/repo/other", services: [] });
|
||||
const current = new Map<string, WorkspaceDescriptor>([
|
||||
["/repo/main", workspace({ id: "/repo/main", services: [] })],
|
||||
[other.id, other],
|
||||
]);
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
workspaceId: "/repo/main",
|
||||
services: [runningService],
|
||||
});
|
||||
|
||||
expect(next).not.toBe(current);
|
||||
expect(next.get("/repo/main")?.services).toEqual([runningService]);
|
||||
expect(next.get("/repo/other")).toBe(other);
|
||||
});
|
||||
|
||||
it("ignores updates for unknown workspaces", () => {
|
||||
const current = new Map<string, WorkspaceDescriptor>([
|
||||
["/repo/main", workspace({ id: "/repo/main", services: [] })],
|
||||
]);
|
||||
|
||||
const next = patchWorkspaceServices(current, {
|
||||
workspaceId: "/repo/missing",
|
||||
services: [runningService],
|
||||
});
|
||||
|
||||
expect(next).toBe(current);
|
||||
expect(next.get("/repo/main")?.services).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
@@ -50,6 +51,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceServices } from "@/contexts/session-workspace-services";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -159,6 +161,10 @@ type WorkspaceUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
>["payload"];
|
||||
type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
const getAgentIdFromUpdate = (update: AgentUpdatePayload): string =>
|
||||
update.kind === "remove" ? update.agentId : update.agent.id;
|
||||
@@ -264,6 +270,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace);
|
||||
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const focusedAgentId = useSessionStore(
|
||||
@@ -747,6 +756,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
],
|
||||
);
|
||||
|
||||
const applyWorkspaceSetupProgress = useCallback(
|
||||
(payload: WorkspaceSetupProgressPayload) => {
|
||||
upsertWorkspaceSetupProgress({ serverId, payload });
|
||||
},
|
||||
[serverId, upsertWorkspaceSetupProgress],
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { endSeq: number }) => {
|
||||
void client
|
||||
@@ -1083,12 +1099,34 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
});
|
||||
|
||||
const unsubServiceStatusUpdate = client.on("service_status_update", (message) => {
|
||||
if (message.type !== "service_status_update") return;
|
||||
setWorkspaces(serverId, (prev) => patchWorkspaceServices(prev, message.payload));
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
|
||||
if (message.type !== "workspace_setup_progress") return;
|
||||
applyWorkspaceSetupProgress(message.payload);
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupStatusResponse = client.on(
|
||||
"workspace_setup_status_response",
|
||||
(message) => {
|
||||
if (message.type !== "workspace_setup_status_response") return;
|
||||
const { workspaceId, snapshot } = message.payload;
|
||||
if (snapshot) {
|
||||
applyWorkspaceSetupProgress({ workspaceId, ...snapshot });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
@@ -1437,6 +1475,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubServiceStatusUpdate();
|
||||
unsubWorkspaceSetupProgress();
|
||||
unsubWorkspaceSetupStatusResponse();
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
@@ -1462,8 +1503,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgentTimelineCursor,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
setWorkspaces,
|
||||
mergeWorkspaces,
|
||||
removeWorkspace,
|
||||
removeWorkspaceSetup,
|
||||
setAgentLastActivity,
|
||||
setPendingPermissions,
|
||||
setHasHydratedAgents,
|
||||
@@ -1471,6 +1514,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
notifyAgentAttention,
|
||||
requestCanonicalCatchUp,
|
||||
applyAgentUpdatePayload,
|
||||
applyWorkspaceSetupProgress,
|
||||
applyTimelineResponse,
|
||||
voiceRuntime,
|
||||
voiceAudioEngine,
|
||||
@@ -1674,9 +1718,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearWorkspaceSetupServer(serverId);
|
||||
clearSession(serverId);
|
||||
};
|
||||
}, [clearSession, serverId]);
|
||||
}, [clearSession, clearWorkspaceSetupServer, serverId]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ServiceStatusUpdateMessage } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export function patchWorkspaceServices(
|
||||
workspaces: Map<string, WorkspaceDescriptor>,
|
||||
update: ServiceStatusUpdateMessage["payload"],
|
||||
): Map<string, WorkspaceDescriptor> {
|
||||
const existing = workspaces.get(update.workspaceId);
|
||||
if (!existing) {
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
const next = new Map(workspaces);
|
||||
next.set(update.workspaceId, {
|
||||
...existing,
|
||||
services: update.services.map((s) => ({ ...s })),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceServicePayload } from "@server/shared/messages";
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
@@ -24,6 +25,7 @@ function workspace(
|
||||
| "workspaceDirectory"
|
||||
| "projectKind"
|
||||
| "workspaceKind"
|
||||
| "services"
|
||||
>
|
||||
>,
|
||||
): WorkspaceDescriptor {
|
||||
@@ -39,9 +41,26 @@ function workspace(
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
diffStat: null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const runningService: WorkspaceServicePayload = {
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
const stoppedService: WorkspaceServicePayload = {
|
||||
serviceName: "api",
|
||||
hostname: "main.api.localhost",
|
||||
port: 3001,
|
||||
url: "http://main.api.localhost:6767",
|
||||
status: "stopped",
|
||||
};
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -122,6 +141,27 @@ describe("buildSidebarProjectsFromWorkspaces", () => {
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
|
||||
});
|
||||
|
||||
it("threads services into workspace rows and derives hasRunningServices", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
services: [runningService, stoppedService],
|
||||
}),
|
||||
],
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces[0]?.services).toEqual([runningService, stoppedService]);
|
||||
expect(projects[0]?.workspaces[0]?.hasRunningServices).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves stored project order even when activity changes", () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
workspace({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
@@ -23,6 +24,8 @@ export interface SidebarWorkspaceEntry {
|
||||
activityAt: Date | null;
|
||||
statusBucket: SidebarStateBucket;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
services: WorkspaceDescriptor["services"];
|
||||
hasRunningServices: boolean;
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
@@ -141,6 +144,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
activityAt: workspace.activityAt,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
services: workspace.services,
|
||||
hasRunningServices: workspace.services.some((service) => service.status === "running"),
|
||||
};
|
||||
|
||||
project.workspaces.push(row);
|
||||
@@ -264,8 +269,12 @@ function toWorkspaceDescriptor(payload: {
|
||||
name: string;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
activityAt: string | null;
|
||||
services?: WorkspaceDescriptorPayload["services"];
|
||||
}): WorkspaceDescriptor {
|
||||
return normalizeWorkspaceDescriptor(payload);
|
||||
return normalizeWorkspaceDescriptor({
|
||||
...payload,
|
||||
services: payload.services ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
@@ -87,6 +87,9 @@ function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return "Setup";
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
|
||||
@@ -149,6 +149,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return "Setup";
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
return "Terminal";
|
||||
}
|
||||
@@ -165,6 +168,9 @@ function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return "Workspace setup";
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
return "Agent";
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("workspace source of truth consumption", () => {
|
||||
status: "running",
|
||||
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
diffStat: null,
|
||||
services: [],
|
||||
};
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace });
|
||||
|
||||
@@ -81,6 +81,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return `workspace-launcher-close-${tab.target.launcherId}`;
|
||||
}
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
|
||||
function workspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession("test-server");
|
||||
});
|
||||
|
||||
describe("normalizeWorkspaceDescriptor", () => {
|
||||
it("normalizes workspace services and invalid activity timestamps", () => {
|
||||
const services = [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running" as const,
|
||||
},
|
||||
];
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "running",
|
||||
activityAt: "not-a-date",
|
||||
diffStat: null,
|
||||
services,
|
||||
});
|
||||
|
||||
expect(workspace.activityAt).toBeNull();
|
||||
expect(workspace.services).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
expect(workspace.services).not.toBe(services);
|
||||
});
|
||||
|
||||
it("defaults missing services to an empty array", () => {
|
||||
const payload = {
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
} as WorkspaceDescriptorPayload;
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload);
|
||||
|
||||
expect(workspace.services).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaces", () => {
|
||||
it("preserves services on merged workspace entries", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession("test-server", null as unknown as DaemonClient);
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
services: [
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([
|
||||
{
|
||||
serviceName: "web",
|
||||
hostname: "main.web.localhost",
|
||||
port: 3000,
|
||||
url: "http://main.web.localhost:6767",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,7 @@ export interface WorkspaceDescriptor {
|
||||
status: WorkspaceDescriptorPayload["status"];
|
||||
activityAt: Date | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
services: WorkspaceDescriptorPayload["services"];
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceDescriptor(
|
||||
@@ -143,6 +144,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
status: payload.status,
|
||||
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
|
||||
diffStat: payload.diffStat ?? null,
|
||||
services: (payload.services ?? []).map((s) => ({ ...s })),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
@@ -12,18 +14,81 @@ export interface PendingWorkspaceSetup {
|
||||
navigationMethod: WorkspaceSetupNavigationMethod;
|
||||
}
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface WorkspaceSetupStoreState {
|
||||
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
|
||||
snapshots: Record<string, WorkspaceSetupSnapshot>;
|
||||
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
|
||||
clearWorkspaceSetup: () => void;
|
||||
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
|
||||
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
|
||||
clearServer: (serverId: string) => void;
|
||||
}
|
||||
|
||||
function buildWorkspaceSetupKey(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
return buildWorkspaceTabPersistenceKey(input);
|
||||
}
|
||||
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||
pendingWorkspaceSetup: null,
|
||||
snapshots: {},
|
||||
beginWorkspaceSetup: (value) => {
|
||||
set({ pendingWorkspaceSetup: value });
|
||||
},
|
||||
clearWorkspaceSetup: () => {
|
||||
set({ pendingWorkspaceSetup: null });
|
||||
},
|
||||
upsertProgress: ({ serverId, payload }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
snapshots: {
|
||||
...state.snapshots,
|
||||
[key]: {
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
removeWorkspace: ({ serverId, workspaceId }) => {
|
||||
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (!(key in state.snapshots)) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.snapshots };
|
||||
delete next[key];
|
||||
return { snapshots: next };
|
||||
});
|
||||
},
|
||||
clearServer: (serverId) => {
|
||||
set((state) => {
|
||||
const nextEntries = Object.entries(state.snapshots).filter(
|
||||
([key]) => !key.startsWith(`${serverId}:`),
|
||||
);
|
||||
if (nextEntries.length === Object.keys(state.snapshots).length) {
|
||||
return state;
|
||||
}
|
||||
return { snapshots: Object.fromEntries(nextEntries) };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -235,4 +235,19 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
expect(reopenedFileTabId).toBe(fileTabId);
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
|
||||
});
|
||||
|
||||
it("builds a deterministic setup tab keyed by workspace id", () => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
|
||||
expect(tabId).toBe(`setup_${WORKSPACE_ID}`);
|
||||
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ export type WorkspaceTabTarget =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "launcher"; launcherId: string };
|
||||
| { kind: "launcher"; launcherId: string }
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
|
||||
export type WorkspaceTab = {
|
||||
tabId: string;
|
||||
|
||||
@@ -83,6 +83,10 @@ describe("workspace route parsing", () => {
|
||||
kind: "file",
|
||||
path: "src/index.ts",
|
||||
});
|
||||
expect(parseWorkspaceOpenIntent("setup:L3RtcC9yZXBv")).toEqual({
|
||||
kind: "setup",
|
||||
workspaceId: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the plain workspace route when workspace context is provided", () => {
|
||||
|
||||
@@ -99,7 +99,8 @@ export type WorkspaceOpenIntent =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "draft"; draftId: string };
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "setup"; workspaceId: string };
|
||||
|
||||
export function parseWorkspaceOpenIntent(
|
||||
value: string | null | undefined,
|
||||
@@ -136,6 +137,13 @@ export function parseWorkspaceOpenIntent(
|
||||
}
|
||||
return { kind: "file", path: decodedPath };
|
||||
}
|
||||
if (kind === "setup") {
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(payload);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "setup", workspaceId };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
services: [],
|
||||
hasRunningServices: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
services: [],
|
||||
hasRunningServices: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ describe("tool-call-display", () => {
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
log: "",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ function workspace(
|
||||
status: input.status ?? "running",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
services: input.services ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ export function normalizeWorkspaceTabTarget(
|
||||
const launcherId = trimNonEmpty(value.launcherId);
|
||||
return launcherId ? { kind: "launcher", launcherId } : null;
|
||||
}
|
||||
if (value.kind === "setup") {
|
||||
const workspaceId = trimNonEmpty(value.workspaceId);
|
||||
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -52,6 +56,9 @@ export function workspaceTabTargetsEqual(
|
||||
// Launcher tabs are intentionally always unique, even when reopened repeatedly.
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "setup" && right.kind === "setup") {
|
||||
return left.workspaceId === right.workspaceId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -68,6 +75,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "launcher") {
|
||||
return `launcher_${target.launcherId}`;
|
||||
}
|
||||
if (target.kind === "setup") {
|
||||
return `setup_${target.workspaceId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user