From 531bb531299383f95a85a19566c32bd96058a731 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 26 Aug 2026 18:42:34 +0800 Subject: [PATCH] feat: pinned rail component + cross-session focus M4 of pinned terminals (v0.17.0): - New PinnedRail component: compact strip above the tab bar, renders pinned terminals from other sessions (pin icon + terminal icon + title + tooltip with kind/scope/cwd) - Click navigates to the home session (ctx.sessions.open) and activates the tab (or raises its float) via the pinnedJumpRef effect - Right-click menu: Unpin / Close Terminal; middle-click close - IconPinOutline16 added to icons.tsx - Rail styles in sidebar.module.css (token-driven, no-drag opt-out) - Sidebar: collectPinnedTabs on store snapshot + session list, railRevision state forces re-render after reduceFor (which doesn't notify) - Cross-session close: reduceFor(closeTab/closeFloatByTab) + ptyClose/ agentPtyClose; cross-session unpin: reduceFor(setTabPin null) --- src/client/PinnedRail.tsx | 132 ++++++++++++++++++ src/client/Sidebar.tsx | 102 +++++++++++++- src/client/icons.tsx | 11 ++ src/client/sidebar.module.css | 82 ++++++++++++ tests/pinned-rail.spec.tsx | 200 ++++++++++++++++++++++++++++ tests/tab-bar-context-menu.spec.tsx | 12 +- 6 files changed, 531 insertions(+), 8 deletions(-) create mode 100644 src/client/PinnedRail.tsx create mode 100644 tests/pinned-rail.spec.tsx diff --git a/src/client/PinnedRail.tsx b/src/client/PinnedRail.tsx new file mode 100644 index 0000000..c484612 --- /dev/null +++ b/src/client/PinnedRail.tsx @@ -0,0 +1,132 @@ +/** + * The PinnedRail (v0.17.0+): a compact strip above the tab bar showing + * terminal tabs pinned in OTHER sessions that should surface in the current + * session (global pins, or workspace pins whose homeCwd matches the viewer's + * cwd). The rail is the only cross-session surface — pinned tabs themselves + * live in their home session's state (the authoritative copy), and the rail + * just renders a clickable affordance to jump back to them. + * + * Click navigates to the home session and activates the tab (or raises its + * free window). Right-click offers unpin / close; middle-click closes. The + * rail hides itself when no pinned tabs are visible. + */ +import { useEffect, useRef, useState } from 'react' +import { IconCloseFill14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PinnedTabEntry } from './pinned.ts' +import { isAgentTabId } from './state.ts' +import { IconPinOutline16, IconTerminalOutline16 } from './icons.tsx' +import { t } from './locales.ts' +import css from './sidebar.module.css' + +/** The kind label for the tooltip (UI vs Agent terminal). */ +function kindLabel(tabId: string): string { + return isAgentTabId(tabId) ? t('pinnedTerminalKindAgent') : t('pinnedTerminalKindUi') +} + +/** The scope label for the tooltip. */ +function scopeLabel(scope: 'workspace' | 'global'): string { + return scope === 'global' ? t('pinnedTerminalScopeGlobal') : t('pinnedTerminalScopeWorkspace') +} + +/** Build the tooltip string: "{kind} · {scope} · {cwd}". */ +function tooltipOf(entry: PinnedTabEntry): string { + const pin = entry.tab.pin + if (pin === undefined) return entry.tab.title + const cwd = pin.homeCwd ?? '—' + return t('pinnedTerminalTooltip', { + kind: kindLabel(entry.tab.id), + scope: scopeLabel(pin.scope), + cwd, + }) +} + +export function PinnedRail(props: { + entries: readonly PinnedTabEntry[] + onFocus: (homeSessionId: string, tabId: string) => void + onUnpin: (homeSessionId: string, tabId: string) => void + onClose: (homeSessionId: string, tabId: string) => void +}) { + const { entries, onFocus, onUnpin, onClose } = props + const [menu, setMenu] = useState<{ homeSessionId: string; tabId: string; x: number; y: number } | null>(null) + + // Middle-click close (mirrors TabBar's middlePressed pattern): the press + // target is recorded on middle mousedown (preventDefaulted to disarm + // Chrome's autoscroll) and the close settles on the first middle mouseup + // OVER that same entry. Release-position semantics match VS Code. + const middlePressed = useRef<{ homeSessionId: string; tabId: string; node: HTMLElement } | null>(null) + const closeRef = useRef(onClose) + useEffect(() => { closeRef.current = onClose }) + useEffect(() => { + const onMouseUp = (event: MouseEvent): void => { + if (event.button !== 1) return + const pressed = middlePressed.current + middlePressed.current = null + if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) { + closeRef.current(pressed.homeSessionId, pressed.tabId) + } + } + window.addEventListener('mouseup', onMouseUp) + return () => { window.removeEventListener('mouseup', onMouseUp) } + }, []) + + if (entries.length === 0) return null + + return ( +
+ {t('pinnedRailLabel')} + {entries.map(entry => ( +
{ onFocus(entry.homeSessionId, entry.tab.id) }} + onMouseDown={(event) => { + if (event.button === 1) { + event.preventDefault() + middlePressed.current = { homeSessionId: entry.homeSessionId, tabId: entry.tab.id, node: event.currentTarget } + } + }} + onContextMenu={(event) => { + event.preventDefault() + setMenu({ homeSessionId: entry.homeSessionId, tabId: entry.tab.id, x: event.clientX, y: event.clientY }) + }} + > + + + {entry.tab.title} + +
+ ))} + { setMenu(null) }} + items={[ + { id: 'unpin', label: t('unpinTerminal') }, + { id: 'close', label: t('closePinnedTerminal') }, + ]} + onSelect={(id) => { + const target = menu + if (target === null) return + setMenu(null) + if (id === 'unpin') onUnpin(target.homeSessionId, target.tabId) + else if (id === 'close') onClose(target.homeSessionId, target.tabId) + }} + portal + align="start" + getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))} + anchor={} + /> +
+ ) +} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 80f4022..28d7789 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -35,11 +35,13 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarSessionList } from '../context-types.ts' import { appendToDraft } from './conversation-draft.ts' import { - BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs, - moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals, + BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, + floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs, + moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, } from './state.ts' +import { collectPinnedTabs, type PinnedTabEntry } from './pinned.ts' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { isNarrowWidth, useViewportSize } from './breakpoints.ts' @@ -51,6 +53,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts' import type { NewTabOption } from './TabBar.tsx' import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { FreeWindow } from './FreeWindow.tsx' +import { PinnedRail } from './PinnedRail.tsx' import { relativeTo } from './paths.ts' import { OrphanedTab } from './OrphanedTab.tsx' import { RenderBoundary } from './RenderBoundary.tsx' @@ -626,6 +629,53 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) }, [sessionId, store, ctx]) + /** + * Pinned-terminal jump-back (v0.17.0+): clicking a rail entry from another + * session navigates to the home session and activates the pinned tab (or + * raises its free window). The ref records the target {homeSessionId, + * tabId}; once the current session becomes the home session, the effect + * fires: expand the panel if collapsed, then activate the tab (docked) or + * raise its float. Mirrors the subagentJumpRef pattern above. + * + * The railRevision state forces the entries useMemo to recompute after a + * rail action (unpin/close) that goes through reduceFor — which doesn't + * notify (targeted opens must not re-render the active session's UI). The + * revision bump is the only signal the rail has that another session's + * state changed. + */ + const pinnedJumpRef = useRef<{ homeSessionId: string; tabId: string } | undefined>(undefined) + const [railRevision, setRailRevision] = useState(0) + useEffect(() => { + const pending = pinnedJumpRef.current + if (pending === undefined || sessionId !== pending.homeSessionId) return + pinnedJumpRef.current = undefined + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + store.reduce(s => { + // Docked: find in either tree and activate. + for (const leaf of allLeaves(s.splits).concat(allLeaves(s.bottomSplits))) { + if (leaf.tabs.some(tab => tab.id === pending.tabId)) { + return activateTab(s, leaf.id, pending.tabId) + } + } + // Floated: raise the window. + const floated = floatWithTab(s, pending.tabId) + if (floated !== undefined) return raiseFloat(s, floated.id) + return s + }) + }, [sessionId, store]) + + /** + * Cross-session pinned-tab collection for the rail. Recomputed on every + * store notify (active session state change), session-list change (cwd + * hydration), and rail action (the revision bump covers reduceFor updates + * that don't notify). The rail only shows tabs from OTHER sessions — the + * viewer's own pinned tabs are already on its tab strip. + */ + const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => { + if (sessionId === undefined) return [] + return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd }) + }, [store, sessionId, cwd, snapshot, railRevision]) + // The app shell's center column: the bottom panel spans ONLY that column // ("squeezes the agent output area") — it starts at the app sidebar's // right edge and ends at the details column's left edge (the details @@ -1212,6 +1262,48 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, }), [store, sessionId, cwd]) + /** + * Pinned-rail actions (v0.17.0+): unpin and close target the HOME session + * (which is NOT the current session — the rail only shows other sessions' + * pinned tabs). reduceFor updates the home session's state without + * notifying (targeted opens must not re-render the active session); the + * railRevision bump is the local signal that makes the entries useMemo + * recompute and the rail re-render. + */ + const onUnpinPinned = useCallback((homeSessionId: string, tabId: string): void => { + store.reduceFor(homeSessionId, s => setTabPin(s, tabId, null)) + setRailRevision(v => v + 1) + }, [store]) + + const onClosePinned = useCallback((homeSessionId: string, tabId: string): void => { + store.reduceFor(homeSessionId, s => { + // Docked: find in either tree and close (empties pane if last tab). + const leaf = leafWithTab(s.splits, tabId) ?? leafWithTab(s.bottomSplits, tabId) + if (leaf !== undefined) return closeTab(s, leaf.id, tabId) + // Floated: close the window (the tab leaves with it). + if (s.floats.some(f => f.tab.id === tabId)) return closeFloatByTab(s, tabId) + return s + }) + // Kill the PTY (the tab is gone; the WS close frame may not reach the + // host if the terminal was in a non-active session). + if (isAgentTabId(tabId)) { + void api.agentPtyClose(agentUuidOf(tabId)).catch(() => { /* already released */ }) + } else { + // The home cwd is the pin's homeCwd (snapshot at pin time); fall back + // to undefined for a global pin without homeCwd. + void api.ptyClose({ sessionId: homeSessionId }, tabId).catch(() => { /* already released */ }) + } + setRailRevision(v => v + 1) + }, [store]) + + const onFocusPinned = useCallback((homeSessionId: string, tabId: string): void => { + // Navigate to the home session (the sessions service switches the host + // current session; Sidebar's setSession effect follows). Record the + // jump target so the effect above can activate the tab once we land. + pinnedJumpRef.current = { homeSessionId, tabId } + ctx.sessions.open?.(homeSessionId) + }, [ctx]) + /** * The explorer's @-reference button: append `@` to the * session's composer draft (space-separated). The conversation service is @@ -1442,6 +1534,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { /> )}
+ ( ) +/** + * Pin glyph in the app's outline style (1.5px stroke, currentColor): a pushpin + * tilted to the lower-right. Used by the PinnedRail and the tab context menu's + * pin entry (v0.17.0+). + */ +export const IconPinOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) + // ── File-viewer inventory glyphs (Side card settings page) ──────────────── /** Image viewer glyph: a picture frame with a sun and a mountain. */ diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 26e1862..74ffb19 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -458,6 +458,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { .toggleCluster, .toggleButton, .tabBar, +.pinnedRail, .floatHeader { -webkit-app-region: no-drag; } @@ -865,6 +866,87 @@ body[data-dsh-sidebar-dragging] .bottomPanel { color: var(--dsw-alias-label-primary); } +/* ── Pinned-terminal rail (v0.17.0+) ──────────────────────────────────── */ + +/* A compact strip above the tab bar showing pinned terminals from OTHER + sessions. Only renders when there is at least one visible pinned tab. + Hairline-separated from the tab bar below; token-driven so skins follow. */ +.pinnedRail { + flex: none; + display: flex; + align-items: stretch; + gap: 0; + padding: 0 4px; + border-bottom: 1px solid var(--dsw-alias-border-l1); + background: var(--dsw-alias-bg-layer-1); + overflow-x: auto; + scrollbar-width: none; +} + +.pinnedRail::-webkit-scrollbar { + display: none; +} + +.pinnedRailLabel { + flex: none; + display: inline-flex; + align-items: center; + padding: 0 6px 0 4px; + font: var(--dsw-font-xxxs-strong-11); + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +.pinnedTab { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 180px; + padding: 0 6px; + height: 28px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-secondary); + border-right: 1px solid var(--dsw-alias-border-l1); + background: transparent; + cursor: pointer; + user-select: none; + border-radius: 0; +} + +.pinnedTab:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.pinnedTabTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pinnedTabClose { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + flex: none; +} + +.pinnedTabClose:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + /* ── Explorer ──────────────────────────────────────────────────────────── */ .explorer { diff --git a/tests/pinned-rail.spec.tsx b/tests/pinned-rail.spec.tsx new file mode 100644 index 0000000..be12bfb --- /dev/null +++ b/tests/pinned-rail.spec.tsx @@ -0,0 +1,200 @@ +/** + * PinnedRail component tests (v0.17.0+). The rail is a pure presentation + * component: it renders the entries passed to it and fires callbacks. The + * cross-session resolution (collectPinnedTabs) and the jump-back effect are + * tested elsewhere (pinned.spec.ts, state.spec.ts). These tests cover the + * rail's render conditions, tooltip, click/focus, right-click menu, and + * middle-click close. + */ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' + +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +import { PinnedRail } from '../src/client/PinnedRail.tsx' +import type { PinnedTabEntry } from '../src/client/pinned.ts' + +/** Point the browser-language fallback at Chinese so the labels assert. */ +function stubZh(): void { + Object.defineProperty(globalThis, 'navigator', { + value: { language: 'zh-CN' }, + configurable: true, + }) +} + +function makeEntry(overrides: Partial & { tabId?: string } = {}): PinnedTabEntry { + const tabId = overrides.tabId ?? 'terminal:1' + return { + tab: { + id: tabId, + type: 'terminal', + title: 'My Terminal', + pin: { scope: 'global' }, + ...(overrides.tab?.pin ? { pin: overrides.tab.pin } : {}), + }, + homeSessionId: 'home-session', + ...overrides, + } as PinnedTabEntry +} + +function mountRail(entries: readonly PinnedTabEntry[]): { + rail: HTMLElement | null + entryEls: HTMLElement[] + onFocus: ReturnType + onUnpin: ReturnType + onClose: ReturnType + unmount: () => void +} { + const container = document.createElement('div') + document.body.append(container) + const onFocus = vi.fn() + const onUnpin = vi.fn() + const onClose = vi.fn() + const root: Root = createRoot(container) + act(() => { + root.render(createElement(PinnedRail, { entries, onFocus, onUnpin, onClose })) + }) + const rail = container.querySelector('[data-dsh-pinned-rail]') + const entryEls = [...container.querySelectorAll('[data-dsh-pinned-tab]')] + return { rail, entryEls, onFocus, onUnpin, onClose, unmount: () => { act(() => { root.unmount() }); container.remove() } } +} + +/** The portaled menu rows (empty when the menu is closed). */ +function menuItems(): HTMLElement[] { + return [...document.querySelectorAll('[role="menuitem"]')] +} + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('PinnedRail', () => { + it('renders nothing when there are no entries', () => { + stubZh() + const { rail } = mountRail([]) + expect(rail).toBeNull() + }) + + it('renders the label and one entry per pinned tab with a tooltip', () => { + stubZh() + const entries = [ + makeEntry({ tabId: 'terminal:1' }), + makeEntry({ tabId: 'terminal:2', tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'workspace', homeCwd: '/proj' } } }), + ] + const { rail, entryEls } = mountRail(entries) + expect(rail).not.toBeNull() + expect(entryEls).toHaveLength(2) + // The label is present. + expect(rail!.textContent).toContain('固定终端') + // The tooltip carries the kind/scope/cwd info. + expect(entryEls[0]!.title).toContain('UI 终端') + expect(entryEls[0]!.title).toContain('固定到全局') + expect(entryEls[1]!.title).toContain('固定到工作区') + expect(entryEls[1]!.title).toContain('/proj') + }) + + it('uses the Agent label for agent terminal tabs', () => { + stubZh() + const entries = [makeEntry({ tabId: 'agent:abc-123', tab: { id: 'agent:abc-123', type: 'terminal', title: 'Agent T', pin: { scope: 'global' } } })] + const { entryEls } = mountRail(entries) + expect(entryEls[0]!.title).toContain('Agent 终端') + }) + + it('click fires onFocus with the home session id and tab id', () => { + stubZh() + const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })] + const { entryEls, onFocus, unmount } = mountRail(entries) + try { + act(() => { entryEls[0]!.click() }) + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onFocus).toHaveBeenCalledWith('home-A', 'terminal:1') + } finally { + unmount() + } + }) + + it('right-click opens a menu with Unpin and Close, and clicking Unpin fires onUnpin', () => { + stubZh() + const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })] + const { entryEls, onUnpin, unmount } = mountRail(entries) + try { + act(() => { + entryEls[0]!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 30 })) + }) + expect(menuItems().map(item => item.textContent)).toEqual(['取消固定', '关闭终端']) + act(() => { menuItems()[0]!.click() }) + expect(onUnpin).toHaveBeenCalledWith('home-A', 'terminal:1') + } finally { + unmount() + } + }) + + it('right-click Close fires onClose with the home session id and tab id', () => { + stubZh() + const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })] + const { entryEls, onClose, unmount } = mountRail(entries) + try { + act(() => { + entryEls[0]!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 50, clientY: 30 })) + }) + act(() => { menuItems()[1]!.click() }) + expect(onClose).toHaveBeenCalledWith('home-A', 'terminal:1') + } finally { + unmount() + } + }) + + it('the per-entry X button fires onClose and stops propagation (no focus)', () => { + stubZh() + const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })] + const { entryEls, onFocus, onClose, unmount } = mountRail(entries) + try { + const closeBtn = entryEls[0]!.querySelector('[class*="pinnedTabClose"]')! + act(() => { closeBtn.click() }) + expect(onClose).toHaveBeenCalledWith('home-A', 'terminal:1') + expect(onFocus).not.toHaveBeenCalled() + } finally { + unmount() + } + }) + + it('middle-click close fires onClose (release on the same entry)', () => { + stubZh() + const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })] + const { entryEls, onClose, unmount } = mountRail(entries) + try { + act(() => { + entryEls[0]!.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true, cancelable: true })) + }) + act(() => { + // mouseup must land on the pressed entry's node. + entryEls[0]!.dispatchEvent(new MouseEvent('mouseup', { button: 1, bubbles: true })) + }) + expect(onClose).toHaveBeenCalledWith('home-A', 'terminal:1') + } finally { + unmount() + } + }) + + it('middle-click press then release elsewhere cancels the close', () => { + stubZh() + const entries = [makeEntry({ tabId: 'terminal:1', homeSessionId: 'home-A' })] + const { entryEls, onClose, unmount } = mountRail(entries) + try { + act(() => { + entryEls[0]!.dispatchEvent(new MouseEvent('mousedown', { button: 1, bubbles: true, cancelable: true })) + }) + act(() => { + // Release on a different target (document.body, not the entry). + document.body.dispatchEvent(new MouseEvent('mouseup', { button: 1, bubbles: true })) + }) + expect(onClose).not.toHaveBeenCalled() + } finally { + unmount() + } + }) +}) diff --git a/tests/tab-bar-context-menu.spec.tsx b/tests/tab-bar-context-menu.spec.tsx index b34ca89..1397fd8 100644 --- a/tests/tab-bar-context-menu.spec.tsx +++ b/tests/tab-bar-context-menu.spec.tsx @@ -31,12 +31,12 @@ function stubZh(): void { const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签'] -function mountBar(tabs: SidebarTab[], opts: { onPinTab?: ReturnType } = {}): { +function mountBar(tabs: SidebarTab[], opts: { onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void } = {}): { tabEls: HTMLElement[] onClose: ReturnType onActivate: ReturnType onFloatTab: ReturnType - onPinTab?: ReturnType + onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void unmount: () => void } { const container = document.createElement('div') @@ -233,7 +233,7 @@ describe('TabBar pin submenu (v0.17.0)', () => { it('adds a "Pin Terminal ▸" submenu for an unpinned UI terminal', () => { stubZh() - const onPinTab = vi.fn() + const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>() const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab }) try { act(() => { rightClick(tabEls[2]!) }) // t3 = UI terminal @@ -259,7 +259,7 @@ describe('TabBar pin submenu (v0.17.0)', () => { it('uses the "Pin Agent Terminal" label for an agent terminal tab', () => { stubZh() - const onPinTab = vi.fn() + const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>() const tabs: SidebarTab[] = [ { id: 'agent:abc-123', type: 'terminal', title: 'Agent T' }, ] @@ -275,7 +275,7 @@ describe('TabBar pin submenu (v0.17.0)', () => { it('shows "Unpin" instead of the submenu for an already-pinned terminal', () => { stubZh() - const onPinTab = vi.fn() + const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>() const tabs: SidebarTab[] = [ { id: 'terminal:1', type: 'terminal', title: 'T', pin: { scope: 'global' } }, ] @@ -295,7 +295,7 @@ describe('TabBar pin submenu (v0.17.0)', () => { it('does not add a pin entry for non-terminal tabs even with onPinTab provided', () => { stubZh() - const onPinTab = vi.fn() + const onPinTab = vi.fn<(tabId: string, scope: 'workspace' | 'global' | null) => void>() const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab }) try { act(() => { rightClick(tabEls[0]!) }) // t1 = editor