diff --git a/src/components/TabsView.tsx b/src/components/TabsView.tsx index 9a54e049..3bd52bc5 100644 --- a/src/components/TabsView.tsx +++ b/src/components/TabsView.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useState } from 'react' +import { createElement, useEffect, useMemo, useState } from 'react' import { nanoid } from 'nanoid' import { Archive, Bot, ChevronDown, ChevronRight, + Copy, + ExternalLink, FileCode2, Globe, Monitor, @@ -20,15 +22,33 @@ import { addPane, initLayout } from '@/store/panesSlice' import { setTabRegistryLoading, setTabRegistrySearchRangeDays } from '@/store/tabRegistrySlice' import { selectTabsRegistryGroups } from '@/store/selectors/tabsRegistrySelectors' import { isNonShellMode } from '@/lib/coding-cli-utils' +import { copyText } from '@/lib/clipboard' +import { cn } from '@/lib/utils' +import { ContextMenu } from '@/components/context-menu/ContextMenu' +import type { MenuItem } from '@/components/context-menu/context-menu-types' import type { PaneContentInput, SessionLocator } from '@/store/paneTypes' import type { CodingCliProviderName, TabMode } from '@/store/types' import type { AgentChatProviderName } from '@/lib/agent-chat-types' +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + type FilterMode = 'all' | 'open' | 'closed' type ScopeMode = 'all' | 'local' | 'remote' type DisplayRecord = RegistryTabRecord & { displayDeviceLabel: string } +type DeviceGroupData = { + deviceId: string + deviceLabel: string + tabs: DisplayRecord[] +} + +/* ------------------------------------------------------------------ */ +/* Utilities (unchanged business logic) */ +/* ------------------------------------------------------------------ */ + function parseSessionLocator(value: unknown): SessionLocator | undefined { if (!value || typeof value !== 'object') return undefined const candidate = value as { provider?: unknown; sessionId?: unknown; serverInstanceId?: unknown } @@ -150,17 +170,34 @@ function paneKindIcon(kind: RegistryPaneSnapshot['kind']): LucideIcon { return Square } -function formatClosedSince(record: RegistryTabRecord, now: number): string { - const closedAt = record.closedAt ?? record.updatedAt - const diff = Math.max(0, now - closedAt) +function paneKindColorClass(kind: RegistryPaneSnapshot['kind']): string { + if (kind === 'terminal') return 'text-foreground/50' + if (kind === 'browser') return 'text-blue-500' + if (kind === 'editor') return 'text-emerald-500' + if (kind === 'agent-chat' || kind === 'claude-chat') return 'text-amber-500' + if (kind === 'extension') return 'text-purple-500' + return 'text-muted-foreground' +} + +function paneKindLabel(kind: RegistryPaneSnapshot['kind']): string { + if (kind === 'terminal') return 'Terminal' + if (kind === 'browser') return 'Browser' + if (kind === 'editor') return 'Editor' + if (kind === 'agent-chat' || kind === 'claude-chat') return 'Agent' + if (kind === 'extension') return 'Extension' + return kind +} + +function formatRelativeTime(timestamp: number, now: number): string { + const diff = Math.max(0, now - timestamp) const minutes = Math.floor(diff / 60000) const hours = Math.floor(diff / 3600000) const days = Math.floor(diff / 86400000) - if (minutes < 1) return 'closed just now' - if (minutes < 60) return `closed ~${minutes}m ago` - if (hours < 24) return `closed ~${hours}h ago` - if (days < 30) return `closed ~${days}d ago` - return `closed ${new Date(closedAt).toLocaleDateString()}` + if (minutes < 1) return 'just now' + if (minutes < 60) return `${minutes}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 30) return `${days}d ago` + return new Date(timestamp).toLocaleDateString() } function matchRecord(record: DisplayRecord, query: string): boolean { @@ -177,140 +214,299 @@ function matchRecord(record: DisplayRecord, query: string): boolean { ) } -function Section({ - title, +function groupByDevice(records: DisplayRecord[]): DeviceGroupData[] { + const map = new Map() + for (const record of records) { + const existing = map.get(record.deviceId) + if (existing) { + existing.tabs.push(record) + } else { + map.set(record.deviceId, { + deviceId: record.deviceId, + deviceLabel: record.displayDeviceLabel, + tabs: [record], + }) + } + } + return [...map.values()] +} + +/* ------------------------------------------------------------------ */ +/* Segmented control */ +/* ------------------------------------------------------------------ */ + +function SegmentedControl({ + options, + value, + onChange, + ariaLabel, +}: { + options: { value: T; label: string }[] + value: T + onChange: (value: T) => void + ariaLabel: string +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Tab card */ +/* ------------------------------------------------------------------ */ + +function TabCard({ + record, + isLocal, + showDevice, + onAction, + onContextMenu, +}: { + record: DisplayRecord + isLocal: boolean + showDevice?: boolean + onAction: () => void + onContextMenu: (e: React.MouseEvent) => void +}) { + const now = Date.now() + const isOpen = record.status === 'open' + const paneKinds = [...new Set(record.panes.map((p) => p.kind))] + const timestamp = record.closedAt ?? record.updatedAt + const actionLabel = isLocal && isOpen ? 'Jump' : 'Pull' + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onAction() + } + }} + > + {showDevice && ( +
+ {record.displayDeviceLabel} +
+ )} + +
{record.tabName}
+ +
+ {paneKinds.map((kind) => { + const Icon = paneKindIcon(kind) + return ( + + ) + })} + {record.paneCount > 0 && ( + <> + + · + + + {record.paneCount} pane{record.paneCount === 1 ? '' : 's'} + + + )} + + · + + {formatRelativeTime(timestamp, now)} +
+ +
+ + {actionLabel} + + +
+
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Device section */ +/* ------------------------------------------------------------------ */ + +function DeviceSection({ + label, icon: Icon, - records, - expanded, - onToggleExpanded, + count, + tabs, + isLocal, + collapsible, + defaultExpanded, + showDeviceOnCards, + onPullAll, onJump, - onOpenAsCopy, - onOpenPaneInNewTab, + onOpenCopy, + onCardContextMenu, }: { - title: string + label: string icon: LucideIcon - records: DisplayRecord[] - expanded: Record - onToggleExpanded: (tabKey: string) => void + count: number + tabs: DisplayRecord[] + isLocal: boolean + collapsible?: boolean + defaultExpanded?: boolean + showDeviceOnCards?: boolean + onPullAll?: () => void onJump: (record: RegistryTabRecord) => void - onOpenAsCopy: (record: RegistryTabRecord) => void - onOpenPaneInNewTab: (record: RegistryTabRecord, pane: RegistryPaneSnapshot) => void + onOpenCopy: (record: RegistryTabRecord) => void + onCardContextMenu: (e: React.MouseEvent, record: DisplayRecord) => void }) { - const now = Date.now() + const [expanded, setExpanded] = useState(defaultExpanded ?? true) + return (
-

- - {title} -

- {records.length === 0 ? ( -
None
- ) : ( - records.map((record) => { - const isExpanded = expanded[record.tabKey] ?? (record.status === 'open') - const paneKinds = [...new Set(record.panes.map((pane) => pane.kind))] - return ( -
-
- -
- {paneKinds.map((kind) => { - const PaneIcon = paneKindIcon(kind) - return - })} - {record.status === 'open' ? ( - - ) : null} - -
-
- - {isExpanded && record.panes.length > 0 ? ( -
- {record.panes.map((pane) => { - const PaneIcon = paneKindIcon(pane.kind) - return ( -
- - - {pane.title || pane.kind} - - -
- ) - })} -
- ) : null} -
- ) - }) +
+ {collapsible ? ( + + ) : ( +

+ + {label} +

+ )} + + {count} tab{count === 1 ? '' : 's'} + + {!isLocal && onPullAll && count > 1 && ( + + )} +
+ + {expanded && ( +
+ {tabs.map((record) => ( + + isLocal && record.status === 'open' + ? onJump(record) + : onOpenCopy(record) + } + onContextMenu={(e) => onCardContextMenu(e, record)} + /> + ))} +
)}
) } +/* ------------------------------------------------------------------ */ +/* Main component */ +/* ------------------------------------------------------------------ */ + export default function TabsView({ onOpenTab }: { onOpenTab?: () => void }) { const dispatch = useAppDispatch() const store = useAppStore() const ws = useMemo(() => getWsClient(), []) const groups = useAppSelector(selectTabsRegistryGroups) - const { deviceId, deviceLabel, deviceAliases, searchRangeDays, syncError } = useAppSelector((state) => state.tabRegistry) + const { deviceId, deviceLabel, deviceAliases, searchRangeDays, syncError } = useAppSelector( + (state) => state.tabRegistry, + ) const localServerInstanceId = useAppSelector((state) => state.connection.serverInstanceId) const connectionStatus = useAppSelector((state) => state.connection.status) const connectionError = useAppSelector((state) => state.connection.lastError) + const [query, setQuery] = useState('') const [filterMode, setFilterMode] = useState('all') const [scopeMode, setScopeMode] = useState('all') - const [expanded, setExpanded] = useState>({}) + const [contextMenuState, setContextMenuState] = useState<{ + position: { x: number; y: number } + items: MenuItem[] + } | null>(null) + + /* -- device label resolver ---------------------------------------- */ const withDisplayDeviceLabel = useMemo( - () => (record: RegistryTabRecord): DisplayRecord => ({ - ...record, - displayDeviceLabel: - record.deviceId === deviceId - ? deviceLabel - : (deviceAliases[record.deviceId] || record.deviceLabel), - }), + () => + (record: RegistryTabRecord): DisplayRecord => ({ + ...record, + displayDeviceLabel: + record.deviceId === deviceId + ? deviceLabel + : deviceAliases[record.deviceId] || record.deviceLabel, + }), [deviceAliases, deviceId, deviceLabel], ) + /* -- search range sync -------------------------------------------- */ + useEffect(() => { if (ws.state !== 'ready') return if (searchRangeDays <= 30) return @@ -322,10 +518,12 @@ export default function TabsView({ onOpenTab }: { onOpenTab?: () => void }) { }) }, [dispatch, ws, deviceId, searchRangeDays]) + /* -- filtering ---------------------------------------------------- */ + const filtered = useMemo(() => { - const localOpen = groups.localOpen.map(withDisplayDeviceLabel).filter((record) => matchRecord(record, query)) - const remoteOpen = groups.remoteOpen.map(withDisplayDeviceLabel).filter((record) => matchRecord(record, query)) - const closed = groups.closed.map(withDisplayDeviceLabel).filter((record) => matchRecord(record, query)) + const localOpen = groups.localOpen.map(withDisplayDeviceLabel).filter((r) => matchRecord(r, query)) + const remoteOpen = groups.remoteOpen.map(withDisplayDeviceLabel).filter((r) => matchRecord(r, query)) + const closed = groups.closed.map(withDisplayDeviceLabel).filter((r) => matchRecord(r, query)) const byScope = (records: DisplayRecord[], scope: 'local' | 'remote') => { if (scopeMode === 'all') return records @@ -339,44 +537,54 @@ export default function TabsView({ onOpenTab }: { onOpenTab?: () => void }) { } }, [groups, query, filterMode, scopeMode, withDisplayDeviceLabel]) + const remoteDeviceGroups = useMemo( + () => groupByDevice(filtered.remoteOpen), + [filtered.remoteOpen], + ) + + const totalCount = + filtered.localOpen.length + filtered.remoteOpen.length + filtered.closed.length + + /* -- actions ------------------------------------------------------ */ + const openRecordAsUnlinkedCopy = (record: RegistryTabRecord) => { const tabId = nanoid() const paneSnapshots = record.panes || [] const firstPane = paneSnapshots[0] const firstContent = firstPane ? sanitizePaneSnapshot(record, firstPane, localServerInstanceId) - : { kind: 'terminal', mode: 'shell' } as const - dispatch(addTab({ - id: tabId, - title: record.tabName, - mode: deriveModeFromRecord(record), - status: 'creating', - })) - dispatch(initLayout({ - tabId, - content: firstContent, - })) + : ({ kind: 'terminal', mode: 'shell' } as const) + dispatch( + addTab({ + id: tabId, + title: record.tabName, + mode: deriveModeFromRecord(record), + status: 'creating', + }), + ) + dispatch(initLayout({ tabId, content: firstContent })) for (const pane of paneSnapshots.slice(1)) { - dispatch(addPane({ - tabId, - newContent: sanitizePaneSnapshot(record, pane, localServerInstanceId), - })) + dispatch(addPane({ tabId, newContent: sanitizePaneSnapshot(record, pane, localServerInstanceId) })) } onOpenTab?.() } const openPaneInNewTab = (record: RegistryTabRecord, pane: RegistryPaneSnapshot) => { const tabId = nanoid() - dispatch(addTab({ - id: tabId, - title: `${record.tabName} · ${pane.title || pane.kind}`, - mode: deriveModeFromRecord(record), - status: 'creating', - })) - dispatch(initLayout({ - tabId, - content: sanitizePaneSnapshot(record, pane, localServerInstanceId), - })) + dispatch( + addTab({ + id: tabId, + title: `${record.tabName} · ${pane.title || pane.kind}`, + mode: deriveModeFromRecord(record), + status: 'creating', + }), + ) + dispatch( + initLayout({ + tabId, + content: sanitizePaneSnapshot(record, pane, localServerInstanceId), + }), + ) onOpenTab?.() } @@ -390,99 +598,210 @@ export default function TabsView({ onOpenTab }: { onOpenTab?: () => void }) { onOpenTab?.() } + const pullAllFromDevice = (tabs: DisplayRecord[]) => { + for (const record of tabs) { + openRecordAsUnlinkedCopy(record) + } + } + + /* -- context menu ------------------------------------------------- */ + + const openCardContextMenu = (e: React.MouseEvent, record: DisplayRecord) => { + e.preventDefault() + e.stopPropagation() + + const isLocal = record.deviceId === deviceId + const isOpen = record.status === 'open' + const items: MenuItem[] = [] + + if (isLocal && isOpen) { + items.push({ + type: 'item', + id: 'jump', + label: 'Jump to tab', + icon: createElement(ExternalLink, { className: 'h-3.5 w-3.5' }), + onSelect: () => jumpToRecord(record), + }) + } + + items.push({ + type: 'item', + id: 'open-copy', + label: isLocal && isOpen ? 'Open copy' : record.status === 'closed' ? 'Reopen' : 'Pull to this device', + icon: createElement(Copy, { className: 'h-3.5 w-3.5' }), + onSelect: () => openRecordAsUnlinkedCopy(record), + }) + + if (record.panes.length > 1) { + items.push({ type: 'separator', id: 'sep-panes' }) + for (const pane of record.panes) { + const PaneIcon = paneKindIcon(pane.kind) + items.push({ + type: 'item', + id: `pane-${pane.paneId}`, + label: `Open ${pane.title || paneKindLabel(pane.kind)} in new tab`, + icon: createElement(PaneIcon, { + className: cn('h-3.5 w-3.5', paneKindColorClass(pane.kind)), + }), + onSelect: () => openPaneInNewTab(record, pane), + }) + } + } + + items.push({ type: 'separator', id: 'sep-copy' }) + items.push({ + type: 'item', + id: 'copy-name', + label: 'Copy tab name', + icon: createElement(Copy, { className: 'h-3.5 w-3.5' }), + onSelect() { + void copyText(record.tabName) + }, + }) + + setContextMenuState({ position: { x: e.clientX, y: e.clientY }, items }) + } + + /* -- render ------------------------------------------------------- */ + return (
+ {/* Header */}
-
-

- - Tabs -

-

- Open on this machine, open on other machines, and closed history. -

-
- {connectionStatus !== 'ready' || syncError ? ( -
- Tabs sync unavailable. - {syncError ? ` ${syncError}` : ' Reconnect WebSocket to refresh remote tabs.'} - {!syncError && connectionError ? ` (${connectionError})` : ''} +
+
+

Tabs

+

+ All your tabs across devices. Click to pull, right-click for options. +

- ) : null} -
setQuery(event.target.value)} - placeholder="Search tabs, devices, panes..." - className="h-9 min-w-[14rem] px-3 text-sm rounded-md border border-border bg-background" + onChange={(e) => setQuery(e.target.value)} + placeholder="Search..." + className="h-8 w-48 px-3 text-xs rounded-md border border-border bg-background placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/40" aria-label="Search tabs" /> - - + onChange={setScopeMode} + ariaLabel="Device scope filter" + />
-
-
setExpanded((current) => ({ ...current, [tabKey]: !(current[tabKey] ?? true) }))} - onJump={jumpToRecord} - onOpenAsCopy={openRecordAsUnlinkedCopy} - onOpenPaneInNewTab={openPaneInNewTab} - /> -
setExpanded((current) => ({ ...current, [tabKey]: !(current[tabKey] ?? true) }))} - onJump={jumpToRecord} - onOpenAsCopy={openRecordAsUnlinkedCopy} - onOpenPaneInNewTab={openPaneInNewTab} - /> -
setExpanded((current) => ({ ...current, [tabKey]: !(current[tabKey] ?? false) }))} - onJump={jumpToRecord} - onOpenAsCopy={openRecordAsUnlinkedCopy} - onOpenPaneInNewTab={openPaneInNewTab} - /> + {/* Content */} +
+ {totalCount === 0 && ( +
+ {query ? 'No tabs match your search.' : 'No tabs to display.'} +
+ )} + + {/* This device */} + {filtered.localOpen.length > 0 && ( + + )} + + {/* Remote devices */} + {remoteDeviceGroups.length > 0 && ( +
+ {filtered.localOpen.length > 0 && ( +

+ Other devices +

+ )} + {remoteDeviceGroups.map((group) => ( + pullAllFromDevice(group.tabs)} + onJump={jumpToRecord} + onOpenCopy={openRecordAsUnlinkedCopy} + onCardContextMenu={openCardContextMenu} + /> + ))} +
+ )} + + {/* Recently closed */} + {filtered.closed.length > 0 && ( + + )}
+ + {/* Context menu (portal) */} + setContextMenuState(null)} + />
) } diff --git a/test/e2e/tabs-view-flow.test.tsx b/test/e2e/tabs-view-flow.test.tsx index a9e084c2..9b7f8b18 100644 --- a/test/e2e/tabs-view-flow.test.tsx +++ b/test/e2e/tabs-view-flow.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, within } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import tabsReducer from '../../src/store/tabsSlice' @@ -19,6 +19,10 @@ vi.mock('@/lib/ws-client', () => ({ }), })) +vi.mock('@/lib/clipboard', () => ({ + copyText: vi.fn(() => Promise.resolve(true)), +})) + describe('tabs view flow', () => { beforeEach(() => { localStorage.clear() @@ -71,9 +75,11 @@ describe('tabs view flow', () => { , ) - const remoteCard = screen.getByText('remote-device: work item').closest('article') + // Click the remote tab card to pull it + const remoteCard = screen.getByLabelText('remote-device: work item') expect(remoteCard).toBeTruthy() - fireEvent.click(within(remoteCard as HTMLElement).getByRole('button', { name: /Open copy/i })) + fireEvent.click(remoteCard) + expect(store.getState().tabs.tabs).toHaveLength(1) expect(store.getState().tabs.tabs[0]?.title).toBe('work item') const tabId = store.getState().tabs.tabs[0]!.id @@ -129,9 +135,10 @@ describe('tabs view flow', () => { , ) - const remoteCard = screen.getByText('remote-device: codex run').closest('article') + // Click the remote tab card to pull it + const remoteCard = screen.getByLabelText('remote-device: codex run') expect(remoteCard).toBeTruthy() - fireEvent.click(within(remoteCard as HTMLElement).getByRole('button', { name: /Open copy/i })) + fireEvent.click(remoteCard) const copiedTab = store.getState().tabs.tabs[0] expect(copiedTab?.title).toBe('codex run') diff --git a/test/e2e/tabs-view-search-range.test.tsx b/test/e2e/tabs-view-search-range.test.tsx index c2c6208f..ea23d183 100644 --- a/test/e2e/tabs-view-search-range.test.tsx +++ b/test/e2e/tabs-view-search-range.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import tabsReducer from '../../src/store/tabsSlice' @@ -21,6 +21,10 @@ vi.mock('@/lib/ws-client', () => ({ getWsClient: () => wsMock, })) +vi.mock('@/lib/clipboard', () => ({ + copyText: vi.fn(() => Promise.resolve(true)), +})) + describe('tabs view search range loading', () => { beforeEach(() => { wsMock.sendTabsSyncQuery.mockClear() diff --git a/test/unit/client/components/TabsView.test.tsx b/test/unit/client/components/TabsView.test.tsx index 72a79718..005ee4c2 100644 --- a/test/unit/client/components/TabsView.test.tsx +++ b/test/unit/client/components/TabsView.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import tabsReducer, { addTab } from '../../../../src/store/tabsSlice' @@ -20,6 +20,10 @@ vi.mock('@/lib/ws-client', () => ({ getWsClient: () => wsMock, })) +vi.mock('@/lib/clipboard', () => ({ + copyText: vi.fn(() => Promise.resolve(true)), +})) + function createStore() { const store = configureStore({ reducer: { @@ -78,8 +82,11 @@ describe('TabsView', () => { beforeEach(() => { wsMock.sendTabsSyncQuery.mockClear() }) + afterEach(() => { + cleanup() + }) - it('renders groups in order: local open, remote open, closed', () => { + it('renders device-centric sections with local, remote, and closed groups', () => { const store = createStore() const { container } = render( @@ -87,18 +94,153 @@ describe('TabsView', () => { , ) - const headings = [...container.querySelectorAll('h2')].map((node) => node.textContent?.trim()) - expect(headings).toEqual([ - 'Open on this device', - 'Open on other devices', - 'Closed', - ]) - expect(screen.getByText('remote-device: remote open')).toBeInTheDocument() - expect(screen.getByText('remote-device: remote closed')).toBeInTheDocument() + // Local device section (h2 heading) + const headings = [...container.querySelectorAll('h2')].map((n) => n.textContent?.trim()) + expect(headings.some((h) => h?.includes('This device'))).toBe(true) + + // Remote tab card is present (aria-label includes device:tabname) + expect(screen.getByLabelText('remote-device: remote open')).toBeInTheDocument() + + // Closed section exists (collapsible button) + expect(screen.getByLabelText(/Expand Recently closed/i)).toBeInTheDocument() }) - it('drops resumeSessionId when opening remote copy from another server instance', () => { + it('renders tab cards as clickable articles with aria-labels', () => { + const store = createStore() + render( + + + , + ) + + const remoteCard = screen.getByLabelText('remote-device: remote open') + expect(remoteCard.tagName).toBe('ARTICLE') + expect(remoteCard).toHaveAttribute('role', 'button') + }) + + it('opens a copy when clicking a remote tab card', () => { const store = createStore() + render( + + + , + ) + + const remoteCard = screen.getByLabelText('remote-device: remote open') + fireEvent.click(remoteCard) + + const tabs = store.getState().tabs.tabs + expect(tabs).toHaveLength(2) // local-tab + new copy + expect(tabs.some((t) => t.title === 'remote open')).toBe(true) + }) + + it('shows context menu on right-click with appropriate items', () => { + const store = createStore() + render( + + + , + ) + + const remoteCard = screen.getByLabelText('remote-device: remote open') + fireEvent.contextMenu(remoteCard) + + // Context menu should appear with "Pull to this device" and "Copy tab name" + expect(screen.getByRole('menuitem', { name: /Pull to this device/i })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /Copy tab name/i })).toBeInTheDocument() + }) + + it('groups remote tabs by device', () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRegistry: tabRegistryReducer, + connection: connectionReducer, + }, + }) + + store.dispatch(setTabRegistrySnapshot({ + localOpen: [], + remoteOpen: [ + { + tabKey: 'dev1:tab1', + tabId: 't1', + serverInstanceId: 'srv-1', + deviceId: 'device-1', + deviceLabel: 'Laptop', + tabName: 'tab one', + status: 'open', + revision: 1, + createdAt: 1, + updatedAt: 2, + paneCount: 1, + titleSetByUser: false, + panes: [], + }, + { + tabKey: 'dev1:tab2', + tabId: 't2', + serverInstanceId: 'srv-1', + deviceId: 'device-1', + deviceLabel: 'Laptop', + tabName: 'tab two', + status: 'open', + revision: 1, + createdAt: 1, + updatedAt: 3, + paneCount: 1, + titleSetByUser: false, + panes: [], + }, + { + tabKey: 'dev2:tab3', + tabId: 't3', + serverInstanceId: 'srv-2', + deviceId: 'device-2', + deviceLabel: 'Desktop', + tabName: 'tab three', + status: 'open', + revision: 1, + createdAt: 1, + updatedAt: 4, + paneCount: 1, + titleSetByUser: false, + panes: [], + }, + ], + closed: [], + })) + + const { container } = render( + + + , + ) + + // Both device groups should render as h2 headings + const headings = [...container.querySelectorAll('h2')].map((n) => n.textContent?.trim()) + expect(headings).toContain('Laptop') + expect(headings).toContain('Desktop') + + // All tab cards are present + expect(screen.getByLabelText('Laptop: tab one')).toBeInTheDocument() + expect(screen.getByLabelText('Laptop: tab two')).toBeInTheDocument() + expect(screen.getByLabelText('Desktop: tab three')).toBeInTheDocument() + + // "Pull all" button visible for multi-tab device group + expect(screen.getByLabelText('Pull all tabs from Laptop')).toBeInTheDocument() + }) + + it('drops resumeSessionId when opening remote copy from another server instance', () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRegistry: tabRegistryReducer, + connection: connectionReducer, + }, + }) store.dispatch(setServerInstanceId('srv-local')) store.dispatch(setTabRegistrySnapshot({ localOpen: [], @@ -138,10 +280,9 @@ describe('TabsView', () => { , ) - const remoteCardTitle = screen.getByText('remote-device: session remote') - const remoteCard = remoteCardTitle.closest('article') - expect(remoteCard).toBeTruthy() - fireEvent.click(within(remoteCard as HTMLElement).getByText('Open copy')) + // Click the card directly (primary action = open copy for remote tabs) + const remoteCard = screen.getByLabelText('remote-device: session remote') + fireEvent.click(remoteCard) const tabs = store.getState().tabs.tabs const newTab = tabs.find((tab) => tab.title === 'session remote') @@ -154,4 +295,130 @@ describe('TabsView', () => { serverInstanceId: 'srv-remote', }) }) + + it('shows pane kind icons with distinct colors', () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRegistry: tabRegistryReducer, + connection: connectionReducer, + }, + }) + store.dispatch(setTabRegistrySnapshot({ + localOpen: [], + remoteOpen: [{ + tabKey: 'multi:pane', + tabId: 'mp-1', + serverInstanceId: 'srv-remote', + deviceId: 'remote', + deviceLabel: 'remote-device', + tabName: 'multi-pane tab', + status: 'open', + revision: 1, + createdAt: 1, + updatedAt: 2, + paneCount: 3, + titleSetByUser: false, + panes: [ + { paneId: 'p1', kind: 'terminal', payload: {} }, + { paneId: 'p2', kind: 'browser', payload: {} }, + { paneId: 'p3', kind: 'agent-chat', payload: {} }, + ], + }], + closed: [], + })) + + render( + + + , + ) + + const card = screen.getByLabelText('remote-device: multi-pane tab') + // Each unique pane kind gets an icon with aria-label + expect(within(card).getByLabelText('Terminal')).toBeInTheDocument() + expect(within(card).getByLabelText('Browser')).toBeInTheDocument() + expect(within(card).getByLabelText('Agent')).toBeInTheDocument() + expect(within(card).getByText('3 panes')).toBeInTheDocument() + }) + + it('shows individual pane items in context menu for multi-pane tabs', () => { + const store = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + tabRegistry: tabRegistryReducer, + connection: connectionReducer, + }, + }) + store.dispatch(setTabRegistrySnapshot({ + localOpen: [], + remoteOpen: [{ + tabKey: 'multi:ctx', + tabId: 'mc-1', + serverInstanceId: 'srv-remote', + deviceId: 'remote', + deviceLabel: 'remote-device', + tabName: 'ctx tab', + status: 'open', + revision: 1, + createdAt: 1, + updatedAt: 2, + paneCount: 2, + titleSetByUser: false, + panes: [ + { paneId: 'p1', kind: 'terminal', title: 'my-shell', payload: {} }, + { paneId: 'p2', kind: 'browser', title: 'docs', payload: {} }, + ], + }], + closed: [], + })) + + render( + + + , + ) + + const card = screen.getByLabelText('remote-device: ctx tab') + fireEvent.contextMenu(card) + + expect(screen.getByRole('menuitem', { name: /Open my-shell in new tab/i })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /Open docs in new tab/i })).toBeInTheDocument() + }) + + it('filters by status using segmented control', () => { + const store = createStore() + render( + + + , + ) + + // Click "Open" filter + const statusGroup = screen.getByRole('radiogroup', { name: 'Tab status filter' }) + fireEvent.click(within(statusGroup).getByText('Open')) + + // Remote open tab should be visible + expect(screen.getByLabelText('remote-device: remote open')).toBeInTheDocument() + + // Closed section should not be visible + expect(screen.queryByLabelText(/Recently closed/i)).not.toBeInTheDocument() + }) + + it('filters by device scope using segmented control', () => { + const store = createStore() + render( + + + , + ) + + const scopeGroup = screen.getByRole('radiogroup', { name: 'Device scope filter' }) + fireEvent.click(within(scopeGroup).getByText('This device')) + + // Remote tab should not be visible when filtered to local + expect(screen.queryByLabelText('remote-device: remote open')).not.toBeInTheDocument() + }) }) diff --git a/test/unit/client/components/TabsView.ws-error.test.tsx b/test/unit/client/components/TabsView.ws-error.test.tsx index 70ded82d..f03a1f06 100644 --- a/test/unit/client/components/TabsView.ws-error.test.tsx +++ b/test/unit/client/components/TabsView.ws-error.test.tsx @@ -18,6 +18,10 @@ vi.mock('@/lib/ws-client', () => ({ }), })) +vi.mock('@/lib/clipboard', () => ({ + copyText: vi.fn(() => Promise.resolve(true)), +})) + describe('TabsView websocket error state', () => { it('shows a clear tabs sync error banner when websocket is disconnected', () => { const store = configureStore({