diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx new file mode 100644 index 000000000..cddc2ae3c --- /dev/null +++ b/packages/app/src/panels/agent-panel.tsx @@ -0,0 +1,85 @@ +import { Bot } from "lucide-react-native"; +import invariant from "tiny-invariant"; +import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import { ClaudeIcon } from "@/components/icons/claude-icon"; +import { CodexIcon } from "@/components/icons/codex-icon"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; + +function formatProviderLabel(provider: Agent["provider"]): string { + if (provider === "claude") { + return "Claude"; + } + if (provider === "codex") { + return "Codex"; + } + if (!provider) { + return "Agent"; + } + return provider.charAt(0).toUpperCase() + provider.slice(1); +} + +function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +function useAgentPanelDescriptor( + target: { kind: "agent"; agentId: string }, + context: { serverId: string } +): PanelDescriptor { + const agent = useSessionStore( + (state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null + ); + const provider = agent?.provider ?? "codex"; + const label = resolveWorkspaceAgentTabLabel(agent?.title); + const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot; + + return { + label: label ?? "", + subtitle: `${formatProviderLabel(provider)} agent`, + titleState: label ? "ready" : "loading", + icon, + statusBucket: agent + ? deriveSidebarStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + }) + : null, + }; +} + +function AgentPanel() { + const { serverId, target, openFileInWorkspace } = usePaneContext(); + invariant(target.kind === "agent", "AgentPanel requires agent target"); + return ( + { + openFileInWorkspace(filePath); + }} + /> + ); +} + +export const agentPanelRegistration: PanelRegistration<"agent"> = { + kind: "agent", + component: AgentPanel, + useDescriptor: useAgentPanelDescriptor, +}; diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx new file mode 100644 index 000000000..eb1cdcda0 --- /dev/null +++ b/packages/app/src/panels/draft-panel.tsx @@ -0,0 +1,56 @@ +import { Pencil } from "lucide-react-native"; +import invariant from "tiny-invariant"; +import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; + +function useDraftPanelDescriptor() { + return { + label: "New Agent", + subtitle: "New Agent", + titleState: "ready" as const, + icon: Pencil, + statusBucket: null, + }; +} + +function DraftPanel() { + const { + serverId, + workspaceId, + tabId, + target, + openFileInWorkspace, + retargetCurrentTab, + } = usePaneContext(); + invariant(target.kind === "draft", "DraftPanel requires draft target"); + + return ( + { + openFileInWorkspace(filePath); + }} + onCreated={(agentSnapshot) => { + const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); + useSessionStore.getState().setAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentSnapshot.id, normalized); + return next; + }); + }} + /> + ); +} + +export const draftPanelRegistration: PanelRegistration<"draft"> = { + kind: "draft", + component: DraftPanel, + useDescriptor: useDraftPanelDescriptor, +}; diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx new file mode 100644 index 000000000..c02bbc117 --- /dev/null +++ b/packages/app/src/panels/file-panel.tsx @@ -0,0 +1,34 @@ +import { FileText } from "lucide-react-native"; +import invariant from "tiny-invariant"; +import { FilePane } from "@/components/file-pane"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelRegistration } from "@/panels/panel-registry"; + +function useFilePanelDescriptor(target: { kind: "file"; path: string }) { + const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path; + return { + label: fileName, + subtitle: target.path, + titleState: "ready" as const, + icon: FileText, + statusBucket: null, + }; +} + +function FilePanel() { + const { serverId, workspaceId, target } = usePaneContext(); + invariant(target.kind === "file", "FilePanel requires file target"); + return ( + + ); +} + +export const filePanelRegistration: PanelRegistration<"file"> = { + kind: "file", + component: FilePanel, + useDescriptor: useFilePanelDescriptor, +}; diff --git a/packages/app/src/panels/pane-context.tsx b/packages/app/src/panels/pane-context.tsx new file mode 100644 index 000000000..6b5241f5c --- /dev/null +++ b/packages/app/src/panels/pane-context.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, type ReactNode } from "react"; +import invariant from "tiny-invariant"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; + +export interface PaneContextValue { + serverId: string; + workspaceId: string; + tabId: string; + target: WorkspaceTabTarget; + openTab(target: WorkspaceTabTarget): void; + closeCurrentTab(): void; + retargetCurrentTab(target: WorkspaceTabTarget): void; + openFileInWorkspace(filePath: string): void; +} + +const PaneContext = createContext(null); + +export function PaneProvider({ + value, + children, +}: { + value: PaneContextValue; + children: ReactNode; +}) { + return {children}; +} + +export function usePaneContext(): PaneContextValue { + const value = useContext(PaneContext); + invariant(value, "PaneContext is required"); + return value; +} diff --git a/packages/app/src/panels/panel-registry.ts b/packages/app/src/panels/panel-registry.ts new file mode 100644 index 000000000..7f4de2835 --- /dev/null +++ b/packages/app/src/panels/panel-registry.ts @@ -0,0 +1,50 @@ +import type { ComponentType } from "react"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; + +export interface PanelIconProps { + size: number; + color: string; +} + +export interface PanelDescriptor { + label: string; + subtitle: string; + titleState: "ready" | "loading"; + icon: ComponentType; + statusBucket: SidebarStateBucket | null; +} + +export interface PanelDescriptorContext { + serverId: string; + workspaceId: string; +} + +export interface PanelRegistration< + K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"], +> { + kind: K; + component: ComponentType; + useDescriptor( + target: Extract, + context: PanelDescriptorContext + ): PanelDescriptor; + confirmClose?( + target: Extract, + context: PanelDescriptorContext + ): Promise; +} + +const panelRegistry = new Map(); + +export function registerPanel( + registration: PanelRegistration +): void { + panelRegistry.set(registration.kind, registration as unknown as PanelRegistration); +} + +export function getPanelRegistration( + kind: WorkspaceTabTarget["kind"] +): PanelRegistration | undefined { + return panelRegistry.get(kind); +} diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts new file mode 100644 index 000000000..760671b65 --- /dev/null +++ b/packages/app/src/panels/register-panels.ts @@ -0,0 +1,18 @@ +import { agentPanelRegistration } from "@/panels/agent-panel"; +import { draftPanelRegistration } from "@/panels/draft-panel"; +import { filePanelRegistration } from "@/panels/file-panel"; +import { registerPanel } from "@/panels/panel-registry"; +import { terminalPanelRegistration } from "@/panels/terminal-panel"; + +let panelsRegistered = false; + +export function ensurePanelsRegistered(): void { + if (panelsRegistered) { + return; + } + registerPanel(draftPanelRegistration); + registerPanel(agentPanelRegistration); + registerPanel(terminalPanelRegistration); + registerPanel(filePanelRegistration); + panelsRegistered = true; +} diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx new file mode 100644 index 000000000..63a8525d1 --- /dev/null +++ b/packages/app/src/panels/terminal-panel.tsx @@ -0,0 +1,80 @@ +import { useQuery } from "@tanstack/react-query"; +import { Terminal } from "lucide-react-native"; +import { View } from "react-native"; +import { useIsFocused } from "@react-navigation/native"; +import invariant from "tiny-invariant"; +import type { ListTerminalsResponse } from "@server/shared/messages"; +import { TerminalPane } from "@/components/terminal-pane"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; + +type ListTerminalsPayload = ListTerminalsResponse["payload"]; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function useTerminalPanelDescriptor( + target: { kind: "terminal"; terminalId: string }, + context: { serverId: string; workspaceId: string } +): PanelDescriptor { + const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); + const terminalsQuery = useQuery({ + queryKey: ["terminals", context.serverId, context.workspaceId] as const, + enabled: Boolean(client && context.workspaceId), + queryFn: async (): Promise => { + if (!client) { + return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" }; + } + return client.listTerminals(context.workspaceId); + }, + staleTime: 5_000, + }); + const terminal = + terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; + + return { + label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal", + subtitle: "Terminal", + titleState: "ready", + icon: Terminal, + statusBucket: null, + }; +} + +function TerminalPanel() { + const isFocused = useIsFocused(); + const { serverId, workspaceId, target, openTab } = usePaneContext(); + invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); + + if (!isFocused) { + return ; + } + + return ( + { + if (!terminalId) { + return; + } + openTab({ kind: "terminal", terminalId }); + }} + hideHeader + manageTerminalDirectorySubscription={false} + /> + ); +} + +export const terminalPanelRegistration: PanelRegistration<"terminal"> = { + kind: "terminal", + component: TerminalPanel, + useDescriptor: useTerminalPanelDescriptor, +}; diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts index 2ca95cdc7..57aaf6ce2 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts @@ -10,11 +10,7 @@ function makeAgentTab(id: string): WorkspaceTabDescriptor { key: `agent_${id}`, tabId: `agent_${id}`, kind: "agent", - agentId: id, - provider: "codex", - label: `Agent ${id}`, - subtitle: "", - titleState: "ready", + target: { kind: "agent", agentId: id }, }; } @@ -23,9 +19,7 @@ function makeTerminalTab(id: string): WorkspaceTabDescriptor { key: `terminal_${id}`, tabId: `terminal_${id}`, kind: "terminal", - terminalId: id, - label: `Terminal ${id}`, - subtitle: "", + target: { kind: "terminal", terminalId: id }, }; } @@ -34,9 +28,7 @@ function makeFileTab(path: string): WorkspaceTabDescriptor { key: `file_${path}`, tabId: `file_${path}`, kind: "file", - filePath: path, - label: path.split("/").pop() ?? path, - subtitle: path, + target: { kind: "file", path }, }; } diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts index 1acc27c5a..3b0ba9bf1 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -14,12 +14,12 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl }; for (const tab of tabs) { - if (tab.kind === "agent") { - groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.agentId }); + if (tab.target.kind === "agent") { + groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId }); continue; } - if (tab.kind === "terminal") { - groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.terminalId }); + if (tab.target.kind === "terminal") { + groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId }); continue; } groups.otherTabs.push({ tabId: tab.tabId }); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 478145bf1..499667e35 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; import { Plus, X } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -14,13 +14,13 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { - deriveWorkspaceTabPresentation, + useWorkspaceTabPresentation, WorkspaceTabIcon, + type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { encodeFilePathForPathSegment } from "@/utils/host-routes"; -import type { Agent } from "@/stores/session-store"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; @@ -29,8 +29,8 @@ type NewTabOptionId = "__new_tab_agent__"; type WorkspaceDesktopTabsRowProps = { tabs: WorkspaceTabDescriptor[]; activeTabKey: string; - agentsById: Map; normalizedServerId: string; + normalizedWorkspaceId: string; hoveredCloseTabKey: string | null; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; @@ -49,11 +49,242 @@ type WorkspaceDesktopTabsRowProps = { onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; }; +function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "draft") { + return "New Agent"; + } + if (tab.target.kind === "terminal") { + return "Terminal"; + } + if (tab.target.kind === "file") { + return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path; + } + return ""; +} + +function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "agent") { + return `workspace-agent-close-${tab.target.agentId}`; + } + if (tab.target.kind === "terminal") { + return `workspace-terminal-close-${tab.target.terminalId}`; + } + if (tab.target.kind === "draft") { + return `workspace-draft-close-${tab.target.draftId}`; + } + return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; +} + +function TabChip({ + tab, + index, + tabCount, + isActive, + resolvedTabWidth, + showLabel, + showCloseButton, + isCloseHovered, + isClosingTab, + normalizedServerId, + normalizedWorkspaceId, + setHoveredTabKey, + setHoveredCloseTabKey, + onNavigateTab, + onCloseTab, + onCopyResumeCommand, + onCopyAgentId, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onPresentationChange, + dragHandleProps, +}: { + tab: WorkspaceTabDescriptor; + index: number; + tabCount: number; + isActive: boolean; + resolvedTabWidth: number; + showLabel: boolean; + showCloseButton: boolean; + isCloseHovered: boolean; + isClosingTab: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + setHoveredTabKey: Dispatch>; + setHoveredCloseTabKey: Dispatch>; + onNavigateTab: (tabId: string) => void; + onCloseTab: (tabId: string) => Promise | void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCloseTabsToLeft: (tabId: string) => Promise | void; + onCloseTabsToRight: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; + onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void; + dragHandleProps: any; +}) { + const { theme } = useUnistyles(); + const presentation = useWorkspaceTabPresentation({ + tab, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + + useEffect(() => { + onPresentationChange(tab.key, presentation); + }, [onPresentationChange, presentation, tab.key]); + + const tooltipLabel = + presentation.titleState === "loading" ? "Loading agent title" : presentation.label; + const contextMenuTestId = `workspace-tab-context-${tab.key}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab, + index, + tabCount, + menuTestIDBase: contextMenuTestId, + onCopyResumeCommand, + onCopyAgentId, + onCloseTab, + onCloseTabsBefore: onCloseTabsToLeft, + onCloseTabsAfter: onCloseTabsToRight, + onCloseOtherTabs, + }); + + return ( + + + + [ + styles.tab, + { + minWidth: resolvedTabWidth, + width: resolvedTabWidth, + maxWidth: resolvedTabWidth, + }, + isActive && styles.tabActive, + !isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered, + ]} + onHoverIn={() => { + setHoveredTabKey(tab.key); + }} + onHoverOut={() => { + setHoveredTabKey((current) => (current === tab.key ? null : current)); + }} + onPressIn={() => { + onNavigateTab(tab.tabId); + }} + onPress={() => { + onNavigateTab(tab.tabId); + }} + accessibilityLabel={tooltipLabel} + > + + + + + {showLabel ? ( + presentation.titleState === "loading" ? ( + + ) : ( + + {presentation.label} + + ) + ) : null} + + + {showCloseButton ? ( + { + 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 ? ( + + ) : ( + + ) + } + + ) : null} + + + + {tooltipLabel} + + + + + {menuEntries.map((entry) => + entry.kind === "separator" ? ( + + ) : ( + + {entry.label} + + ) + )} + + + ); +} + export function WorkspaceDesktopTabsRow({ tabs, activeTabKey, - agentsById, normalizedServerId, + normalizedWorkspaceId, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, @@ -74,6 +305,43 @@ export function WorkspaceDesktopTabsRow({ const { theme } = useUnistyles(); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [tabsActionsWidth, setTabsActionsWidth] = useState(0); + const [presentationsByKey, setPresentationsByKey] = useState>( + () => new Map() + ); + + const handlePresentationChange = useCallback( + (tabKey: string, presentation: WorkspaceTabPresentation) => { + setPresentationsByKey((current) => { + const existing = current.get(tabKey); + if ( + existing?.label === presentation.label && + existing?.subtitle === presentation.subtitle && + existing?.titleState === presentation.titleState && + existing?.statusBucket === presentation.statusBucket && + existing?.icon === presentation.icon + ) { + return current; + } + const next = new Map(current); + next.set(tabKey, presentation); + return next; + }); + }, + [] + ); + + useEffect(() => { + setPresentationsByKey((current) => { + const next = new Map(); + for (const tab of tabs) { + const presentation = current.get(tab.key); + if (presentation) { + next.set(tab.key, presentation); + } + } + return next.size === current.size ? current : next; + }); + }, [tabs]); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); @@ -103,12 +371,14 @@ export function WorkspaceDesktopTabsRow({ const tabLabelLengths = useMemo( () => tabs.map((tab) => { - if (tab.kind === "agent" && tab.titleState === "loading") { + const presentation = presentationsByKey.get(tab.key); + if (presentation?.titleState === "loading") { return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth)); } - return tab.label.length; + const label = presentation?.label ?? getFallbackTabLabel(tab); + return label.length; }), - [layoutMetrics.estimatedCharWidth, tabs] + [layoutMetrics.estimatedCharWidth, presentationsByKey, tabs] ); const { layout } = useWorkspaceTabLayout({ @@ -142,177 +412,49 @@ export function WorkspaceDesktopTabsRow({ onDragEnd={onReorderTabs} renderItem={({ item: tab, index, dragHandleProps }) => { const isActive = tab.key === activeTabKey; - const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null; const isCloseHovered = hoveredCloseTabKey === tab.key; const isClosingAgent = - tab.kind === "agent" && + tab.target.kind === "agent" && isArchivingAgent({ serverId: normalizedServerId, - agentId: tab.agentId, + agentId: tab.target.agentId, }); const isClosingTerminal = - tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId; + tab.target.kind === "terminal" && + killTerminalPending && + killTerminalId === tab.target.terminalId; const isClosingTab = isClosingAgent || isClosingTerminal; const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; - const presentation = deriveWorkspaceTabPresentation({ tab, agent: tabAgent }); - const tooltipLabel = - tab.kind === "agent" && tab.titleState === "loading" - ? "Loading agent title" - : presentation.label; - - const contextMenuTestId = `workspace-tab-context-${tab.key}`; - const menuEntries = buildWorkspaceTabMenuEntries({ - surface: "desktop", - tab, - index, - tabCount: tabs.length, - menuTestIDBase: contextMenuTestId, - onCopyResumeCommand, - onCopyAgentId, - onCloseTab, - onCloseTabsBefore: onCloseTabsToLeft, - onCloseTabsAfter: onCloseTabsToRight, - onCloseOtherTabs, - }); return ( - - - - [ - styles.tab, - { - minWidth: resolvedTabWidth, - width: resolvedTabWidth, - maxWidth: resolvedTabWidth, - }, - isActive && styles.tabActive, - !isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered, - ]} - onHoverIn={() => { - setHoveredTabKey(tab.key); - }} - onHoverOut={() => { - setHoveredTabKey((current) => (current === tab.key ? null : current)); - }} - onPressIn={() => { - onNavigateTab(tab.tabId); - }} - onPress={() => { - onNavigateTab(tab.tabId); - }} - accessibilityLabel={tooltipLabel} - > - - - - - {showLabel ? ( - presentation.titleState === "loading" ? ( - - ) : ( - - {tab.label} - - ) - ) : null} - - - {shouldShowCloseButton ? ( - { - 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 ? ( - - ) : ( - - ) - } - - ) : null} - - - - {tooltipLabel} - - - - - {menuEntries.map((entry) => - entry.kind === "separator" ? ( - - ) : ( - - {entry.label} - - ) - )} - - + ); }} /> diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5228c97c3..7f9636a3c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -22,6 +22,7 @@ import { } from "lucide-react-native"; import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; @@ -40,8 +41,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; -import { FilePane } from "@/components/file-pane"; -import { TerminalPane } from "@/components/terminal-pane"; import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon"; import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; @@ -70,19 +69,18 @@ import { checkoutStatusQueryKey, type CheckoutStatusPayload, } from "@/hooks/use-checkout-status-query"; -import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import { PaneProvider } from "@/panels/pane-context"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import { getPanelRegistration } from "@/panels/panel-registry"; 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 { 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, - type WorkspaceTabPresentation, + useWorkspaceTabPresentation, WorkspaceTabIcon, WorkspaceTabOptionRow, } from "@/screens/workspace/workspace-tab-presentation"; @@ -145,14 +143,40 @@ function buildOpenIntentKey(input: { return `${input.serverId}:${input.workspaceId}:${openParam}`; } +function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "draft") { + return "New Agent"; + } + if (tab.target.kind === "terminal") { + return "Terminal"; + } + if (tab.target.kind === "file") { + return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path; + } + return "Agent"; +} + +function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { + if (tab.target.kind === "draft") { + return "New Agent"; + } + if (tab.target.kind === "agent") { + return "Agent"; + } + if (tab.target.kind === "terminal") { + return "Terminal"; + } + return tab.target.path; +} + type MobileWorkspaceTabSwitcherProps = { tabs: WorkspaceTabDescriptor[]; activeTabKey: string; - activeTabLabel: string; - activeTabPresentation: WorkspaceTabPresentation | null; + activeTab: WorkspaceTabDescriptor | null; tabSwitcherOptions: ComboboxOption[]; tabByKey: Map; - tabPresentationsByKey: Map; + normalizedServerId: string; + normalizedWorkspaceId: string; onSelectSwitcherTab: (key: string) => void; onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void; onCopyResumeCommand: (agentId: string) => Promise | void; @@ -163,14 +187,167 @@ type MobileWorkspaceTabSwitcherProps = { onCloseOtherTabs: (tabId: string) => Promise | void; }; +function MobileActiveTabTrigger({ + activeTab, + normalizedServerId, + normalizedWorkspaceId, +}: { + activeTab: WorkspaceTabDescriptor | null; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + if (!activeTab) { + return null; + } + + return ( + + ); +} + +function ResolvedMobileActiveTabTrigger({ + activeTab, + normalizedServerId, + normalizedWorkspaceId, +}: { + activeTab: WorkspaceTabDescriptor; + normalizedServerId: string; + normalizedWorkspaceId: string; +}) { + const presentation = useWorkspaceTabPresentation({ + tab: activeTab, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + + return ( + <> + + + + + + {presentation.titleState === "loading" ? "Loading..." : presentation.label} + + + ); +} + +function MobileWorkspaceTabOption({ + tab, + tabIndex, + tabCount, + normalizedServerId, + normalizedWorkspaceId, + selected, + active, + onPress, + onCopyResumeCommand, + onCopyAgentId, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: { + tab: WorkspaceTabDescriptor; + tabIndex: number; + tabCount: number; + normalizedServerId: string; + normalizedWorkspaceId: string; + selected: boolean; + active: boolean; + onPress: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +}) { + const { theme } = useUnistyles(); + const presentation = useWorkspaceTabPresentation({ + tab, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }); + const menuTestIDBase = `workspace-tab-menu-${tab.key}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "mobile", + tab, + index: tabIndex, + tabCount, + menuTestIDBase, + onCopyResumeCommand, + onCopyAgentId, + onCloseTab, + onCloseTabsBefore: onCloseTabsAbove, + onCloseTabsAfter: onCloseTabsBelow, + onCloseOtherTabs, + }); + + return ( + + [ + styles.mobileTabMenuTrigger, + (open || pressed) && styles.mobileTabMenuTriggerActive, + ]} + > + + + + {menuEntries.map((entry) => + entry.kind === "separator" ? ( + + ) : ( + + {entry.label} + + ) + )} + + + } + /> + ); +} + const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ tabs, activeTabKey, - activeTabLabel, - activeTabPresentation, + activeTab, tabSwitcherOptions, tabByKey, - tabPresentationsByKey, + normalizedServerId, + normalizedWorkspaceId, onSelectSwitcherTab, onSelectNewTabOption, onCopyResumeCommand, @@ -211,15 +388,11 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onPress={() => setIsOpen(true)} > - - {activeTabPresentation ? ( - - ) : null} - - - - {activeTabLabel} - + @@ -264,74 +437,26 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ if (!tab) { return ; } - const presentation = - tabPresentationsByKey.get(option.id) ?? - deriveWorkspaceTabPresentation({ tab }); const tabIndex = tabIndexByKey.get(tab.key) ?? -1; - const menuTestIDBase = `workspace-tab-menu-${tab.key}`; - const menuEntries = - tabIndex < 0 - ? [] - : buildWorkspaceTabMenuEntries({ - surface: "mobile", - tab, - index: tabIndex, - tabCount: tabs.length, - menuTestIDBase, - onCopyResumeCommand, - onCopyAgentId, - onCloseTab, - onCloseTabsBefore: onCloseTabsAbove, - onCloseTabsAfter: onCloseTabsBelow, - onCloseOtherTabs, - }); + if (tabIndex < 0) { + return ; + } return ( - - [ - styles.mobileTabMenuTrigger, - (open || pressed) && styles.mobileTabMenuTriggerActive, - ]} - > - - - - {menuEntries.map((entry) => - entry.kind === "separator" ? ( - - ) : ( - - {entry.label} - - ) - )} - - - } + onCopyResumeCommand={onCopyResumeCommand} + onCopyAgentId={onCopyAgentId} + onCloseTab={onCloseTab} + onCloseTabsAbove={onCloseTabsAbove} + onCloseTabsBelow={onCloseTabsBelow} + onCloseOtherTabs={onCloseOtherTabs} /> ); }} @@ -340,6 +465,46 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ ); }); +function PaneContent({ + tab, + normalizedServerId, + normalizedWorkspaceId, + onOpenTab, + onCloseCurrentTab, + onRetargetCurrentTab, + onOpenWorkspaceFile, +}: { + tab: WorkspaceTabDescriptor; + normalizedServerId: string; + normalizedWorkspaceId: string; + onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void; + onCloseCurrentTab: () => void; + onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void; + onOpenWorkspaceFile: (filePath: string) => void; +}) { + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.kind); + invariant(registration, `No panel registration for kind: ${tab.kind}`); + const Component = registration.component; + + return ( + + + + ); +} + export function WorkspaceScreen({ serverId, workspaceId, @@ -841,8 +1006,6 @@ function WorkspaceScreenContent({ const tabModel = useMemo( () => deriveWorkspaceTabModel({ - workspaceAgents, - terminals, tabs: uiTabs, tabOrder, focusedTabId, @@ -857,7 +1020,7 @@ function WorkspaceScreenContent({ ? { kind: "file", path: unresolvedOpenIntent.path } : null, }), - [focusedTabId, tabOrder, terminals, uiTabs, unresolvedOpenIntent, workspaceAgents] + [focusedTabId, tabOrder, uiTabs, unresolvedOpenIntent] ); const activeTabId = tabModel.activeTabId; @@ -976,33 +1139,17 @@ function WorkspaceScreenContent({ }, [tabs]); const activeTabKey = activeTabId ?? ""; - const tabPresentationsByKey = useMemo(() => { - const map = new Map>(); - 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: tabPresentationsByKey.get(tab.key)?.titleState === "loading" ? "Loading..." : tab.label, - description: tab.subtitle, + label: getFallbackTabOptionLabel(tab), + description: getFallbackTabOptionDescription(tab), })), - [tabPresentationsByKey, tabs] + [tabs] ); - const activeTabLabel = useMemo(() => { - if (activeTabPresentation?.titleState === "loading") { - return "Loading..."; - } - return activeTabPresentation?.label ?? "Select tab"; - }, [activeTabPresentation]); - const handleCreateDraftTab = useCallback(() => { openWorkspaceDraftTab(); }, [openWorkspaceDraftTab]); @@ -1176,12 +1323,12 @@ function WorkspaceScreenContent({ if (!tab) { return; } - if (tab.kind === "terminal") { - await handleCloseTerminalTab({ tabId, terminalId: tab.terminalId }); + if (tab.target.kind === "terminal") { + await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId }); return; } - if (tab.kind === "agent") { - await handleCloseAgentTab({ tabId, agentId: tab.agentId }); + if (tab.target.kind === "agent") { + await handleCloseAgentTab({ tabId, agentId: tab.target.agentId }); return; } handleCloseDraftOrFileTab(tabId); @@ -1434,114 +1581,59 @@ function WorkspaceScreenContent({ handle: handleWorkspaceTabAction, }); - const renderContent = () => { - if ( - shouldRenderMissingWorkspaceDescriptor({ - workspace: workspaceDescriptor, - hasHydratedWorkspaces, - }) - ) { - return ( - - - - ); - } - - const target = activeTab?.target ?? null; - if (!target) { - if (!hasHydratedAgents) { - return ( - - - - ); - } - return ( - - - No tabs are available yet. Use New tab to create an agent or terminal. - - - ); - } - - if (target.kind === "draft") { - const tabId = activeTabId ?? target.draftId; - return ( - { - const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId); - retargetWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - tabId, - target: { kind: "agent", agentId: agentSnapshot.id }, - }); - useSessionStore.getState().setAgents(normalizedServerId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, normalized); - return next; - }); - }} - /> - ); - } - - if (target.kind === "agent") { - return ( - - ); - } - - if (target.kind === "file") { - return ( - - ); - } - - return isScreenFocused ? ( - { - if (!terminalId) { - return; - } - const tabId = openOrFocusTab({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - target: { kind: "terminal", terminalId }, - }); - if (tabId) { - navigateToTabId(tabId); - } - }} - hideHeader - manageTerminalDirectorySubscription={false} - /> + const activeTabDescriptor = activeTab?.descriptor ?? null; + const content = shouldRenderMissingWorkspaceDescriptor({ + workspace: workspaceDescriptor, + hasHydratedWorkspaces, + }) ? ( + + + + ) : !activeTabDescriptor ? ( + !hasHydratedAgents ? ( + + + ) : ( - - ); - }; + + + No tabs are available yet. Use New tab to create an agent or terminal. + + + ) + ) : ( + { + const tabId = openOrFocusTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + target, + }); + if (tabId) { + navigateToTabId(tabId); + } + }} + onCloseCurrentTab={() => { + if (activeTabDescriptor.tabId) { + void handleCloseTabById(activeTabDescriptor.tabId); + } + }} + onRetargetCurrentTab={(target) => { + retargetWorkspaceTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId: activeTabDescriptor.tabId, + target, + }); + }} + onOpenWorkspaceFile={(filePath) => { + handleOpenFileFromChat({ filePath }); + }} + /> + ); return ( @@ -1728,11 +1820,11 @@ function WorkspaceScreenContent({ {isMobile ? ( - {renderContent()} + {content} ) : ( - {renderContent()} + {content} )} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 32742bca3..23c52d418 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -7,11 +7,7 @@ function createAgentTab(): WorkspaceTabDescriptor { key: "agent_123", tabId: "agent_123", kind: "agent", - agentId: "agent-123", - provider: "codex", - label: "Agent 123", - subtitle: "/repo", - titleState: "ready", + target: { kind: "agent", agentId: "agent-123" }, }; } @@ -84,9 +80,7 @@ describe("buildWorkspaceTabMenuEntries", () => { key: "draft_123", tabId: "draft_123", kind: "draft", - draftId: "draft_123", - label: "New agent", - subtitle: "/repo", + target: { kind: "draft", draftId: "draft_123" }, }, index: 0, tabCount: 1, diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index c971c4407..ffcf3e67b 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -68,14 +68,15 @@ export function buildWorkspaceTabMenuEntries( const isOnlyTab = tabCount <= 1; const entries: WorkspaceTabMenuEntry[] = []; - if (tab.kind === "agent") { + if (tab.target.kind === "agent") { + const { agentId } = tab.target; entries.push({ kind: "item", key: "copy-resume-command", label: "Copy resume command", testID: `${menuTestIDBase}-copy-resume-command`, onSelect: () => { - void onCopyResumeCommand(tab.agentId); + void onCopyResumeCommand(agentId); }, }); entries.push({ @@ -84,7 +85,7 @@ export function buildWorkspaceTabMenuEntries( label: "Copy agent id", testID: `${menuTestIDBase}-copy-agent-id`, onSelect: () => { - void onCopyAgentId(tab.agentId); + void onCopyAgentId(agentId); }, }); entries.push({ diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index aef4b800a..25f312c6c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -1,85 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { Agent } from "@/stores/session-store"; import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; -function makeAgent(input: { - id: string; - provider?: Agent["provider"]; - title?: string | null; - createdAt?: Date; - lastActivityAt?: Date; - requiresAttention?: boolean; - attentionReason?: Agent["attentionReason"]; -}): Agent { - const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z"); - const lastActivityAt = input.lastActivityAt ?? createdAt; - return { - serverId: "srv", - id: input.id, - provider: input.provider ?? "codex", - status: "idle", - createdAt, - updatedAt: createdAt, - lastUserMessageAt: null, - lastActivityAt, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: input.provider ?? "codex", - sessionId: null, - }, - title: input.title ?? null, - cwd: "/repo/worktree", - model: null, - thinkingOptionId: null, - labels: {}, - requiresAttention: input.requiresAttention ?? false, - attentionReason: input.attentionReason ?? null, - attentionTimestamp: null, - archivedAt: null, - }; -} - describe("deriveWorkspaceTabModel", () => { - it("derives agent and terminal tabs from domain state, not UI membership", () => { - const model = deriveWorkspaceTabModel({ - workspaceAgents: [ - makeAgent({ id: "agent-a", title: "Build API" }), - makeAgent({ id: "agent-b", title: "" }), - ], - terminals: [{ id: "term-1", name: "shell" }], - tabs: [ - { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, - { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, - { tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 }, - ], - tabOrder: [], - }); - - expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([ - "agent_agent-a", - "agent_agent-b", - "terminal_term-1", - ]); - const firstAgent = model.tabs[0]?.descriptor; - const secondAgent = model.tabs[1]?.descriptor; - expect(firstAgent?.kind === "agent" ? firstAgent.titleState : null).toBe("ready"); - expect(secondAgent?.kind === "agent" ? secondAgent.titleState : null).toBe("loading"); - expect(secondAgent?.label).toBe(""); - }); - - it("keeps draft and file tabs as explicit UI tabs", () => { + it("keeps normalized tabs in stored order and preserves targets", () => { const uiTabs: WorkspaceTab[] = [ { tabId: "draft_123", @@ -91,26 +15,33 @@ describe("deriveWorkspaceTabModel", () => { target: { kind: "file", path: "/repo/worktree/README.md" }, createdAt: 2, }, + { + tabId: "agent_agent-a", + target: { kind: "agent", agentId: "agent-a" }, + createdAt: 3, + }, ]; const model = deriveWorkspaceTabModel({ - workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })], - terminals: [], - tabs: [ - ...uiTabs, - { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 3 }, - ], + tabs: uiTabs, tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"], }); - expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]); - expect(model.tabs[0]?.descriptor.label).toBe("New Agent"); + expect(model.tabs.map((tab) => tab.descriptor.tabId)).toEqual([ + "draft_123", + "agent_agent-a", + "file_/repo/worktree/README.md", + ]); + expect(model.tabs[0]?.descriptor.target).toEqual({ kind: "draft", draftId: "draft_123" }); + expect(model.tabs[1]?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-a" }); + expect(model.tabs[2]?.descriptor.target).toEqual({ + kind: "file", + path: "/repo/worktree/README.md", + }); }); - it("applies stored order and appends newly-derived tabs deterministically", () => { + it("applies stored order and appends unordered tabs deterministically", () => { const model = deriveWorkspaceTabModel({ - workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], - terminals: [{ id: "term-1", name: "zsh" }], tabs: [ { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, @@ -128,8 +59,6 @@ describe("deriveWorkspaceTabModel", () => { it("uses focused tab when present, otherwise falls back to first tab", () => { const base: Parameters[0] = { - workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], - terminals: [], tabs: [ { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, @@ -144,18 +73,11 @@ describe("deriveWorkspaceTabModel", () => { }).activeTabId ).toBe("agent_agent-b"); - expect( - deriveWorkspaceTabModel({ - ...base, - focusedTabId: "agent_agent-b", - }).activeTabId - ).toBe("agent_agent-b"); + expect(deriveWorkspaceTabModel(base).activeTabId).toBe("agent_agent-a"); }); it("prefers the route-selected target over stale focused tab state", () => { const model = deriveWorkspaceTabModel({ - workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], - terminals: [], tabs: [ { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, @@ -166,59 +88,11 @@ describe("deriveWorkspaceTabModel", () => { }); expect(model.activeTabId).toBe("agent_agent-b"); - expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" }); + expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-b" }); }); - it("re-resolves active content for a new workspace when prior focused tab is not available", () => { + it("keeps retargeted tab ids stable while matching upgraded targets", () => { const model = deriveWorkspaceTabModel({ - workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })], - terminals: [], - tabs: [ - { - tabId: "agent_workspace-b-agent", - target: { kind: "agent", agentId: "workspace-b-agent" }, - createdAt: 1, - }, - ], - tabOrder: ["agent_workspace-b-agent"], - focusedTabId: "agent_workspace-a-agent", - }); - - expect(model.activeTabId).toBe("agent_workspace-b-agent"); - expect(model.activeTab?.target).toEqual({ - kind: "agent", - agentId: "workspace-b-agent", - }); - }); - - it("does not materialize tabs absent from workspace tab membership", () => { - const offending = makeAgent({ - id: "offender", - title: "Needs permission", - requiresAttention: true, - attentionReason: "permission", - }); - - const model = deriveWorkspaceTabModel({ - workspaceAgents: [offending], - terminals: [], - tabs: [ - { - tabId: "draft_123", - target: { kind: "draft", draftId: "draft_123" }, - createdAt: 1, - }, - ], - tabOrder: ["draft_123"], - }); - - expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(false); - }); - - it("keeps retargeted agent tab id stable while upgrading descriptor data", () => { - const model = deriveWorkspaceTabModel({ - workspaceAgents: [], - terminals: [], tabs: [ { tabId: "draft_abc", @@ -227,52 +101,35 @@ describe("deriveWorkspaceTabModel", () => { }, ], tabOrder: ["draft_abc"], - }); - const initial = model.tabs[0]?.descriptor; - expect(initial?.tabId).toBe("draft_abc"); - expect(initial?.kind).toBe("agent"); - if (initial?.kind === "agent") { - expect(initial.titleState).toBe("loading"); - expect(initial.agentId).toBe("agent-1"); - } - - const upgraded = deriveWorkspaceTabModel({ - workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })], - terminals: [], - tabs: [ - { - tabId: "draft_abc", - target: { kind: "agent", agentId: "agent-1" }, - createdAt: 1, - }, - ], - tabOrder: ["draft_abc"], - }); - const upgradedDescriptor = upgraded.tabs[0]?.descriptor; - expect(upgradedDescriptor?.tabId).toBe("draft_abc"); - if (upgradedDescriptor?.kind === "agent") { - expect(upgradedDescriptor.titleState).toBe("ready"); - expect(upgradedDescriptor.label).toBe("Ready title"); - } - }); - - it("prefers a retargeted tab when the route selects its upgraded agent target", () => { - const model = deriveWorkspaceTabModel({ - workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })], - terminals: [], - tabs: [ - { - tabId: "draft_abc", - target: { kind: "agent", agentId: "agent-1" }, - createdAt: 1, - }, - ], - tabOrder: ["draft_abc"], - focusedTabId: "some_other_tab", preferredTarget: { kind: "agent", agentId: "agent-1" }, }); expect(model.activeTabId).toBe("draft_abc"); expect(model.activeTab?.descriptor.tabId).toBe("draft_abc"); + expect(model.activeTab?.descriptor.target).toEqual({ kind: "agent", agentId: "agent-1" }); + }); + + it("normalizes file paths and discards invalid tabs", () => { + const model = deriveWorkspaceTabModel({ + tabs: [ + { + tabId: "file_path", + target: { kind: "file", path: "\\repo\\worktree\\README.md" }, + createdAt: 1, + }, + { + tabId: "", + target: { kind: "agent", agentId: "agent-a" }, + createdAt: 2, + }, + ], + tabOrder: [], + }); + + expect(model.tabs).toHaveLength(1); + expect(model.tabs[0]?.descriptor.target).toEqual({ + kind: "file", + path: "/repo/worktree/README.md", + }); }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 121aed287..2a5ba4096 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -1,22 +1,15 @@ -import type { Agent } from "@/stores/session-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -type TerminalLike = { - id: string; - name?: string | null; -}; - -export type WorkspaceDerivedTab = { +export interface WorkspaceDerivedTab { descriptor: WorkspaceTabDescriptor; - target: WorkspaceTabTarget; -}; +} -export type WorkspaceTabModel = { +export interface WorkspaceTabModel { tabs: WorkspaceDerivedTab[]; activeTabId: string | null; activeTab: WorkspaceDerivedTab | null; -}; +} function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { @@ -45,33 +38,6 @@ function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): b return false; } -function formatProviderLabel(provider: Agent["provider"]): string { - if (provider === "claude") { - return "Claude"; - } - if (provider === "codex") { - return "Codex"; - } - if (!provider) { - return "Agent"; - } - return provider.charAt(0).toUpperCase() + provider.slice(1); -} - -function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null { - if (typeof title !== "string") { - return null; - } - const normalized = title.trim(); - if (!normalized) { - return null; - } - if (normalized.toLowerCase() === "new agent") { - return null; - } - return normalized; -} - function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null { if (!tab || typeof tab !== "object") { return null; @@ -144,16 +110,12 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string { } export function deriveWorkspaceTabModel(input: { - workspaceAgents: Agent[]; - terminals: TerminalLike[]; tabs: WorkspaceTab[]; tabOrder: string[]; focusedTabId?: string | null; preferredTarget?: WorkspaceTabTarget | null; }): WorkspaceTabModel { const tabsById = new Map(); - const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent])); - const terminalsById = new Map(input.terminals.map((terminal) => [terminal.id, terminal])); const normalizedTabs = input.tabs .map((tab) => normalizeWorkspaceTab(tab)) @@ -161,72 +123,14 @@ export function deriveWorkspaceTabModel(input: { .sort((left, right) => left.createdAt - right.createdAt); for (const tab of normalizedTabs) { - if (tab.target.kind === "draft") { - tabsById.set(tab.tabId, { + tabsById.set(tab.tabId, { + descriptor: { + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, target: tab.target, - descriptor: { - key: tab.tabId, - tabId: tab.tabId, - kind: "draft", - draftId: tab.target.draftId, - label: "New Agent", - subtitle: "New Agent", - }, - }); - continue; - } - - if (tab.target.kind === "agent") { - const agent = agentsById.get(tab.target.agentId) ?? null; - const label = resolveWorkspaceAgentTabLabel(agent?.title); - const provider = agent?.provider ?? "codex"; - tabsById.set(tab.tabId, { - target: tab.target, - descriptor: { - key: tab.tabId, - tabId: tab.tabId, - kind: "agent", - agentId: tab.target.agentId, - provider, - label: label ?? "", - subtitle: `${formatProviderLabel(provider)} agent`, - titleState: label ? "ready" : "loading", - }, - }); - continue; - } - - if (tab.target.kind === "terminal") { - const terminal = terminalsById.get(tab.target.terminalId) ?? null; - tabsById.set(tab.tabId, { - target: tab.target, - descriptor: { - key: tab.tabId, - tabId: tab.tabId, - kind: "terminal", - terminalId: tab.target.terminalId, - label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal", - subtitle: "Terminal", - }, - }); - continue; - } - - if (tab.target.kind === "file") { - const filePath = tab.target.path; - const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath; - tabsById.set(tab.tabId, { - target: tab.target, - descriptor: { - key: tab.tabId, - tabId: tab.tabId, - kind: "file", - filePath, - label: fileName, - subtitle: filePath, - }, - }); - } + }, + }); } const orderedTabIds: string[] = []; @@ -260,7 +164,7 @@ export function deriveWorkspaceTabModel(input: { return null; } const matchingTab = - tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null; + tabs.find((tab) => tabTargetsEqual(tab.descriptor.target, preferredTarget)) ?? null; return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget); })(); @@ -268,8 +172,8 @@ export function deriveWorkspaceTabModel(input: { preferredTabId && openTabIds.has(preferredTabId) ? preferredTabId : focusedTabId && openTabIds.has(focusedTabId) - ? focusedTabId - : tabs[0]?.descriptor.tabId ?? null; + ? focusedTabId + : tabs[0]?.descriptor.tabId ?? null; const activeTab = activeTabId ? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index a4e2b7ffd..84c57b4d8 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -1,50 +1,47 @@ import type { ReactElement, ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; -import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native"; +import { Check } 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 invariant from "tiny-invariant"; import { SyncedLoader } from "@/components/synced-loader"; -import type { Agent } from "@/stores/session-store"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import { getPanelRegistration } from "@/panels/panel-registry"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { - deriveSidebarStateBucket, - type SidebarStateBucket, -} from "@/utils/sidebar-agent-state"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; -export type WorkspaceTabPresentation = { +export interface WorkspaceTabPresentation { key: string; kind: WorkspaceTabDescriptor["kind"]; label: string; subtitle: string; titleState: "ready" | "loading"; - provider: Agent["provider"] | null; + icon: React.ComponentType<{ size: number; color: string }>; statusBucket: SidebarStateBucket | null; -}; +} -export function deriveWorkspaceTabPresentation(input: { +export function useWorkspaceTabPresentation(input: { tab: WorkspaceTabDescriptor; - agent?: Agent | null; + serverId: string; + workspaceId: string; }): WorkspaceTabPresentation { - const { tab, agent = null } = input; + ensurePanelsRegistered(); + const registration = getPanelRegistration(input.tab.kind); + invariant(registration, `No panel registration for kind: ${input.tab.kind}`); + const descriptor = registration.useDescriptor(input.tab.target, { + serverId: input.serverId, + workspaceId: input.workspaceId, + }); + 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, + key: input.tab.key, + kind: input.tab.kind, + label: descriptor.label, + subtitle: descriptor.subtitle, + titleState: descriptor.titleState, + icon: descriptor.icon, + statusBucket: descriptor.statusBucket, }; } @@ -74,49 +71,32 @@ export function WorkspaceTabIcon({ const shouldShowLoader = shouldRenderSyncedStatusLoader({ bucket: presentation.statusBucket, }); + const Icon = presentation.icon; - if (presentation.kind === "agent") { - if (shouldShowLoader) { - return ( - - - - ); - } - + if (shouldShowLoader) { return ( - {presentation.provider === "claude" ? ( - - ) : presentation.provider === "codex" ? ( - - ) : ( - - )} - {statusDotColor ? ( - - ) : null} + ); } - if (presentation.kind === "draft") { - return ; - } - - if (presentation.kind === "file") { - return ; - } - - return ; + return ( + + + {statusDotColor ? ( + + ) : null} + + ); } type WorkspaceTabOptionRowProps = { diff --git a/packages/app/src/screens/workspace/workspace-tabs-types.ts b/packages/app/src/screens/workspace/workspace-tabs-types.ts index 77c9c10ef..ef1a09e21 100644 --- a/packages/app/src/screens/workspace/workspace-tabs-types.ts +++ b/packages/app/src/screens/workspace/workspace-tabs-types.ts @@ -1,37 +1,8 @@ -import type { Agent } from "@/stores/session-store"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; -export type WorkspaceTabDescriptor = - | { - key: string; - tabId: string; - kind: "draft"; - draftId: string; - label: string; - subtitle: string; - } - | { - key: string; - tabId: string; - kind: "agent"; - agentId: string; - provider: Agent["provider"]; - label: string; - subtitle: string; - titleState: "ready" | "loading"; - } - | { - key: string; - tabId: string; - kind: "terminal"; - terminalId: string; - label: string; - subtitle: string; - } - | { - key: string; - tabId: string; - kind: "file"; - filePath: string; - label: string; - subtitle: string; - }; +export interface WorkspaceTabDescriptor { + key: string; + tabId: string; + kind: WorkspaceTabTarget["kind"]; + target: WorkspaceTabTarget; +}