feat: workspace-level sidebar shortcuts with shortcut badges and desktop tabs extraction
This commit is contained in:
@@ -58,6 +58,7 @@ import {
|
|||||||
type SortOption,
|
type SortOption,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import {
|
import {
|
||||||
WebDesktopScrollbarOverlay,
|
WebDesktopScrollbarOverlay,
|
||||||
useWebDesktopScrollbarMetrics,
|
useWebDesktopScrollbarMetrics,
|
||||||
@@ -258,9 +259,17 @@ export function FileExplorerPane({
|
|||||||
[handleOpenFile, handleToggleDirectory]
|
[handleOpenFile, handleToggleDirectory]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyPath = useCallback(async (path: string) => {
|
const handleCopyPath = useCallback(
|
||||||
await Clipboard.setStringAsync(path);
|
async (path: string) => {
|
||||||
}, []);
|
await Clipboard.setStringAsync(
|
||||||
|
buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
|
entryPath: path,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[normalizedWorkspaceRoot]
|
||||||
|
);
|
||||||
|
|
||||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||||
const handleDownloadEntry = useCallback(
|
const handleDownloadEntry = useCallback(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
|
|||||||
import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list'
|
import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list'
|
||||||
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
|
||||||
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
|
||||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
|
||||||
import { Combobox } from '@/components/ui/combobox'
|
import { Combobox } from '@/components/ui/combobox'
|
||||||
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
|
import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
|
||||||
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore } from '@/runtime/host-runtime'
|
||||||
@@ -146,13 +145,6 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
}
|
}
|
||||||
}, [isRevalidating, isManualRefresh])
|
}, [isRevalidating, isManualRefresh])
|
||||||
|
|
||||||
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
|
|
||||||
(s) => s.setSidebarShortcutAgentKeys
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
|
||||||
setSidebarShortcutAgentKeys([])
|
|
||||||
}, [setSidebarShortcutAgentKeys])
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
closeToAgent()
|
closeToAgent()
|
||||||
}, [closeToAgent])
|
}, [closeToAgent])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture
|
|||||||
import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
|
import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||||
|
import { getIsTauri } from '@/constants/layout'
|
||||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from '@/hooks/use-sidebar-agents-list'
|
} from '@/hooks/use-sidebar-agents-list'
|
||||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||||
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
|
||||||
|
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||||
import { formatTimeAgo } from '@/utils/time'
|
import { formatTimeAgo } from '@/utils/time'
|
||||||
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
||||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||||
@@ -38,20 +40,11 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
useContextMenu,
|
useContextMenu,
|
||||||
} from '@/components/ui/context-menu'
|
} from '@/components/ui/context-menu'
|
||||||
|
import { deriveProjectDisplayName } from '@/utils/agent-grouping'
|
||||||
type SidebarTreeRow =
|
import {
|
||||||
| {
|
buildSidebarWorkspaceViewModel,
|
||||||
kind: 'project'
|
type SidebarWorkspaceTreeRow,
|
||||||
rowKey: string
|
} from '@/utils/sidebar-shortcuts'
|
||||||
project: SidebarProjectEntry
|
|
||||||
displayName: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'workspace'
|
|
||||||
rowKey: string
|
|
||||||
projectKey: string
|
|
||||||
workspace: SidebarWorkspaceEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
@@ -84,38 +77,12 @@ interface ProjectRowProps {
|
|||||||
interface WorkspaceRowProps {
|
interface WorkspaceRowProps {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
selected: boolean
|
selected: boolean
|
||||||
|
shortcutNumber: number | null
|
||||||
|
showShortcutBadge: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string {
|
|
||||||
const githubPrefix = 'remote:github.com/'
|
|
||||||
if (input.projectKey.startsWith(githubPrefix)) {
|
|
||||||
return input.projectKey.slice(githubPrefix.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.projectKey.startsWith('remote:')) {
|
|
||||||
const withoutPrefix = input.projectKey.slice('remote:'.length)
|
|
||||||
const slashIdx = withoutPrefix.indexOf('/')
|
|
||||||
if (slashIdx >= 0) {
|
|
||||||
const remotePath = withoutPrefix.slice(slashIdx + 1).trim()
|
|
||||||
if (remotePath.length > 0) {
|
|
||||||
return remotePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return withoutPrefix
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedProjectName = input.projectName.trim()
|
|
||||||
if (trimmedProjectName.length > 0) {
|
|
||||||
return trimmedProjectName
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = input.projectKey.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
||||||
const segments = normalized.split('/').filter(Boolean)
|
|
||||||
return segments[segments.length - 1] ?? input.projectKey
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string {
|
function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string {
|
||||||
const branch = workspace.branchName?.trim()
|
const branch = workspace.branchName?.trim()
|
||||||
if (branch && branch.length > 0) {
|
if (branch && branch.length > 0) {
|
||||||
@@ -225,7 +192,14 @@ function ProjectRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps) {
|
function WorkspaceRow({
|
||||||
|
workspace,
|
||||||
|
selected,
|
||||||
|
shortcutNumber,
|
||||||
|
showShortcutBadge,
|
||||||
|
onPress,
|
||||||
|
drag,
|
||||||
|
}: WorkspaceRowProps) {
|
||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||||
|
|
||||||
@@ -412,11 +386,18 @@ function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps)
|
|||||||
{resolveWorkspaceBranchLabel(workspace)}
|
{resolveWorkspaceBranchLabel(workspace)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{createdAtLabel ? (
|
<View style={styles.workspaceRowRight}>
|
||||||
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
{createdAtLabel ? (
|
||||||
{createdAtLabel}
|
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
||||||
</Text>
|
{createdAtLabel}
|
||||||
) : null}
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{showShortcutBadge && shortcutNumber !== null ? (
|
||||||
|
<View style={styles.shortcutBadge}>
|
||||||
|
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -453,17 +434,28 @@ function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps)
|
|||||||
function WorkspaceRowWithMenu({
|
function WorkspaceRowWithMenu({
|
||||||
workspace,
|
workspace,
|
||||||
selected,
|
selected,
|
||||||
|
shortcutNumber,
|
||||||
|
showShortcutBadge,
|
||||||
onPress,
|
onPress,
|
||||||
drag,
|
drag,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
selected: boolean
|
selected: boolean
|
||||||
|
shortcutNumber: number | null
|
||||||
|
showShortcutBadge: boolean
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
drag: () => void
|
drag: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<WorkspaceRow workspace={workspace} selected={selected} onPress={onPress} drag={drag} />
|
<WorkspaceRow
|
||||||
|
workspace={workspace}
|
||||||
|
selected={selected}
|
||||||
|
shortcutNumber={shortcutNumber}
|
||||||
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
onPress={onPress}
|
||||||
|
drag={drag}
|
||||||
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -507,6 +499,13 @@ export function SidebarAgentList({
|
|||||||
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
||||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||||
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
||||||
|
const isTauri = getIsTauri()
|
||||||
|
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||||
|
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||||
|
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
|
||||||
|
(state) => state.setSidebarShortcutWorkspaceTargets
|
||||||
|
)
|
||||||
|
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown)
|
||||||
|
|
||||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder)
|
||||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
|
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder)
|
||||||
@@ -610,34 +609,29 @@ export function SidebarAgentList({
|
|||||||
return byProject
|
return byProject
|
||||||
}, [projectIconQueries, projectIconRequests, projects, serverId])
|
}, [projectIconQueries, projectIconRequests, projects, serverId])
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const viewModel = useMemo(
|
||||||
const next: SidebarTreeRow[] = []
|
() =>
|
||||||
for (const project of projects) {
|
buildSidebarWorkspaceViewModel({
|
||||||
next.push({
|
projects,
|
||||||
kind: 'project',
|
collapsedProjectKeys,
|
||||||
rowKey: `project:${project.projectKey}`,
|
getProjectDisplayName: (project) =>
|
||||||
project,
|
deriveProjectDisplayName({
|
||||||
displayName: deriveProjectDisplayName({
|
projectKey: project.projectKey,
|
||||||
projectKey: project.projectKey,
|
projectName: project.projectName,
|
||||||
projectName: project.projectName,
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
[canonicalResyncNonce, collapsedProjectKeys, projects]
|
||||||
|
)
|
||||||
|
|
||||||
if (collapsedProjectKeys.has(project.projectKey)) {
|
useEffect(() => {
|
||||||
continue
|
setSidebarShortcutWorkspaceTargets(viewModel.shortcutTargets)
|
||||||
}
|
}, [setSidebarShortcutWorkspaceTargets, viewModel.shortcutTargets])
|
||||||
|
|
||||||
for (const workspace of project.workspaces) {
|
useEffect(() => {
|
||||||
next.push({
|
return () => {
|
||||||
kind: 'workspace',
|
setSidebarShortcutWorkspaceTargets([])
|
||||||
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
workspace,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return next
|
}, [setSidebarShortcutWorkspaceTargets])
|
||||||
}, [canonicalResyncNonce, collapsedProjectKeys, projects])
|
|
||||||
|
|
||||||
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
const toggleProjectCollapsed = useCallback((projectKey: string) => {
|
||||||
setCollapsedProjectKeys((prev) => {
|
setCollapsedProjectKeys((prev) => {
|
||||||
@@ -652,7 +646,7 @@ export function SidebarAgentList({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const renderRow = useCallback(
|
const renderRow = useCallback(
|
||||||
({ item, drag }: DraggableRenderItemInfo<SidebarTreeRow>) => {
|
({ item, drag }: DraggableRenderItemInfo<SidebarWorkspaceTreeRow>) => {
|
||||||
if (item.kind === 'project') {
|
if (item.kind === 'project') {
|
||||||
return (
|
return (
|
||||||
<ProjectRow
|
<ProjectRow
|
||||||
@@ -677,6 +671,8 @@ export function SidebarAgentList({
|
|||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
workspace={item.workspace}
|
workspace={item.workspace}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
|
shortcutNumber={item.shortcutNumber}
|
||||||
|
showShortcutBadge={showShortcutBadges}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return
|
return
|
||||||
@@ -691,19 +687,19 @@ export function SidebarAgentList({
|
|||||||
[
|
[
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
isMobile,
|
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
projectIconByProjectKey,
|
projectIconByProjectKey,
|
||||||
serverId,
|
serverId,
|
||||||
|
showShortcutBadges,
|
||||||
shouldReplaceWorkspaceNavigation,
|
shouldReplaceWorkspaceNavigation,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const keyExtractor = useCallback((entry: SidebarTreeRow) => entry.rowKey, [])
|
const keyExtractor = useCallback((entry: SidebarWorkspaceTreeRow) => entry.rowKey, [])
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(reorderedRows: SidebarTreeRow[]) => {
|
(reorderedRows: SidebarWorkspaceTreeRow[]) => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -711,7 +707,8 @@ export function SidebarAgentList({
|
|||||||
let didPersistOrderChange = false
|
let didPersistOrderChange = false
|
||||||
const reorderedProjectKeys = reorderedRows
|
const reorderedProjectKeys = reorderedRows
|
||||||
.filter(
|
.filter(
|
||||||
(row): row is Extract<SidebarTreeRow, { kind: 'project' }> => row.kind === 'project'
|
(row): row is Extract<SidebarWorkspaceTreeRow, { kind: 'project' }> =>
|
||||||
|
row.kind === 'project'
|
||||||
)
|
)
|
||||||
.map((row) => row.project.projectKey)
|
.map((row) => row.project.projectKey)
|
||||||
|
|
||||||
@@ -776,7 +773,7 @@ export function SidebarAgentList({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<DraggableList
|
<DraggableList
|
||||||
data={rows}
|
data={viewModel.rows}
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
testID="sidebar-project-workspace-list-scroll"
|
testID="sidebar-project-workspace-list-scroll"
|
||||||
@@ -879,6 +876,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
workspaceRowRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
workspaceRowHovered: {
|
workspaceRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
@@ -913,4 +916,21 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
shortcutBadge: {
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
paddingHorizontal: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
shortcutBadgeText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 14,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import TerminalEmulator from "./terminal-emulator";
|
|||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
selectedTerminalId?: string | null;
|
selectedTerminalId: string | null;
|
||||||
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
|
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
manageTerminalDirectorySubscription?: boolean;
|
manageTerminalDirectorySubscription?: boolean;
|
||||||
@@ -142,7 +142,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI
|
|||||||
export function TerminalPane({
|
export function TerminalPane({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
selectedTerminalId: selectedTerminalIdProp,
|
selectedTerminalId,
|
||||||
onSelectedTerminalIdChange,
|
onSelectedTerminalIdChange,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
manageTerminalDirectorySubscription = true,
|
manageTerminalDirectorySubscription = true,
|
||||||
@@ -165,19 +165,10 @@ export function TerminalPane({
|
|||||||
|
|
||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
const outputPumpRef = useRef<TerminalOutputPump | null>(null);
|
||||||
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
|
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
|
||||||
const isSelectedTerminalControlled =
|
|
||||||
typeof selectedTerminalIdProp !== "undefined";
|
|
||||||
|
|
||||||
const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] =
|
|
||||||
useState<string | null>(null);
|
|
||||||
const selectedTerminalId = isSelectedTerminalControlled
|
|
||||||
? selectedTerminalIdProp ?? null
|
|
||||||
: uncontrolledSelectedTerminalId;
|
|
||||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
text: "",
|
text: "",
|
||||||
@@ -203,25 +194,10 @@ export function TerminalPane({
|
|||||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
|
|
||||||
const updateSelectedTerminalId = useCallback(
|
const updateSelectedTerminalId = useCallback(
|
||||||
(
|
(next: string | null) => {
|
||||||
next:
|
onSelectedTerminalIdChange?.(next);
|
||||||
| string
|
|
||||||
| null
|
|
||||||
| ((current: string | null) => string | null)
|
|
||||||
) => {
|
|
||||||
const current = selectedTerminalIdRef.current;
|
|
||||||
const resolved =
|
|
||||||
typeof next === "function"
|
|
||||||
? (next as (value: string | null) => string | null)(current)
|
|
||||||
: next;
|
|
||||||
selectedTerminalIdRef.current = resolved;
|
|
||||||
if (isSelectedTerminalControlled) {
|
|
||||||
onSelectedTerminalIdChange?.(resolved);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUncontrolledSelectedTerminalId(resolved);
|
|
||||||
},
|
},
|
||||||
[isSelectedTerminalControlled, onSelectedTerminalIdChange]
|
[onSelectedTerminalIdChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -469,9 +445,6 @@ export function TerminalPane({
|
|||||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!isSelectedTerminalControlled) {
|
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
|
|
||||||
}
|
|
||||||
updateSelectedTerminalId(createdTerminal.id);
|
updateSelectedTerminalId(createdTerminal.id);
|
||||||
requestTerminalFocus();
|
requestTerminalFocus();
|
||||||
}
|
}
|
||||||
@@ -496,9 +469,7 @@ export function TerminalPane({
|
|||||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||||
outputPumpRef.current?.clearTerminal({ terminalId });
|
outputPumpRef.current?.clearTerminal({ terminalId });
|
||||||
if (selectedTerminalIdRef.current === terminalId) {
|
if (selectedTerminalIdRef.current === terminalId) {
|
||||||
updateSelectedTerminalId((current) =>
|
updateSelectedTerminalId(null);
|
||||||
current === terminalId ? null : current
|
|
||||||
);
|
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
@@ -512,56 +483,8 @@ export function TerminalPane({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSelectedTerminalControlled) {
|
|
||||||
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
|
|
||||||
}
|
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
}, [isSelectedTerminalControlled, scopeKey, updateSelectedTerminalId]);
|
}, [scopeKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSelectedTerminalControlled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedTerminalId) {
|
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId);
|
|
||||||
}
|
|
||||||
}, [isSelectedTerminalControlled, scopeKey, selectedTerminalId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (terminals.length === 0) {
|
|
||||||
updateSelectedTerminalId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const has = (id: string | null | undefined) =>
|
|
||||||
Boolean(id && terminals.some((terminal) => terminal.id === id));
|
|
||||||
|
|
||||||
if (has(selectedTerminalId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = isSelectedTerminalControlled
|
|
||||||
? null
|
|
||||||
: selectedTerminalByScopeRef.current.get(scopeKey);
|
|
||||||
if (has(stored)) {
|
|
||||||
updateSelectedTerminalId(stored!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = terminals[0]?.id ?? null;
|
|
||||||
if (fallback) {
|
|
||||||
if (!isSelectedTerminalControlled) {
|
|
||||||
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
|
|
||||||
}
|
|
||||||
updateSelectedTerminalId(fallback);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isSelectedTerminalControlled,
|
|
||||||
scopeKey,
|
|
||||||
terminals,
|
|
||||||
selectedTerminalId,
|
|
||||||
updateSelectedTerminalId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminalIds = terminals.map((terminal) => terminal.id);
|
const terminalIds = terminals.map((terminal) => terminal.id);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { usePathname, useRouter } from "expo-router";
|
|||||||
import { getIsTauri } from "@/constants/layout";
|
import { getIsTauri } from "@/constants/layout";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
@@ -17,8 +16,9 @@ import {
|
|||||||
resolveNewAgentWorkingDir,
|
resolveNewAgentWorkingDir,
|
||||||
} from "@/utils/new-agent-routing";
|
} from "@/utils/new-agent-routing";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostWorkspaceRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import {
|
import {
|
||||||
@@ -70,20 +70,16 @@ export function useKeyboardShortcuts({
|
|||||||
|
|
||||||
const navigateToSidebarShortcut = (digit: number): boolean => {
|
const navigateToSidebarShortcut = (digit: number): boolean => {
|
||||||
const state = useKeyboardShortcutsStore.getState();
|
const state = useKeyboardShortcutsStore.getState();
|
||||||
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
|
const target = state.sidebarShortcutWorkspaceTargets[digit - 1] ?? null;
|
||||||
if (!targetKey) {
|
if (!target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseSidebarAgentKey(targetKey);
|
const shouldReplace =
|
||||||
if (!parsed) {
|
Boolean(parseHostWorkspaceRouteFromPathname(pathname)) ||
|
||||||
return false;
|
Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||||
}
|
|
||||||
const { serverId, agentId } = parsed;
|
|
||||||
|
|
||||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
|
||||||
const navigate = shouldReplace ? router.replace : router.push;
|
const navigate = shouldReplace ? router.replace : router.push;
|
||||||
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
navigate(buildHostWorkspaceRoute(target.serverId, target.workspaceId) as any);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
|
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
|
||||||
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
|
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
|
||||||
"alt",
|
"alt",
|
||||||
"1-9",
|
"1-9",
|
||||||
]);
|
]);
|
||||||
@@ -213,7 +213,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
|
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
|
||||||
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
|
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
|
||||||
"mod",
|
"mod",
|
||||||
"1-9",
|
"1-9",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -318,9 +318,9 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
when: (context) => !context.commandCenterOpen,
|
when: (context) => !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "quick-open-agent",
|
id: "quick-open-workspace",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Open sidebar agent shortcut",
|
label: "Open sidebar workspace shortcut",
|
||||||
keys: ["alt", "1-9"],
|
keys: ["alt", "1-9"],
|
||||||
when: (context) => !context.isTauri,
|
when: (context) => !context.isTauri,
|
||||||
},
|
},
|
||||||
@@ -335,9 +335,9 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||||
help: {
|
help: {
|
||||||
id: "quick-open-agent",
|
id: "quick-open-workspace",
|
||||||
section: "global",
|
section: "global",
|
||||||
label: "Open sidebar agent shortcut",
|
label: "Open sidebar workspace shortcut",
|
||||||
keys: ["mod", "1-9"],
|
keys: ["mod", "1-9"],
|
||||||
when: (context) => context.isTauri,
|
when: (context) => context.isTauri,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1032,7 +1032,6 @@ function DraftAgentScreenContent({
|
|||||||
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
|
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
void createAgentClient.refreshAgent(agentId).catch(() => undefined)
|
|
||||||
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
|
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import type { LayoutChangeEvent } from "react-native";
|
||||||
|
import {
|
||||||
|
computeWorkspaceTabLayout,
|
||||||
|
type WorkspaceTabLayoutResult,
|
||||||
|
} from "@/screens/workspace/workspace-tab-layout";
|
||||||
|
|
||||||
|
type UseWorkspaceTabLayoutInput = {
|
||||||
|
tabLabels: string[];
|
||||||
|
metrics: {
|
||||||
|
rowPaddingHorizontal: number;
|
||||||
|
tabGap: number;
|
||||||
|
maxTabWidth: number;
|
||||||
|
iconOnlyTabWidth: number;
|
||||||
|
tabBaseWidthWithClose: number;
|
||||||
|
minLabelChars: number;
|
||||||
|
charWidth: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseWorkspaceTabLayoutResult = {
|
||||||
|
layout: WorkspaceTabLayoutResult;
|
||||||
|
onContainerLayout: (event: LayoutChangeEvent) => void;
|
||||||
|
onActionsLayout: (event: LayoutChangeEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWorkspaceTabLayoutResult {
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
const [actionsWidth, setActionsWidth] = useState(0);
|
||||||
|
|
||||||
|
const onContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
setContainerWidth(event.nativeEvent.layout.width);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onActionsLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
setActionsWidth(event.nativeEvent.layout.width);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const layout = useMemo(
|
||||||
|
() =>
|
||||||
|
computeWorkspaceTabLayout({
|
||||||
|
containerWidth,
|
||||||
|
actionsWidth,
|
||||||
|
tabLabelLengths: input.tabLabels.map((label) => label.length),
|
||||||
|
metrics: input.metrics,
|
||||||
|
}),
|
||||||
|
[actionsWidth, containerWidth, input.metrics, input.tabLabels]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
onContainerLayout,
|
||||||
|
onActionsLayout,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import { useMemo, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
|
import { Bot, FileText, Pencil, Plus, SquareTerminal, Terminal, 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,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const DROPDOWN_WIDTH = 220;
|
||||||
|
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
|
||||||
|
|
||||||
|
type WorkspaceDesktopTabsRowProps = {
|
||||||
|
tabs: WorkspaceTabDescriptor[];
|
||||||
|
activeTabKey: string;
|
||||||
|
agentsById: Map<string, Agent>;
|
||||||
|
normalizedServerId: string;
|
||||||
|
hoveredCloseTabKey: string | null;
|
||||||
|
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
|
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
|
||||||
|
isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean;
|
||||||
|
killTerminalPending: boolean;
|
||||||
|
killTerminalId: string | null;
|
||||||
|
onNavigateTab: (tabId: string) => void;
|
||||||
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
|
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
|
||||||
|
newTabAgentOptionId: NewTabOptionId;
|
||||||
|
newTabTerminalOptionId: NewTabOptionId;
|
||||||
|
createTerminalPending: boolean;
|
||||||
|
isNewTerminalHovered: boolean;
|
||||||
|
setIsNewTerminalHovered: Dispatch<SetStateAction<boolean>>;
|
||||||
|
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceDesktopTabsRow({
|
||||||
|
tabs,
|
||||||
|
activeTabKey,
|
||||||
|
agentsById,
|
||||||
|
normalizedServerId,
|
||||||
|
hoveredCloseTabKey,
|
||||||
|
setHoveredTabKey,
|
||||||
|
setHoveredCloseTabKey,
|
||||||
|
isArchivingAgent,
|
||||||
|
killTerminalPending,
|
||||||
|
killTerminalId,
|
||||||
|
onNavigateTab,
|
||||||
|
onCloseTab,
|
||||||
|
onCopyResumeCommand,
|
||||||
|
onCopyAgentId,
|
||||||
|
onCloseTabsToRight,
|
||||||
|
onSelectNewTabOption,
|
||||||
|
newTabAgentOptionId,
|
||||||
|
newTabTerminalOptionId,
|
||||||
|
createTerminalPending,
|
||||||
|
isNewTerminalHovered,
|
||||||
|
setIsNewTerminalHovered,
|
||||||
|
onReorderTabs,
|
||||||
|
}: WorkspaceDesktopTabsRowProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
const layoutMetrics = useMemo(
|
||||||
|
() => ({
|
||||||
|
rowPaddingHorizontal: theme.spacing[2],
|
||||||
|
tabGap: theme.spacing[1],
|
||||||
|
maxTabWidth: 260,
|
||||||
|
iconOnlyTabWidth: 40,
|
||||||
|
tabBaseWidthWithClose: 84,
|
||||||
|
minLabelChars: 4,
|
||||||
|
charWidth: 7,
|
||||||
|
}),
|
||||||
|
[theme.spacing]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { layout, onContainerLayout, onActionsLayout } = useWorkspaceTabLayout({
|
||||||
|
tabLabels: tabs.map((tab) => tab.label),
|
||||||
|
metrics: layoutMetrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.tabsContainer} testID="workspace-tabs-row" onLayout={onContainerLayout}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
scrollEnabled={layout.shouldScroll}
|
||||||
|
testID="workspace-tabs-scroll"
|
||||||
|
style={styles.tabsScroll}
|
||||||
|
contentContainerStyle={styles.tabsContent}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SortableInlineList
|
||||||
|
data={tabs}
|
||||||
|
keyExtractor={(tab) => tab.key}
|
||||||
|
useDragHandle
|
||||||
|
disabled={tabs.length < 2}
|
||||||
|
onDragEnd={onReorderTabs}
|
||||||
|
renderItem={({ item: tab, dragHandleProps, index }) => {
|
||||||
|
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" &&
|
||||||
|
isArchivingAgent({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
agentId: tab.agentId,
|
||||||
|
});
|
||||||
|
const isClosingTerminal =
|
||||||
|
tab.kind === "terminal" && killTerminalPending && killTerminalId === tab.terminalId;
|
||||||
|
const isClosingTab = isClosingAgent || isClosingTerminal;
|
||||||
|
const shouldShowCloseButton = layout.showCloseButtons;
|
||||||
|
const iconColor = isActive ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
const tabWidth = layout.tabWidths[index] ?? layoutMetrics.maxTabWidth;
|
||||||
|
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 contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu key={tab.key}>
|
||||||
|
<ContextMenuTrigger
|
||||||
|
testID={`workspace-tab-${tab.key}`}
|
||||||
|
enabledOnMobile={false}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.tab,
|
||||||
|
{ width: tabWidth, minWidth: tabWidth, maxWidth: tabWidth },
|
||||||
|
layout.mode === "icon" && styles.tabIconOnly,
|
||||||
|
isActive && styles.tabActive,
|
||||||
|
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||||
|
]}
|
||||||
|
onHoverIn={() => {
|
||||||
|
setHoveredTabKey(tab.key);
|
||||||
|
}}
|
||||||
|
onHoverOut={() => {
|
||||||
|
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
onNavigateTab(tab.tabId);
|
||||||
|
}}
|
||||||
|
accessibilityLabel={tab.label}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
{...(dragHandleProps?.attributes as any)}
|
||||||
|
{...(dragHandleProps?.listeners as any)}
|
||||||
|
ref={(node: unknown) => {
|
||||||
|
dragHandleProps?.setActivatorNodeRef?.(node);
|
||||||
|
}}
|
||||||
|
style={styles.tabHandle}
|
||||||
|
>
|
||||||
|
<View style={styles.tabIcon}>{icon}</View>
|
||||||
|
{layout.showLabels ? (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.tabLabel,
|
||||||
|
isActive && styles.tabLabelActive,
|
||||||
|
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{shouldShowCloseButton ? (
|
||||||
|
<Pressable
|
||||||
|
testID={
|
||||||
|
tab.kind === "agent"
|
||||||
|
? `workspace-agent-close-${tab.agentId}`
|
||||||
|
: tab.kind === "terminal"
|
||||||
|
? `workspace-terminal-close-${tab.terminalId}`
|
||||||
|
: tab.kind === "draft"
|
||||||
|
? `workspace-draft-close-${tab.draftId}`
|
||||||
|
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||||
|
}
|
||||||
|
disabled={isClosingTab}
|
||||||
|
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,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isClosingTab ? (
|
||||||
|
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<X size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
|
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||||
|
{tab.kind === "agent" ? (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
testID={`${contextMenuTestId}-copy-resume-command`}
|
||||||
|
onSelect={() => {
|
||||||
|
void onCopyResumeCommand(tab.agentId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy resume command
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
testID={`${contextMenuTestId}-copy-agent-id`}
|
||||||
|
onSelect={() => {
|
||||||
|
void onCopyAgentId(tab.agentId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy agent id
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
testID={`${contextMenuTestId}-close-right`}
|
||||||
|
disabled={tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1}
|
||||||
|
onSelect={() => {
|
||||||
|
void onCloseTabsToRight(tab.tabId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close to the right
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
testID={`${contextMenuTestId}-close`}
|
||||||
|
onSelect={() => {
|
||||||
|
void onCloseTab(tab.tabId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
<View style={styles.tabsActions} onLayout={onActionsLayout}>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-agent-tab"
|
||||||
|
onPress={() => onSelectNewTabOption(newTabAgentOptionId)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New agent tab"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.newTabActionButton,
|
||||||
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" offset={8}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-terminal-tab"
|
||||||
|
onPress={() => onSelectNewTabOption(newTabTerminalOptionId)}
|
||||||
|
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||||
|
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||||
|
disabled={createTerminalPending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New terminal tab"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.newTabActionButton,
|
||||||
|
createTerminalPending && styles.newTabActionButtonDisabled,
|
||||||
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{createTerminalPending ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.terminalPlusIcon}>
|
||||||
|
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
||||||
|
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" offset={8}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
tabsContainer: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tabsScroll: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
tabsContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
},
|
||||||
|
tabsActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingRight: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
tabIconOnly: {
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
tabHandle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
tabHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
tabLabel: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
tabLabelWithCloseButton: {
|
||||||
|
paddingRight: 0,
|
||||||
|
},
|
||||||
|
tabLabelActive: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
tabCloseButton: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
marginLeft: 0,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
tabCloseButtonShown: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
tabCloseButtonActive: {
|
||||||
|
backgroundColor: theme.colors.surface3,
|
||||||
|
},
|
||||||
|
newTabActionButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
newTabActionButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
newTabActionButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
terminalPlusIcon: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
terminalPlusBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
right: -7,
|
||||||
|
bottom: -7,
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.surface0,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
terminalPlusBadgeHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
newTabTooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -273,7 +273,6 @@ export function WorkspaceDraftAgentTab({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
void client.refreshAgent(agentId).catch(() => undefined);
|
|
||||||
onCreated(result);
|
onCreated(result);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
BackHandler,
|
BackHandler,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -23,7 +22,6 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Terminal,
|
Terminal,
|
||||||
X,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
import { GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -40,17 +38,13 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
Tooltip,
|
||||||
ContextMenuContent,
|
TooltipContent,
|
||||||
ContextMenuItem,
|
TooltipTrigger,
|
||||||
ContextMenuSeparator,
|
} from "@/components/ui/tooltip";
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { FilePane } from "@/components/file-pane";
|
import { FilePane } from "@/components/file-pane";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
@@ -69,7 +63,6 @@ import {
|
|||||||
buildHostWorkspaceFileRoute,
|
buildHostWorkspaceFileRoute,
|
||||||
buildHostWorkspaceTabRoute,
|
buildHostWorkspaceTabRoute,
|
||||||
buildHostWorkspaceTerminalRoute,
|
buildHostWorkspaceTerminalRoute,
|
||||||
encodeFilePathForPathSegment,
|
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
@@ -87,6 +80,14 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
|||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
|
||||||
|
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import {
|
||||||
|
deriveProjectDisplayName,
|
||||||
|
deriveProjectKey,
|
||||||
|
deriveProjectName,
|
||||||
|
deriveRemoteProjectKey,
|
||||||
|
} from "@/utils/agent-grouping";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
@@ -106,41 +107,6 @@ type WorkspaceScreenProps = {
|
|||||||
routeTabId?: string | null;
|
routeTabId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
key: string;
|
|
||||||
tabId: string;
|
|
||||||
kind: "terminal";
|
|
||||||
terminalId: string;
|
|
||||||
label: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
key: string;
|
|
||||||
tabId: string;
|
|
||||||
kind: "file";
|
|
||||||
filePath: string;
|
|
||||||
label: string;
|
|
||||||
subtitle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyWorkspaceTabOrder(input: {
|
function applyWorkspaceTabOrder(input: {
|
||||||
tabs: WorkspaceTabDescriptor[];
|
tabs: WorkspaceTabDescriptor[];
|
||||||
keys: string[];
|
keys: string[];
|
||||||
@@ -199,20 +165,15 @@ function deriveWorkspaceName(workspaceId: string): string {
|
|||||||
return last ?? workspaceId;
|
return last ?? workspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveWorkspaceHeaderTitle(input: {
|
function deriveWorkspaceProjectDisplayName(input: {
|
||||||
workspaceName: string;
|
workspaceId: string;
|
||||||
checkout: CheckoutStatusPayload | null;
|
checkout: CheckoutStatusPayload | null;
|
||||||
}): string {
|
}): string {
|
||||||
if (!input.checkout?.isGit) {
|
const projectKey =
|
||||||
return input.workspaceName;
|
deriveRemoteProjectKey(input.checkout?.remoteUrl ?? null) ??
|
||||||
}
|
deriveProjectKey(input.workspaceId);
|
||||||
|
const projectName = deriveProjectName(projectKey);
|
||||||
const branch = trimNonEmpty(input.checkout.currentBranch ?? null);
|
return deriveProjectDisplayName({ projectKey, projectName });
|
||||||
if (!branch || branch === "HEAD") {
|
|
||||||
return input.workspaceName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return branch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||||
@@ -566,13 +527,13 @@ function WorkspaceScreenContent({
|
|||||||
() => deriveWorkspaceName(normalizedWorkspaceId),
|
() => deriveWorkspaceName(normalizedWorkspaceId),
|
||||||
[normalizedWorkspaceId]
|
[normalizedWorkspaceId]
|
||||||
);
|
);
|
||||||
const headerTitle = useMemo(
|
const headerProjectName = useMemo(
|
||||||
() =>
|
() =>
|
||||||
deriveWorkspaceHeaderTitle({
|
deriveWorkspaceProjectDisplayName({
|
||||||
workspaceName,
|
workspaceId: normalizedWorkspaceId,
|
||||||
checkout: checkoutQuery.data ?? null,
|
checkout: checkoutQuery.data ?? null,
|
||||||
}),
|
}),
|
||||||
[checkoutQuery.data, workspaceName]
|
[checkoutQuery.data, normalizedWorkspaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||||
@@ -1507,9 +1468,14 @@ function WorkspaceScreenContent({
|
|||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<View style={styles.headerTitleContainer}>
|
||||||
{headerTitle}
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
</Text>
|
{workspaceName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||||
|
{headerProjectName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
@@ -1745,287 +1711,30 @@ function WorkspaceScreenContent({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.tabsContainer} testID="workspace-tabs-row">
|
<WorkspaceDesktopTabsRow
|
||||||
<ScrollView
|
tabs={tabs}
|
||||||
horizontal
|
activeTabKey={activeTabKey}
|
||||||
testID="workspace-tabs-scroll"
|
agentsById={agentsById}
|
||||||
style={styles.tabsScroll}
|
normalizedServerId={normalizedServerId}
|
||||||
contentContainerStyle={styles.tabsContent}
|
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||||
showsHorizontalScrollIndicator={false}
|
setHoveredTabKey={setHoveredTabKey}
|
||||||
>
|
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||||
<SortableInlineList
|
isArchivingAgent={isArchivingAgent}
|
||||||
data={tabs}
|
killTerminalPending={killTerminalMutation.isPending}
|
||||||
keyExtractor={(tab) => tab.key}
|
killTerminalId={killTerminalMutation.variables ?? null}
|
||||||
useDragHandle
|
onNavigateTab={navigateToTabId}
|
||||||
disabled={tabs.length < 2}
|
onCloseTab={handleCloseTabById}
|
||||||
onDragEnd={handleReorderTabs}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
renderItem={({ item: tab, dragHandleProps }) => {
|
onCopyAgentId={handleCopyAgentId}
|
||||||
const isActive = tab.key === activeTabKey;
|
onCloseTabsToRight={handleCloseTabsToRight}
|
||||||
const tabAgent =
|
onSelectNewTabOption={handleSelectNewTabOption}
|
||||||
tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
newTabTerminalOptionId={NEW_TAB_TERMINAL_OPTION_ID}
|
||||||
const isClosingAgent =
|
createTerminalPending={createTerminalMutation.isPending}
|
||||||
tab.kind === "agent" &&
|
isNewTerminalHovered={isNewTerminalHovered}
|
||||||
isArchivingAgent({
|
setIsNewTerminalHovered={setIsNewTerminalHovered}
|
||||||
serverId: normalizedServerId,
|
onReorderTabs={handleReorderTabs}
|
||||||
agentId: tab.agentId,
|
/>
|
||||||
});
|
|
||||||
const isClosingTerminal =
|
|
||||||
tab.kind === "terminal" &&
|
|
||||||
killTerminalMutation.isPending &&
|
|
||||||
killTerminalMutation.variables === tab.terminalId;
|
|
||||||
const isClosingTab = isClosingAgent || isClosingTerminal;
|
|
||||||
const shouldShowCloseButton = true;
|
|
||||||
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 contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu key={tab.key}>
|
|
||||||
<ContextMenuTrigger
|
|
||||||
testID={`workspace-tab-${tab.key}`}
|
|
||||||
enabledOnMobile={false}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.tab,
|
|
||||||
isActive && styles.tabActive,
|
|
||||||
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
|
||||||
]}
|
|
||||||
onHoverIn={() => {
|
|
||||||
setHoveredTabKey(tab.key);
|
|
||||||
}}
|
|
||||||
onHoverOut={() => {
|
|
||||||
setHoveredTabKey((current) =>
|
|
||||||
current === tab.key ? null : current
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onPress={() => {
|
|
||||||
navigateToTabId(tab.tabId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
{...(dragHandleProps?.attributes as any)}
|
|
||||||
{...(dragHandleProps?.listeners as any)}
|
|
||||||
ref={(node: unknown) => {
|
|
||||||
dragHandleProps?.setActivatorNodeRef?.(node);
|
|
||||||
}}
|
|
||||||
style={styles.tabHandle}
|
|
||||||
>
|
|
||||||
<View style={styles.tabIcon}>{icon}</View>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.tabLabel,
|
|
||||||
isActive && styles.tabLabelActive,
|
|
||||||
shouldShowCloseButton && styles.tabLabelWithCloseButton,
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
testID={
|
|
||||||
tab.kind === "agent"
|
|
||||||
? `workspace-agent-close-${tab.agentId}`
|
|
||||||
: tab.kind === "terminal"
|
|
||||||
? `workspace-terminal-close-${tab.terminalId}`
|
|
||||||
: tab.kind === "draft"
|
|
||||||
? `workspace-draft-close-${tab.draftId}`
|
|
||||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
|
||||||
}
|
|
||||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
|
||||||
disabled={!shouldShowCloseButton || isClosingTab}
|
|
||||||
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 handleCloseTabById(tab.tabId);
|
|
||||||
}}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.tabCloseButton,
|
|
||||||
shouldShowCloseButton
|
|
||||||
? styles.tabCloseButtonShown
|
|
||||||
: styles.tabCloseButtonHidden,
|
|
||||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isClosingTab ? (
|
|
||||||
<ActivityIndicator
|
|
||||||
size={12}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<X size={12} color={theme.colors.foregroundMuted} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
|
|
||||||
<ContextMenuContent
|
|
||||||
align="start"
|
|
||||||
width={DROPDOWN_WIDTH}
|
|
||||||
testID={contextMenuTestId}
|
|
||||||
>
|
|
||||||
{tab.kind === "agent" ? (
|
|
||||||
<>
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`${contextMenuTestId}-copy-resume-command`}
|
|
||||||
onSelect={() => {
|
|
||||||
void handleCopyResumeCommand(tab.agentId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy resume command
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`${contextMenuTestId}-copy-agent-id`}
|
|
||||||
onSelect={() => {
|
|
||||||
void handleCopyAgentId(tab.agentId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copy agent id
|
|
||||||
</ContextMenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`${contextMenuTestId}-close-right`}
|
|
||||||
disabled={
|
|
||||||
tabs.findIndex((t) => t.key === tab.key) === tabs.length - 1
|
|
||||||
}
|
|
||||||
onSelect={() => {
|
|
||||||
void handleCloseTabsToRight(tab.tabId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close to the right
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
testID={`${contextMenuTestId}-close`}
|
|
||||||
onSelect={() => {
|
|
||||||
void handleCloseTabById(tab.tabId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
<View style={styles.tabsActions}>
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
testID="workspace-new-agent-tab"
|
|
||||||
onPress={() => handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="New agent tab"
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.newTabActionButton,
|
|
||||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
|
||||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
testID="workspace-new-terminal-tab"
|
|
||||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
|
||||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
|
||||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
|
||||||
disabled={createTerminalMutation.isPending}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="New terminal tab"
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.newTabActionButton,
|
|
||||||
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
|
|
||||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{createTerminalMutation.isPending ? (
|
|
||||||
<ActivityIndicator
|
|
||||||
size="small"
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={styles.terminalPlusIcon}>
|
|
||||||
<SquareTerminal
|
|
||||||
size={theme.iconSize.sm}
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
|
||||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" align="end" offset={8}>
|
|
||||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.centerContent}>
|
<View style={styles.centerContent}>
|
||||||
@@ -2067,13 +1776,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
flex: 1,
|
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: {
|
fontWeight: {
|
||||||
xs: "400",
|
xs: "400",
|
||||||
md: "300",
|
md: "300",
|
||||||
},
|
},
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
headerTitleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
headerProjectTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
headerRight: {
|
headerRight: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeWorkspaceTabLayout } from "@/screens/workspace/workspace-tab-layout";
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
rowPaddingHorizontal: 8,
|
||||||
|
tabGap: 4,
|
||||||
|
maxTabWidth: 260,
|
||||||
|
iconOnlyTabWidth: 40,
|
||||||
|
tabBaseWidthWithClose: 84,
|
||||||
|
minLabelChars: 4,
|
||||||
|
charWidth: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("computeWorkspaceTabLayout", () => {
|
||||||
|
it("keeps full width tabs when space is available", () => {
|
||||||
|
const result = computeWorkspaceTabLayout({
|
||||||
|
containerWidth: 1200,
|
||||||
|
actionsWidth: 100,
|
||||||
|
tabLabelLengths: [8, 10, 7],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("full");
|
||||||
|
expect(result.shouldScroll).toBe(false);
|
||||||
|
expect(result.showLabels).toBe(true);
|
||||||
|
expect(result.showCloseButtons).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shrinks proportionally in compact mode before icon-only", () => {
|
||||||
|
const result = computeWorkspaceTabLayout({
|
||||||
|
containerWidth: 500,
|
||||||
|
actionsWidth: 110,
|
||||||
|
tabLabelLengths: [24, 12, 8],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("compact");
|
||||||
|
expect(result.shouldScroll).toBe(false);
|
||||||
|
expect(result.showLabels).toBe(true);
|
||||||
|
expect(result.tabWidths[0]).toBeGreaterThan(result.tabWidths[1]);
|
||||||
|
expect(result.tabWidths[1]).toBeGreaterThan(result.tabWidths[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to icon mode when compact labels still cannot fit", () => {
|
||||||
|
const result = computeWorkspaceTabLayout({
|
||||||
|
containerWidth: 300,
|
||||||
|
actionsWidth: 120,
|
||||||
|
tabLabelLengths: [14, 14, 14, 14],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("icon");
|
||||||
|
expect(result.showLabels).toBe(false);
|
||||||
|
expect(result.showCloseButtons).toBe(false);
|
||||||
|
expect(result.shouldScroll).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps icon mode without scroll when icons can fit", () => {
|
||||||
|
const result = computeWorkspaceTabLayout({
|
||||||
|
containerWidth: 380,
|
||||||
|
actionsWidth: 120,
|
||||||
|
tabLabelLengths: [20, 20, 20, 20],
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.mode).toBe("icon");
|
||||||
|
expect(result.shouldScroll).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export type WorkspaceTabLayoutMode = "full" | "compact" | "icon";
|
||||||
|
|
||||||
|
export type WorkspaceTabLayoutInput = {
|
||||||
|
containerWidth: number;
|
||||||
|
actionsWidth: number;
|
||||||
|
tabLabelLengths: number[];
|
||||||
|
metrics: {
|
||||||
|
rowPaddingHorizontal: number;
|
||||||
|
tabGap: number;
|
||||||
|
maxTabWidth: number;
|
||||||
|
iconOnlyTabWidth: number;
|
||||||
|
tabBaseWidthWithClose: number;
|
||||||
|
minLabelChars: number;
|
||||||
|
charWidth: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceTabLayoutResult = {
|
||||||
|
mode: WorkspaceTabLayoutMode;
|
||||||
|
tabWidths: number[];
|
||||||
|
shouldScroll: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
showCloseButtons: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
if (value < min) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
if (value > max) {
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(values: number[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
total += value;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTotalRowWidth(input: {
|
||||||
|
tabWidths: number[];
|
||||||
|
tabGap: number;
|
||||||
|
rowPaddingHorizontal: number;
|
||||||
|
}): number {
|
||||||
|
if (input.tabWidths.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
input.rowPaddingHorizontal * 2 +
|
||||||
|
sum(input.tabWidths) +
|
||||||
|
Math.max(input.tabWidths.length - 1, 0) * input.tabGap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeWorkspaceTabLayout(
|
||||||
|
input: WorkspaceTabLayoutInput
|
||||||
|
): WorkspaceTabLayoutResult {
|
||||||
|
const tabCount = input.tabLabelLengths.length;
|
||||||
|
if (tabCount === 0) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
tabWidths: [],
|
||||||
|
shouldScroll: false,
|
||||||
|
showLabels: true,
|
||||||
|
showCloseButtons: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableWidth = Math.max(0, input.containerWidth - input.actionsWidth);
|
||||||
|
const minLabelWidth =
|
||||||
|
input.metrics.tabBaseWidthWithClose + input.metrics.charWidth * input.metrics.minLabelChars;
|
||||||
|
|
||||||
|
const preferredTabWidths = input.tabLabelLengths.map((rawLength) => {
|
||||||
|
const labelLength = Math.max(rawLength, 1);
|
||||||
|
const preferred = input.metrics.tabBaseWidthWithClose + labelLength * input.metrics.charWidth;
|
||||||
|
return clamp(preferred, minLabelWidth, input.metrics.maxTabWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
const preferredTotal = computeTotalRowWidth({
|
||||||
|
tabWidths: preferredTabWidths,
|
||||||
|
tabGap: input.metrics.tabGap,
|
||||||
|
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preferredTotal <= availableWidth) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
tabWidths: preferredTabWidths,
|
||||||
|
shouldScroll: false,
|
||||||
|
showLabels: true,
|
||||||
|
showCloseButtons: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactMinTabWidths = preferredTabWidths.map(() => minLabelWidth);
|
||||||
|
const compactTotal = computeTotalRowWidth({
|
||||||
|
tabWidths: compactMinTabWidths,
|
||||||
|
tabGap: input.metrics.tabGap,
|
||||||
|
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (compactTotal <= availableWidth) {
|
||||||
|
const totalShrinkCapacity = sum(preferredTabWidths.map((width) => width - minLabelWidth));
|
||||||
|
const targetShrink = preferredTotal - availableWidth;
|
||||||
|
const shrinkRatio = totalShrinkCapacity > 0 ? targetShrink / totalShrinkCapacity : 1;
|
||||||
|
const tabWidths = preferredTabWidths.map((preferred) => {
|
||||||
|
const shrinkCapacity = preferred - minLabelWidth;
|
||||||
|
return preferred - shrinkCapacity * shrinkRatio;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "compact",
|
||||||
|
tabWidths,
|
||||||
|
shouldScroll: false,
|
||||||
|
showLabels: true,
|
||||||
|
showCloseButtons: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconTabWidths = preferredTabWidths.map(() => input.metrics.iconOnlyTabWidth);
|
||||||
|
const iconTotal = computeTotalRowWidth({
|
||||||
|
tabWidths: iconTabWidths,
|
||||||
|
tabGap: input.metrics.tabGap,
|
||||||
|
rowPaddingHorizontal: input.metrics.rowPaddingHorizontal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "icon",
|
||||||
|
tabWidths: iconTabWidths,
|
||||||
|
shouldScroll: iconTotal > availableWidth,
|
||||||
|
showLabels: false,
|
||||||
|
showCloseButtons: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Agent } from "@/stores/session-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;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
tabId: string;
|
||||||
|
kind: "terminal";
|
||||||
|
terminalId: string;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
tabId: string;
|
||||||
|
kind: "file";
|
||||||
|
filePath: string;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
export type MessageInputActionRequest = {
|
export type MessageInputActionRequest = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,8 +13,8 @@ interface KeyboardShortcutsState {
|
|||||||
shortcutsDialogOpen: boolean;
|
shortcutsDialogOpen: boolean;
|
||||||
altDown: boolean;
|
altDown: boolean;
|
||||||
cmdOrCtrlDown: boolean;
|
cmdOrCtrlDown: boolean;
|
||||||
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
|
||||||
sidebarShortcutAgentKeys: string[];
|
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||||
messageInputActionRequest: MessageInputActionRequest | null;
|
messageInputActionRequest: MessageInputActionRequest | null;
|
||||||
nextMessageInputActionRequestId: number;
|
nextMessageInputActionRequestId: number;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ interface KeyboardShortcutsState {
|
|||||||
setShortcutsDialogOpen: (open: boolean) => void;
|
setShortcutsDialogOpen: (open: boolean) => void;
|
||||||
setAltDown: (down: boolean) => void;
|
setAltDown: (down: boolean) => void;
|
||||||
setCmdOrCtrlDown: (down: boolean) => void;
|
setCmdOrCtrlDown: (down: boolean) => void;
|
||||||
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||||
resetModifiers: () => void;
|
resetModifiers: () => void;
|
||||||
|
|
||||||
requestMessageInputAction: (input: {
|
requestMessageInputAction: (input: {
|
||||||
@@ -37,7 +38,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
shortcutsDialogOpen: false,
|
shortcutsDialogOpen: false,
|
||||||
altDown: false,
|
altDown: false,
|
||||||
cmdOrCtrlDown: false,
|
cmdOrCtrlDown: false,
|
||||||
sidebarShortcutAgentKeys: [],
|
sidebarShortcutWorkspaceTargets: [],
|
||||||
messageInputActionRequest: null,
|
messageInputActionRequest: null,
|
||||||
nextMessageInputActionRequestId: 1,
|
nextMessageInputActionRequestId: 1,
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||||
setAltDown: (down) => set({ altDown: down }),
|
setAltDown: (down) => set({ altDown: down }),
|
||||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
setSidebarShortcutWorkspaceTargets: (targets) =>
|
||||||
|
set({ sidebarShortcutWorkspaceTargets: targets }),
|
||||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||||
|
|
||||||
requestMessageInputAction: ({ agentKey, kind }) => {
|
requestMessageInputAction: ({ agentKey, kind }) => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { deriveRemoteProjectKey, groupAgents } from "./agent-grouping";
|
import {
|
||||||
|
deriveProjectDisplayName,
|
||||||
|
deriveRemoteProjectKey,
|
||||||
|
groupAgents,
|
||||||
|
} from "./agent-grouping";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
|
function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
|
||||||
@@ -35,6 +39,35 @@ describe("deriveRemoteProjectKey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deriveProjectDisplayName", () => {
|
||||||
|
it("shows owner/repo for GitHub remote keys", () => {
|
||||||
|
expect(
|
||||||
|
deriveProjectDisplayName({
|
||||||
|
projectKey: "remote:github.com/getpaseo/paseo",
|
||||||
|
projectName: "paseo",
|
||||||
|
})
|
||||||
|
).toBe("getpaseo/paseo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows remote path for non-GitHub remote keys", () => {
|
||||||
|
expect(
|
||||||
|
deriveProjectDisplayName({
|
||||||
|
projectKey: "remote:gitlab.example.com/group/repo",
|
||||||
|
projectName: "repo",
|
||||||
|
})
|
||||||
|
).toBe("group/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to projectName for local keys", () => {
|
||||||
|
expect(
|
||||||
|
deriveProjectDisplayName({
|
||||||
|
projectKey: "/Users/me/dev/paseo",
|
||||||
|
projectName: "paseo",
|
||||||
|
})
|
||||||
|
).toBe("paseo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("groupAgents", () => {
|
describe("groupAgents", () => {
|
||||||
it("groups active agents by remote URL when available", () => {
|
it("groups active agents by remote URL when available", () => {
|
||||||
const agents = [
|
const agents = [
|
||||||
|
|||||||
@@ -156,6 +156,44 @@ export function deriveProjectName(projectKey: string): string {
|
|||||||
return segments[segments.length - 1] || projectKey;
|
return segments[segments.length - 1] || projectKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a project name for display in the UI.
|
||||||
|
*
|
||||||
|
* - GitHub remotes show owner/repo
|
||||||
|
* - Other remotes show the remote path when possible
|
||||||
|
* - Local projects prefer the provided projectName, then fallback to cwd tail
|
||||||
|
*/
|
||||||
|
export function deriveProjectDisplayName(input: {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
}): string {
|
||||||
|
const githubPrefix = "remote:github.com/";
|
||||||
|
if (input.projectKey.startsWith(githubPrefix)) {
|
||||||
|
return input.projectKey.slice(githubPrefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.projectKey.startsWith("remote:")) {
|
||||||
|
const withoutPrefix = input.projectKey.slice("remote:".length);
|
||||||
|
const slashIdx = withoutPrefix.indexOf("/");
|
||||||
|
if (slashIdx >= 0) {
|
||||||
|
const remotePath = withoutPrefix.slice(slashIdx + 1).trim();
|
||||||
|
if (remotePath.length > 0) {
|
||||||
|
return remotePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return withoutPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedProjectName = input.projectName.trim();
|
||||||
|
if (trimmedProjectName.length > 0) {
|
||||||
|
return trimmedProjectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input.projectKey.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const segments = normalized.split("/").filter(Boolean);
|
||||||
|
return segments[segments.length - 1] ?? input.projectKey;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the date group label for an agent based on lastActivityAt.
|
* Determines the date group label for an agent based on lastActivityAt.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildAbsoluteExplorerPath } from "./explorer-paths";
|
||||||
|
|
||||||
|
describe("buildAbsoluteExplorerPath", () => {
|
||||||
|
it("builds a POSIX absolute path from a relative explorer path", () => {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||||
|
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
|
||||||
|
})
|
||||||
|
).toBe("/Users/moboudra/dev/paseo/packages/app/src/components/file-explorer-pane.tsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns workspace root when entry path points to explorer root", () => {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||||
|
entryPath: ".",
|
||||||
|
})
|
||||||
|
).toBe("/Users/moboudra/dev/paseo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims trailing separators from workspace root before joining", () => {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot: "/Users/moboudra/dev/paseo/",
|
||||||
|
entryPath: "README.md",
|
||||||
|
})
|
||||||
|
).toBe("/Users/moboudra/dev/paseo/README.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a Windows absolute path with backslash separators", () => {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot: "C:\\repo\\paseo",
|
||||||
|
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
|
||||||
|
})
|
||||||
|
).toBe("C:\\repo\\paseo\\packages\\app\\src\\components\\file-explorer-pane.tsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through an already-absolute entry path", () => {
|
||||||
|
expect(
|
||||||
|
buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||||
|
entryPath: "/tmp/another/location.txt",
|
||||||
|
})
|
||||||
|
).toBe("/tmp/another/location.txt");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
interface BuildAbsoluteExplorerPathInput {
|
||||||
|
workspaceRoot: string;
|
||||||
|
entryPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsolutePath(pathValue: string): boolean {
|
||||||
|
return (
|
||||||
|
pathValue.startsWith("/") ||
|
||||||
|
pathValue.startsWith("\\\\") ||
|
||||||
|
/^[A-Za-z]:[\\/]/.test(pathValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAbsoluteExplorerPath({
|
||||||
|
workspaceRoot,
|
||||||
|
entryPath,
|
||||||
|
}: BuildAbsoluteExplorerPathInput): string {
|
||||||
|
const normalizedWorkspaceRoot = workspaceRoot.trim().replace(/[\\/]+$/, "");
|
||||||
|
const normalizedEntryPath = entryPath.trim();
|
||||||
|
|
||||||
|
if (!normalizedWorkspaceRoot) {
|
||||||
|
return normalizedEntryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedEntryPath || normalizedEntryPath === ".") {
|
||||||
|
return normalizedWorkspaceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsolutePath(normalizedEntryPath)) {
|
||||||
|
return normalizedEntryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = normalizedWorkspaceRoot.includes("\\") ? "\\" : "/";
|
||||||
|
const segments = normalizedEntryPath.split(/[\\/]+/).filter(Boolean);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return normalizedWorkspaceRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`;
|
||||||
|
}
|
||||||
@@ -1,56 +1,69 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-agents-list";
|
||||||
|
|
||||||
import { deriveSidebarShortcutAgentKeys, parseSidebarAgentKey } from "./sidebar-shortcuts";
|
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
|
||||||
|
|
||||||
describe("parseSidebarAgentKey", () => {
|
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||||
it("parses serverId and agentId", () => {
|
return {
|
||||||
expect(parseSidebarAgentKey("server:agent")).toEqual({ serverId: "server", agentId: "agent" });
|
workspaceKey: `${serverId}:${cwd}`,
|
||||||
});
|
serverId,
|
||||||
|
cwd,
|
||||||
|
branchName: null,
|
||||||
|
createdAt: null,
|
||||||
|
isMainCheckout: false,
|
||||||
|
isPaseoOwnedWorktree: false,
|
||||||
|
statusBucket: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
it("returns null for invalid keys", () => {
|
function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry {
|
||||||
expect(parseSidebarAgentKey("")).toBeNull();
|
return {
|
||||||
expect(parseSidebarAgentKey("no-separator")).toBeNull();
|
projectKey,
|
||||||
expect(parseSidebarAgentKey(":agent")).toBeNull();
|
projectName: projectKey,
|
||||||
expect(parseSidebarAgentKey("server:")).toBeNull();
|
iconWorkingDir: workspaces[0]?.cwd ?? "",
|
||||||
});
|
statusBucket: "done",
|
||||||
});
|
activeCount: 0,
|
||||||
|
totalCount: workspaces.length,
|
||||||
|
latestCreatedAt: null,
|
||||||
|
workspaces,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("deriveSidebarShortcutAgentKeys", () => {
|
describe("buildSidebarWorkspaceViewModel", () => {
|
||||||
it("skips collapsed projects and preserves visual order", () => {
|
it("builds visible rows and shortcut targets in visual order", () => {
|
||||||
const sections = [
|
const projects = [
|
||||||
{
|
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
|
||||||
projectKey: "p1",
|
project("p2", [workspace("s1", "/repo2/main")]),
|
||||||
agents: [
|
|
||||||
{ serverId: "s", id: "a1" },
|
|
||||||
{ serverId: "s", id: "a2" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
projectKey: "p2",
|
|
||||||
agents: [
|
|
||||||
{ serverId: "s", id: "b1" },
|
|
||||||
{ serverId: "s", id: "b2" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(["p2"]), 9)).toEqual([
|
const model = buildSidebarWorkspaceViewModel({
|
||||||
"s:a1",
|
projects,
|
||||||
"s:a2",
|
collapsedProjectKeys: new Set<string>(["p2"]),
|
||||||
|
getProjectDisplayName: (entry) => entry.projectName,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.rows.map((row) => row.kind)).toEqual(["project", "workspace", "workspace", "project"]);
|
||||||
|
expect(model.shortcutTargets).toEqual([
|
||||||
|
{ serverId: "s1", workspaceId: "/repo/main" },
|
||||||
|
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
||||||
]);
|
]);
|
||||||
|
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/main")).toBe(1);
|
||||||
|
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/feat-a")).toBe(2);
|
||||||
|
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo2/main")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("limits to 9", () => {
|
it("limits shortcuts to 9", () => {
|
||||||
const sections = [
|
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
|
||||||
{
|
const projects = [project("p", workspaces)];
|
||||||
projectKey: "p1",
|
|
||||||
agents: Array.from({ length: 20 }, (_, i) => ({ serverId: "s", id: `a${i + 1}` })),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)).toHaveLength(9);
|
const model = buildSidebarWorkspaceViewModel({
|
||||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[0]).toBe("s:a1");
|
projects,
|
||||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[8]).toBe("s:a9");
|
collapsedProjectKeys: new Set<string>(),
|
||||||
|
getProjectDisplayName: (entry) => entry.projectName,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.shortcutTargets).toHaveLength(9);
|
||||||
|
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
|
||||||
|
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,77 @@
|
|||||||
export interface SidebarShortcutSection {
|
import type {
|
||||||
projectKey: string;
|
SidebarProjectEntry,
|
||||||
agents: Array<{ serverId: string; id: string }>;
|
SidebarWorkspaceEntry,
|
||||||
|
} from '@/hooks/use-sidebar-agents-list'
|
||||||
|
|
||||||
|
export interface SidebarShortcutWorkspaceTarget {
|
||||||
|
serverId: string
|
||||||
|
workspaceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSidebarAgentKey(
|
export type SidebarWorkspaceTreeRow =
|
||||||
key: string
|
| {
|
||||||
): { serverId: string; agentId: string } | null {
|
kind: 'project'
|
||||||
const sep = key.indexOf(":");
|
rowKey: string
|
||||||
if (sep === -1) {
|
project: SidebarProjectEntry
|
||||||
return null;
|
displayName: string
|
||||||
}
|
|
||||||
const serverId = key.slice(0, sep);
|
|
||||||
const agentId = key.slice(sep + 1);
|
|
||||||
if (!serverId || !agentId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { serverId, agentId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveSidebarShortcutAgentKeys(
|
|
||||||
sections: SidebarShortcutSection[],
|
|
||||||
collapsedProjectKeys: ReadonlySet<string>,
|
|
||||||
limit = 9
|
|
||||||
): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
const max = Math.max(0, Math.floor(limit));
|
|
||||||
if (max === 0) {
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const section of sections) {
|
|
||||||
if (collapsedProjectKeys.has(section.projectKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
for (const agent of section.agents) {
|
| {
|
||||||
keys.push(`${agent.serverId}:${agent.id}`);
|
kind: 'workspace'
|
||||||
if (keys.length >= max) {
|
rowKey: string
|
||||||
return keys;
|
projectKey: string
|
||||||
|
workspace: SidebarWorkspaceEntry
|
||||||
|
shortcutNumber: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarWorkspaceViewModel {
|
||||||
|
rows: SidebarWorkspaceTreeRow[]
|
||||||
|
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
||||||
|
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSidebarWorkspaceViewModel(input: {
|
||||||
|
projects: SidebarProjectEntry[]
|
||||||
|
collapsedProjectKeys: ReadonlySet<string>
|
||||||
|
getProjectDisplayName: (project: SidebarProjectEntry) => string
|
||||||
|
shortcutLimit?: number
|
||||||
|
}): SidebarWorkspaceViewModel {
|
||||||
|
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
||||||
|
const rows: SidebarWorkspaceTreeRow[] = []
|
||||||
|
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||||
|
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const project of input.projects) {
|
||||||
|
rows.push({
|
||||||
|
kind: 'project',
|
||||||
|
rowKey: `project:${project.projectKey}`,
|
||||||
|
project,
|
||||||
|
displayName: input.getProjectDisplayName(project),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workspace of project.workspaces) {
|
||||||
|
const shortcutNumber =
|
||||||
|
shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null
|
||||||
|
if (shortcutNumber !== null) {
|
||||||
|
shortcutTargets.push({
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
workspaceId: workspace.cwd,
|
||||||
|
})
|
||||||
|
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
kind: 'workspace',
|
||||||
|
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
|
||||||
|
projectKey: project.projectKey,
|
||||||
|
workspace,
|
||||||
|
shortcutNumber,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return keys;
|
return { rows, shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user