refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
|
||||
import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, X } from "lucide-react-native";
|
||||
import { Plus, SquareTerminal, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -14,9 +12,11 @@ import {
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -158,52 +158,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
const showLabel = layoutItem?.showLabel ?? true;
|
||||
const labelCharCap = layoutItem?.labelCharCap ?? tab.label.length;
|
||||
const renderedLabel = showLabel ? tab.label.slice(0, Math.max(1, labelCharCap)) : "";
|
||||
const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const tabAgentStatusBucket = tabAgent
|
||||
? deriveSidebarStateBucket({
|
||||
status: tabAgent.status,
|
||||
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
||||
requiresAttention: tabAgent.requiresAttention,
|
||||
attentionReason: tabAgent.attentionReason,
|
||||
})
|
||||
: null;
|
||||
const tabAgentStatusColor =
|
||||
tabAgentStatusBucket === null
|
||||
? null
|
||||
: getStatusDotColor({
|
||||
theme,
|
||||
bucket: tabAgentStatusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
const icon =
|
||||
tab.kind === "agent" ? (
|
||||
<View style={styles.tabAgentIconWrapper}>
|
||||
{tab.provider === "claude" ? (
|
||||
<ClaudeIcon size={14} color={iconColor} />
|
||||
) : tab.provider === "codex" ? (
|
||||
<CodexIcon size={14} color={iconColor} />
|
||||
) : (
|
||||
<Bot size={14} color={iconColor} />
|
||||
)}
|
||||
{tabAgentStatusColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabStatusDot,
|
||||
{
|
||||
backgroundColor: tabAgentStatusColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : tab.kind === "draft" ? (
|
||||
<Pencil size={14} color={iconColor} />
|
||||
) : tab.kind === "file" ? (
|
||||
<FileText size={14} color={iconColor} />
|
||||
) : (
|
||||
<Terminal size={14} color={iconColor} />
|
||||
);
|
||||
const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent });
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
|
||||
@@ -246,9 +201,11 @@ export function WorkspaceDesktopTabsRow({
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
style={styles.tabHandle}
|
||||
>
|
||||
<View style={styles.tabIcon}>{icon}</View>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isActive} />
|
||||
</View>
|
||||
{showLabel ? (
|
||||
tab.kind === "agent" && tab.titleState === "loading" ? (
|
||||
presentation.titleState === "loading" ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabLabelSkeleton,
|
||||
@@ -453,22 +410,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tabIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
tabAgentIconWrapper: {
|
||||
position: "relative",
|
||||
width: 14,
|
||||
height: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tabStatusDot: {
|
||||
position: "absolute",
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -11,16 +11,12 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Folder,
|
||||
GitBranch,
|
||||
PanelRight,
|
||||
Plus,
|
||||
Pencil,
|
||||
SquareTerminal,
|
||||
Terminal,
|
||||
} from "lucide-react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
@@ -28,8 +24,6 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -58,6 +52,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
@@ -66,14 +61,17 @@ import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
||||
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabOptionRow,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
@@ -145,6 +143,13 @@ function WorkspaceScreenContent({
|
||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||
const normalizedWorkspaceId =
|
||||
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
|
||||
const workspaceTerminalScopeKey =
|
||||
normalizedServerId && normalizedWorkspaceId
|
||||
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
||||
: null;
|
||||
useWorkspaceTerminalSessionRetention({
|
||||
scopeKey: workspaceTerminalScopeKey,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { client, isConnected } = useHostRuntimeSession(normalizedServerId);
|
||||
@@ -702,24 +707,32 @@ function WorkspaceScreenContent({
|
||||
}, [tabs]);
|
||||
|
||||
const activeTabKey = activeTabId ?? "";
|
||||
const tabPresentationsByKey = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof deriveWorkspaceTabPresentation>>();
|
||||
for (const tab of tabs) {
|
||||
const agent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
||||
map.set(tab.key, deriveWorkspaceTabPresentation({ tab, agent }));
|
||||
}
|
||||
return map;
|
||||
}, [agentsById, tabs]);
|
||||
const activeTabPresentation = tabPresentationsByKey.get(activeTabKey) ?? null;
|
||||
|
||||
const tabSwitcherOptions = useMemo(
|
||||
() =>
|
||||
tabs.map((tab) => ({
|
||||
id: tab.key,
|
||||
label: tab.kind === "agent" && tab.titleState === "loading" ? "Loading..." : tab.label,
|
||||
label: tabPresentationsByKey.get(tab.key)?.titleState === "loading" ? "Loading..." : tab.label,
|
||||
description: tab.subtitle,
|
||||
})),
|
||||
[tabs]
|
||||
[tabPresentationsByKey, tabs]
|
||||
);
|
||||
|
||||
const activeTabLabel = useMemo(() => {
|
||||
const active = tabs.find((tab) => tab.key === activeTabKey);
|
||||
if (active?.kind === "agent" && active.titleState === "loading") {
|
||||
if (activeTabPresentation?.titleState === "loading") {
|
||||
return "Loading...";
|
||||
}
|
||||
return active?.label ?? "Select tab";
|
||||
}, [activeTabKey, tabs]);
|
||||
return activeTabPresentation?.label ?? "Select tab";
|
||||
}, [activeTabPresentation]);
|
||||
|
||||
const handleCreateDraftTab = useCallback(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
@@ -1322,69 +1335,9 @@ function WorkspaceScreenContent({
|
||||
>
|
||||
<View style={styles.switcherTriggerLeft}>
|
||||
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
||||
{(() => {
|
||||
const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null;
|
||||
if (!activeDescriptor) {
|
||||
return <View style={styles.tabIcon}><Bot size={14} color={theme.colors.foregroundMuted} /></View>;
|
||||
}
|
||||
|
||||
if (activeDescriptor.kind === "terminal") {
|
||||
return <Terminal size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
if (activeDescriptor.kind === "file") {
|
||||
return <FileText size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
if (activeDescriptor.kind === "draft") {
|
||||
return <Pencil size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
if (activeDescriptor.kind !== "agent") {
|
||||
return <Bot size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
|
||||
const tabAgentStatusBucket = tabAgent
|
||||
? deriveSidebarStateBucket({
|
||||
status: tabAgent.status,
|
||||
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
||||
requiresAttention: tabAgent.requiresAttention,
|
||||
attentionReason: tabAgent.attentionReason,
|
||||
})
|
||||
: null;
|
||||
const tabAgentStatusColor =
|
||||
tabAgentStatusBucket === null
|
||||
? null
|
||||
: getStatusDotColor({
|
||||
theme,
|
||||
bucket: tabAgentStatusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.tabAgentIconWrapper}>
|
||||
{activeDescriptor.provider === "claude" ? (
|
||||
<ClaudeIcon size={14} color={theme.colors.foreground} />
|
||||
) : activeDescriptor.provider === "codex" ? (
|
||||
<CodexIcon size={14} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Bot size={14} color={theme.colors.foreground} />
|
||||
)}
|
||||
{tabAgentStatusColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.tabStatusDot,
|
||||
{
|
||||
backgroundColor: tabAgentStatusColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
{activeTabPresentation ? (
|
||||
<WorkspaceTabIcon presentation={activeTabPresentation} active />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
||||
@@ -1456,6 +1409,23 @@ function WorkspaceScreenContent({
|
||||
open={isTabSwitcherOpen}
|
||||
onOpenChange={setIsTabSwitcherOpen}
|
||||
anchorRef={tabSwitcherAnchorRef}
|
||||
renderOption={({ option, selected, active, onPress }) => {
|
||||
const tab = tabByKey.get(option.id);
|
||||
if (!tab) {
|
||||
return <View />;
|
||||
}
|
||||
const presentation =
|
||||
tabPresentationsByKey.get(option.id) ??
|
||||
deriveWorkspaceTabPresentation({ tab });
|
||||
return (
|
||||
<WorkspaceTabOptionRow
|
||||
presentation={presentation}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
@@ -1710,22 +1680,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tabIcon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
tabAgentIconWrapper: {
|
||||
position: "relative",
|
||||
width: 14,
|
||||
height: 14,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tabStatusDot: {
|
||||
position: "absolute",
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
deriveSidebarStateBucket,
|
||||
type SidebarStateBucket,
|
||||
} from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
|
||||
export type WorkspaceTabPresentation = {
|
||||
key: string;
|
||||
kind: WorkspaceTabDescriptor["kind"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
titleState: "ready" | "loading";
|
||||
provider: Agent["provider"] | null;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
};
|
||||
|
||||
export function deriveWorkspaceTabPresentation(input: {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
agent?: Agent | null;
|
||||
}): WorkspaceTabPresentation {
|
||||
const { tab, agent = null } = input;
|
||||
return {
|
||||
key: tab.key,
|
||||
kind: tab.kind,
|
||||
label: tab.label,
|
||||
subtitle: tab.subtitle,
|
||||
titleState: tab.kind === "agent" ? tab.titleState : "ready",
|
||||
provider: tab.kind === "agent" ? tab.provider : null,
|
||||
statusBucket:
|
||||
tab.kind === "agent" && agent
|
||||
? deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceTabIconProps = {
|
||||
presentation: WorkspaceTabPresentation;
|
||||
active?: boolean;
|
||||
size?: number;
|
||||
statusDotBorderColor?: string;
|
||||
};
|
||||
|
||||
export function WorkspaceTabIcon({
|
||||
presentation,
|
||||
active = false,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
}: WorkspaceTabIconProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const statusDotColor =
|
||||
presentation.statusBucket === null
|
||||
? null
|
||||
: getStatusDotColor({
|
||||
theme,
|
||||
bucket: presentation.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
|
||||
if (presentation.kind === "agent") {
|
||||
return (
|
||||
<View style={[styles.agentIconWrapper, { width: size, height: size }]}>
|
||||
{presentation.provider === "claude" ? (
|
||||
<ClaudeIcon size={size} color={iconColor} />
|
||||
) : presentation.provider === "codex" ? (
|
||||
<CodexIcon size={size} color={iconColor} />
|
||||
) : (
|
||||
<Bot size={size} color={iconColor} />
|
||||
)}
|
||||
{statusDotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (presentation.kind === "draft") {
|
||||
return <Pencil size={size} color={iconColor} />;
|
||||
}
|
||||
|
||||
if (presentation.kind === "file") {
|
||||
return <FileText size={size} color={iconColor} />;
|
||||
}
|
||||
|
||||
return <Terminal size={size} color={iconColor} />;
|
||||
}
|
||||
|
||||
type WorkspaceTabOptionRowProps = {
|
||||
presentation: WorkspaceTabPresentation;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export function WorkspaceTabOptionRow({
|
||||
presentation,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionRow,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
agentIconWrapper: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: 0,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
optionRowActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
optionLeadingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
optionContent: {
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
optionTrailingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user