Merge branch 'main' into dev

Resolve 38 conflicts preserving both sides' work:
- Server: DB-based registries (dev) + WorkspaceGitService/DaemonConfigStore (main)
- App: script proxy/voice MCP (dev) + pin/hide tabs/bootstrap boundary (main)
- Shared: WorkspaceScriptPayload (dev) + git/GitHub runtime schemas (main)
- Deleted workspace-registry-bootstrap.ts (dev's DB approach supersedes)
This commit is contained in:
Mohamed Boudra
2026-04-14 03:05:45 +07:00
333 changed files with 13490 additions and 6317 deletions
@@ -8,7 +8,12 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import {
useIsCompactFormFactor,
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -42,7 +47,12 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
) : null}
</View>
<View style={styles.cta}>
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
<Button
variant="default"
leftIcon={FolderOpen}
onPress={() => void openProjectPicker()}
testID="open-project-submit"
>
Add a project
</Button>
</View>
+52 -29
View File
@@ -65,6 +65,7 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useIsCompactFormFactor } from "@/constants/layout";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
@@ -360,12 +361,21 @@ function HostsSection(props: HostsSectionProps) {
}
interface GeneralSectionProps {
routeServerId: string;
settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) {
function ThemeIcon({
theme,
size,
color,
}: {
theme: AppSettings["theme"];
size: number;
color: string;
}) {
switch (theme) {
case "light":
return <Sun size={size} color={color} />;
@@ -404,11 +414,14 @@ const THEME_LABELS: Record<AppSettings["theme"], string> = {
};
function GeneralSection({
routeServerId,
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(routeServerId);
const { config, patchConfig } = useDaemonConfig(routeServerId);
const iconSize = theme.iconSize.md;
const iconColor = theme.colors.foregroundMuted;
@@ -422,15 +435,10 @@ function GeneralSection({
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.themeTrigger,
pressed && { opacity: 0.85 },
]}
style={({ pressed }) => [styles.themeTrigger, pressed && { opacity: 0.85 }]}
>
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
<Text style={styles.themeTriggerText}>
{THEME_LABELS[settings.theme]}
</Text>
<Text style={styles.themeTriggerText}>{THEME_LABELS[settings.theme]}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
@@ -475,6 +483,31 @@ function GeneralSection({
]}
/>
</View>
{routeServerId.length > 0 && isConnected ? (
<View style={[styles.audioRow, styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Inject Paseo tools</Text>
<Text style={styles.audioRowSubtitle}>
Automatically inject Paseo MCP tools into new agents
</Text>
</View>
<SegmentedControl
size="sm"
value={config?.mcp.injectIntoAgents === false ? "off" : "on"}
onValueChange={(value) => {
void patchConfig({
mcp: {
injectIntoAgents: value === "on",
},
});
}}
options={[
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
]}
/>
</View>
) : null}
</View>
</View>
);
@@ -501,10 +534,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
<Pressable
onPress={refresh}
disabled={isFetching}
style={[
settingsStyles.sectionHeaderLink,
isFetching ? { opacity: 0.5 } : null,
]}
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
>
<Text
style={{
@@ -532,7 +562,9 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
const status = entry?.status ?? "unavailable";
const ProviderIcon = getProviderIcon(def.id);
const providerError =
status === "error" && typeof entry?.error === "string" && entry.error.trim().length > 0
status === "error" &&
typeof entry?.error === "string" &&
entry.error.trim().length > 0
? entry.error.trim()
: null;
@@ -563,11 +595,7 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
: "Not installed"
}
variant={
status === "ready"
? "success"
: status === "error"
? "error"
: "muted"
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
<Button
@@ -723,10 +751,7 @@ function SettingsMobileLayout({ sections, sectionContentProps }: SettingsLayoutP
const insets = useSafeAreaInsets();
return (
<ScrollView
style={styles.scrollView}
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<ScrollView style={styles.scrollView} contentContainerStyle={{ paddingBottom: insets.bottom }}>
<View style={styles.content}>
{sections.map((section) => (
<SettingsSectionContent
@@ -751,8 +776,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
{sections.map((section) => {
const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon;
const showSeparator =
section.id === "integrations" || section.id === "providers";
const showSeparator = section.id === "integrations" || section.id === "providers";
return (
<View key={section.id}>
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
@@ -788,10 +812,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View style={styles.content}>
<SettingsSectionContent
sectionId={selectedSectionId}
{...sectionContentProps}
/>
<SettingsSectionContent sectionId={selectedSectionId} {...sectionContentProps} />
</View>
</ScrollView>
</View>
@@ -1100,12 +1121,14 @@ export default function SettingsScreen() {
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
restartConfirmationMessage:
"This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
};
const generalProps: GeneralSectionProps = {
routeServerId,
settings,
handleThemeChange,
handleSendBehaviorChange,
@@ -81,11 +81,7 @@ function ShortcutRow({
Done
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
<Button variant="ghost" size="sm" onPress={isCapturing ? onCancel : onRebind}>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
</>
@@ -182,9 +178,7 @@ export function KeyboardShortcutsSection() {
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>
Keyboard shortcuts are only available on desktop.
</Text>
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text>
</View>
</View>
);
@@ -7,10 +7,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
type StartupSplashScreenProps = {
@@ -216,7 +213,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connecting to local server...", status: "active" as const },
{
key: "connecting",
label: "Connecting to local server...",
status: "active" as const,
},
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
@@ -292,12 +293,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
The local server failed to start. If this keeps happening, please report the issue on
GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
<Text style={styles.errorMessage}>{bootstrapState.error}</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
@@ -34,9 +34,7 @@ export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSet
const allTabIdsKey = allTabIds.join("\u0000");
const availableTabIds = useMemo(() => new Set(allTabIds), [allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(
activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : [],
);
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
@@ -22,6 +22,7 @@ import {
ArrowRightToLine,
Columns2,
Copy,
RotateCw,
Rows2,
SquarePen,
SquareTerminal,
@@ -184,173 +185,181 @@ function TabChip({
return (
<View ref={middleClickRef}>
<ContextMenu key={tab.key}>
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<ContextMenuTrigger
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
testID={`workspace-tab-${tab.key}`}
triggerRef={dragHandleProps?.setActivatorNodeRef as any}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
]}
onHoverIn={() => {
setHovered(true);
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}}
onPressIn={() => {
onNavigateTab(tab.tabId);
}}
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tooltipLabel}
>
{isActive && (
<View
style={[styles.tabFocusIndicator, !isFocused && styles.tabFocusIndicatorUnfocused]}
/>
)}
<View style={styles.tabHandle}>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
</View>
{showLabel ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
showCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<Text
style={[
styles.tabLabel,
isHighlighted && styles.tabLabelActive,
showCloseButton && styles.tabLabelWithCloseButton,
]}
selectable={false}
numberOfLines={1}
ellipsizeMode="tail"
>
{presentation.label}
</Text>
)
) : null}
</View>
{showCloseButton ? (
<Pressable
{...(closeButtonDragBlockers as any)}
testID={closeButtonTestId}
disabled={isClosingTab}
onPressIn={(event) => {
event.stopPropagation?.();
}}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{({ hovered, pressed }) =>
isClosingTab ? (
<ActivityIndicator
size={12}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
<ContextMenu key={tab.key}>
<Tooltip delayDuration={400} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="triggerRef">
<ContextMenuTrigger
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
testID={`workspace-tab-${tab.key}`}
triggerRef={dragHandleProps?.setActivatorNodeRef as any}
enabledOnMobile={false}
style={({ hovered, pressed }) => [
styles.tab,
Platform.OS === "web" && isDragging && ({ cursor: "grabbing" } as const),
{
minWidth: resolvedTabWidth,
width: resolvedTabWidth,
maxWidth: resolvedTabWidth,
},
]}
onHoverIn={() => {
setHovered(true);
setHoveredTabKey(tab.key);
}}
onHoverOut={() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}}
onPressIn={() => {
onNavigateTab(tab.tabId);
}}
onPress={() => {
onNavigateTab(tab.tabId);
}}
accessibilityLabel={tooltipLabel}
>
{isActive && (
<View
style={[
styles.tabFocusIndicator,
!isFocused && styles.tabFocusIndicatorUnfocused,
]}
/>
)}
<View style={styles.tabHandle}>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
</View>
{showLabel ? (
presentation.titleState === "loading" ? (
<View
style={[
styles.tabLabelSkeleton,
showCloseButton && styles.tabLabelSkeletonWithCloseButton,
]}
/>
) : (
<X
size={12}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
<Text
style={[
styles.tabLabel,
isHighlighted && styles.tabLabelActive,
showCloseButton && styles.tabLabelWithCloseButton,
]}
selectable={false}
numberOfLines={1}
ellipsizeMode="tail"
>
{presentation.label}
</Text>
)
}
</Pressable>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
</View>
) : (
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
)}
</TooltipContent>
</Tooltip>
) : null}
</View>
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} />
) : (
<ContextMenuItem
key={entry.key}
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
{showCloseButton ? (
<Pressable
{...(closeButtonDragBlockers as any)}
testID={closeButtonTestId}
disabled={isClosingTab}
onPressIn={(event) => {
event.stopPropagation?.();
}}
onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}}
onHoverOut={() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}}
onPress={(event) => {
event.stopPropagation?.();
void onCloseTab(tab.tabId);
}}
style={({ hovered, pressed }) => [
styles.tabCloseButton,
styles.tabCloseButtonShown,
(hovered || pressed) && styles.tabCloseButtonActive,
]}
>
{({ hovered, pressed }) =>
isClosingTab ? (
<ActivityIndicator
size={12}
color={
hovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<X
size={12}
color={
hovered || pressed
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
}
</Pressable>
) : null}
</ContextMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
{tab.target.kind === "agent" ? (
<View style={styles.tooltipAgentRow}>
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
</View>
) : (
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
)}
</TooltipContent>
</Tooltip>
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
{menuEntries.map((entry) =>
entry.kind === "separator" ? (
<ContextMenuSeparator key={entry.key} />
) : (
<ContextMenuItem
key={entry.key}
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
tooltip={entry.tooltip}
leading={(() => {
const iconColor = theme.colors.foregroundMuted;
switch (entry.icon) {
case "copy":
return <Copy size={16} color={iconColor} />;
case "rotate-cw":
return <RotateCw size={16} color={iconColor} />;
case "arrow-left-to-line":
return <ArrowLeftToLine size={16} color={iconColor} />;
case "arrow-right-to-line":
return <ArrowRightToLine size={16} color={iconColor} />;
case "copy-x":
return <CopyX size={16} color={iconColor} />;
case "x":
return <X size={16} color={iconColor} />;
default:
return undefined;
}
})()}
trailing={
entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined
}
})()}
trailing={
entry.hint ? (
<Text style={styles.menuItemHint}>{entry.hint}</Text>
) : undefined
}
>
{entry.label}
</ContextMenuItem>
),
)}
</ContextMenuContent>
</ContextMenu>
>
{entry.label}
</ContextMenuItem>
),
)}
</ContextMenuContent>
</ContextMenu>
</View>
);
}
@@ -3,10 +3,7 @@ import { ActivityIndicator, Platform, Pressable, Text, View } from "react-native
import { useMutation, useQuery } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
} from "@server/shared/messages";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
import {
DropdownMenu,
@@ -16,10 +13,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
resolvePreferredEditorId,
usePreferredEditor,
} from "@/hooks/use-preferred-editor";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { isAbsolutePath } from "@/utils/path";
interface WorkspaceOpenInEditorButtonProps {
@@ -27,10 +21,7 @@ interface WorkspaceOpenInEditorButtonProps {
cwd: string;
}
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
}: WorkspaceOpenInEditorButtonProps) {
export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEditorButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const client = useHostRuntimeClient(serverId);
@@ -173,9 +164,9 @@ export function WorkspaceOpenInEditorButton({
/>
}
trailing={
editor.id === effectivePreferredEditorId
? <Check size={16} color={theme.colors.foregroundMuted} />
: undefined
editor.id === effectivePreferredEditorId ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : undefined
}
onSelect={() => handleOpenEditor(editor.id)}
>
@@ -83,7 +83,6 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -108,6 +107,7 @@ import {
} from "@/screens/workspace/workspace-header-source";
import {
deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual,
} from "@/screens/workspace/workspace-agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
@@ -123,11 +123,13 @@ import {
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import { findAdjacentPane } from "@/utils/split-navigation";
import { isAbsolutePath } from "@/utils/path";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
const EMPTY_SET = new Set<string>();
type WorkspaceScreenProps = {
@@ -789,23 +791,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
? trimNonEmpty(checkoutQuery.data.currentBranch)
: null;
const {
branchOptions,
isOpen: isBranchSwitcherOpen,
setIsOpen: setIsBranchSwitcherOpen,
handleBranchSelect,
invalidateStashAndCheckout,
} = useBranchSwitcher({
client,
normalizedServerId,
normalizedWorkspaceId,
currentBranchName,
isGitCheckout,
isConnected,
toast,
queryClient,
});
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -892,11 +877,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent);
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
@@ -904,6 +889,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
const pinnedAgentIds = useWorkspaceLayoutStore((state) =>
persistenceKey
? (state.pinnedAgentIdsByWorkspace[persistenceKey] ?? EMPTY_PINNED_AGENT_IDS)
: EMPTY_PINNED_AGENT_IDS,
);
const hiddenAgentIds = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.hiddenAgentIdsByWorkspace[persistenceKey] ?? EMPTY_SET) : EMPTY_SET,
);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const { closingTabIds, closeTab } = useCloseTabs();
const closeWorkspaceTabWithCleanup = useCallback(
@@ -1008,6 +1001,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return;
}
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
if (tab.target.kind !== "draft") {
return false;
@@ -1016,16 +1010,56 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
});
reconcileWorkspaceTabs(persistenceKey, {
agentsHydrated: hasHydratedAgents,
terminalsHydrated: terminalsQuery.isSuccess,
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
standaloneTerminalIds: terminals.map((terminal) => terminal.id),
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
});
for (const agentId of workspaceAgentVisibility.activeAgentIds) {
if (hiddenAgentIds.has(agentId)) {
continue;
}
const representedByTarget = uiTabs.some(
(tab) => tab.target.kind === "agent" && tab.target.agentId === agentId,
);
const representedByDeterministicTabId = uiTabs.some(
(tab) => tab.tabId === `agent_${agentId}`,
);
if (
hasActivePendingDraftCreateInWorkspace &&
!representedByTarget &&
!representedByDeterministicTabId
) {
continue;
}
ensureWorkspaceTab({ kind: "agent", agentId });
}
for (const terminal of terminals) {
ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id });
}
const canPruneAgentTabs = hasHydratedAgents;
const canPruneTerminalTabs = terminalsQuery.isSuccess;
for (const tab of uiTabs) {
if (
canPruneAgentTabs &&
tab.target.kind === "agent" &&
!pinnedAgentIds.has(tab.target.agentId) &&
shouldPruneWorkspaceAgentTab({
agentId: tab.target.agentId,
agentsHydrated: hasHydratedAgents,
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
})
) {
closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
}
if (
canPruneTerminalTabs &&
tab.target.kind === "terminal" &&
!terminalIds.has(tab.target.terminalId)
) {
closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
}
}
}, [
hasHydratedAgents,
hiddenAgentIds,
pendingByDraftId,
persistenceKey,
reconcileWorkspaceTabs,
@@ -1517,12 +1551,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
client,
closeTab,
closeWorkspaceTabWithCleanup,
persistenceKey,
],
[client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
);
const handleCloseTabsToLeftInPane = useCallback(
@@ -2026,233 +2055,234 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{(!isFocusModeEnabled || isMobile) && (
<ScreenHeader
left={
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
{isWorkspaceHeaderLoading ? (
<>
<View style={styles.headerTitleSkeleton} />
<View style={styles.headerProjectTitleSkeleton} />
</>
) : (
<>
<BranchSwitcher
currentBranchName={currentBranchName}
title={workspaceHeader.title}
branchOptions={branchOptions}
isOpen={isBranchSwitcherOpen}
onOpenChange={setIsBranchSwitcherOpen}
onBranchSelect={handleBranchSelect}
/>
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
{workspaceHeader.subtitle}
</Text>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
>
{({ hovered, open }) => {
const Icon = isMobile ? EllipsisVertical : Ellipsis;
return (
<Icon
size={theme.iconSize.md}
color={
hovered || open
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
);
}}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={220} testID="workspace-header-menu">
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={<SquarePen size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleCreateDraftTab}
>
New agent
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={<SquareTerminal size={16} color={theme.colors.foregroundMuted} />}
disabled={createTerminalMutation.isPending}
onSelect={handleCreateTerminal}
>
New terminal
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
disabled={!workspaceDirectory}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
</DropdownMenuItem>
{currentBranchName ? (
<DropdownMenuItem
testID="workspace-header-copy-branch-name"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleCopyBranchName}
<ScreenHeader
left={
<>
<SidebarMenuToggle />
<View style={styles.headerTitleContainer}>
{isWorkspaceHeaderLoading ? (
<>
<View style={styles.headerTitleSkeleton} />
<View style={styles.headerProjectTitleSkeleton} />
</>
) : (
<>
<BranchSwitcher
currentBranchName={currentBranchName}
title={workspaceHeader.title}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isGitCheckout={isGitCheckout}
/>
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}
numberOfLines={1}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-show-setup"
leading={<Settings size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleOpenSetupTab}
{workspaceHeader.subtitle}
</Text>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
>
Show setup
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</>
}
right={
<View style={styles.headerRight}>
{!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts}
/>
) : null}
{!isMobile ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
) : null}
{!isMobile && isGitCheckout ? (
<>
{workspaceDirectory ? (
{({ hovered, open }) => {
const Icon = isMobile ? EllipsisVertical : Ellipsis;
return (
<Icon
size={theme.iconSize.md}
color={
hovered || open
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
);
}}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={220} testID="workspace-header-menu">
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={<SquarePen size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleCreateDraftTab}
>
New agent
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={
<SquareTerminal size={16} color={theme.colors.foregroundMuted} />
}
disabled={createTerminalMutation.isPending}
onSelect={handleCreateTerminal}
>
New terminal
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
disabled={!isAbsolutePath(normalizedWorkspaceId)}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
</DropdownMenuItem>
{currentBranchName ? (
<DropdownMenuItem
testID="workspace-header-copy-branch-name"
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-show-setup"
leading={<Settings size={16} color={theme.colors.foregroundMuted} />}
onSelect={handleOpenSetupTab}
>
Show setup
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</>
}
right={
<View style={styles.headerRight}>
{!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
scripts={workspaceDescriptor.scripts}
/>
) : null}
{!isMobile ? (
<WorkspaceOpenInEditorButton
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
) : null}
{!isMobile && isGitCheckout ? (
<>
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
cwd={normalizedWorkspaceId}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={
isExplorerOpen ? "Close explorer" : "Open explorer"
}
accessibilityState={{ expanded: isExplorerOpen }}
style={({ hovered, pressed }) => [
styles.sourceControlButton,
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
(hovered || pressed || isExplorerOpen) &&
styles.sourceControlButtonHovered,
]}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const iconColor = active
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<SourceControlPanelIcon
size={theme.iconSize.md}
color={iconColor}
/>
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
style={({ hovered, pressed }) => [
styles.sourceControlButton,
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
(hovered || pressed || isExplorerOpen) &&
styles.sourceControlButtonHovered,
]}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const iconColor = active
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<SourceControlPanelIcon
size={theme.iconSize.md}
color={iconColor}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>Toggle explorer</Text>
<Shortcut keys={["mod", "E"]} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</>
) : null}
{!isMobile && !isGitCheckout ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{({ hovered }) => {
const color =
isExplorerOpen || hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return <PanelRight size={theme.iconSize.md} color={color} />;
}}
</HeaderToggleButton>
) : null}
{isMobile ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{({ hovered }) => {
const color =
isExplorerOpen || hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return isGitCheckout ? (
<SourceControlPanelIcon
size={theme.iconSize.lg}
color={color}
strokeWidth={1.5}
/>
) : (
<PanelRight size={theme.iconSize.lg} color={color} />
);
}}
</HeaderToggleButton>
) : null}
</View>
}
/>
{workspaceDescriptor?.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>
+{workspaceDescriptor.diffStat.additions}
</Text>
<Text style={styles.diffStatDeletions}>
-{workspaceDescriptor.diffStat.deletions}
</Text>
</View>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>Toggle explorer</Text>
<Shortcut keys={["mod", "E"]} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</>
) : null}
{!isMobile && !isGitCheckout ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{({ hovered }) => {
const color =
isExplorerOpen || hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return <PanelRight size={theme.iconSize.md} color={color} />;
}}
</HeaderToggleButton>
) : null}
{isMobile ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{({ hovered }) => {
const color =
isExplorerOpen || hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return isGitCheckout ? (
<SourceControlPanelIcon
size={theme.iconSize.lg}
color={color}
strokeWidth={1.5}
/>
) : (
<PanelRight size={theme.iconSize.lg} color={color} />
);
}}
</HeaderToggleButton>
) : null}
</View>
}
/>
)}
{isMobile ? (
@@ -18,7 +18,6 @@ describe("workspace source of truth consumption", () => {
workspaceKind: "checkout",
name: "feat/workspace-sot",
status: "running",
activityAt: new Date("2026-03-01T00:00:00.000Z"),
diffStat: null,
scripts: [],
};
@@ -8,13 +8,7 @@ export type WorkspaceTabMenuEntry =
kind: "item";
key: string;
label: string;
icon?:
| "copy"
| "rotate-cw"
| "arrow-left-to-line"
| "arrow-right-to-line"
| "copy-x"
| "x";
icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
hint?: string;
tooltip?: string;
disabled?: boolean;