feat: inline pinned tabs in TabBar (replaces PinnedRail)

M4 rewrite per user feedback: pinned terminals should display in the
Tabs bar itself, not a separate rail. Clicking activates in-place
(cross-session terminal rendering), no session jump.

Changes:
- Delete PinnedRail.tsx + tests/pinned-rail.spec.tsx
- pinned.ts: add createPinnedVirtualTab (virtual id = pinned:<sess>:<id>,
  meta.__pinnedHome = { sessionId, cwd, tabId }), injectPinnedIntoTree
  (appends virtual tabs to first leaf, overrides active), isPinnedVirtualId,
  parsePinnedVirtualId, isPinnedVirtualTab, getPinnedHomeScope
- tab-content-memo.ts: add effectiveTabId to memo key — TabContent passes
  the original tab id (not the virtual id) to the descriptor component so
  TerminalView's WS URL resolves to the home PTY
- Sidebar.tsx: augmented tree (injectPinnedIntoTree), activePinnedTabId
  state, wrappedActions (intercept virtual ids for activate/close/unpin/
  drag/float), renderTab passes home scope + effectiveTabId for pinned tabs,
  pinnedRevision replaces railRevision
- TabBar.tsx: pinned virtual tabs non-draggable, pin icon prefix, stripped
  context menu (Unpin/Close only, no float/close-others/left/right)
- sidebar.module.css: remove rail styles, add .pinnedTab (italic + tertiary)
- Docs: README/README_EN/AGENTS.md/design doc updated for inline approach

157/157 client-side tests pass; typecheck + build clean
This commit is contained in:
HuanLinOTO
2026-08-26 21:40:32 +08:00
parent acf639471d
commit e7c4578791
13 changed files with 416 additions and 547 deletions
-132
View File
@@ -1,132 +0,0 @@
/**
* 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 (
<div className={css.pinnedRail} data-dsh-pinned-rail>
<span className={css.pinnedRailLabel}>{t('pinnedRailLabel')}</span>
{entries.map(entry => (
<div
key={`${entry.homeSessionId}:${entry.tab.id}`}
className={css.pinnedTab}
data-dsh-pinned-tab={entry.tab.id}
title={tooltipOf(entry)}
onClick={() => { 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 })
}}
>
<IconPinOutline16 size={12} />
<IconTerminalOutline16 size={14} />
<span className={css.pinnedTabTitle}>{entry.tab.title}</span>
<button
type="button"
className={css.pinnedTabClose}
aria-label={t('closePinnedTerminal')}
onClick={(event) => {
event.stopPropagation()
onClose(entry.homeSessionId, entry.tab.id)
}}
>
<IconCloseFill14 />
</button>
</div>
))}
<Menu
open={menu !== null}
onClose={() => { 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={<span />}
/>
</div>
)
}
+148 -121
View File
@@ -41,7 +41,8 @@ import {
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 { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
import { IconPinOutline16 } from './icons.tsx'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
@@ -53,7 +54,6 @@ 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'
@@ -138,25 +138,22 @@ interface TabContentProps extends TabContentMemoKey {
/** Render the content of one tab (dispatched by type). */
const TabContent = memo(function TabContent(props: TabContentProps) {
const { tab, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
// One boundary per tab: a render crash in a viewer/editor shows a strip in
// THIS tab's pane only — the toggle cluster, the other tabs, and the panel
// stay alive (issue #31). The tab strip (close button) lives outside, so a
// crashing tab stays closable; the root boundary in index.tsx remains the
// last resort for errors in the sidebar shell itself. The descriptor is
// rendered as a REAL element (not called directly): a direct call would
// throw inside TabContent's own render, which the boundary cannot catch —
// as a child fiber, every render error (top-level or deep) lands in it.
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
// restores the real id at the component boundary.
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx, store, scope, tab, visible, expanded, revealed,
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
@@ -630,51 +627,48 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [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])
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
* the home session's PTY via WS, no session jump).
*
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
* (which doesn't notify — targeted opens must not re-render the active
* session). The `pinnedRevision` state bump forces the pinnedEntries
* useMemo to recompute after such an action.
*/
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
const [pinnedRevision, setPinnedRevision] = useState(0)
/**
* 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.
* Cross-session pinned-tab collection. Recomputed on every store notify,
* session-list change, and pinned action (the revision bump covers
* reduceFor updates that don't notify). Only 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])
}, [store, sessionId, cwd, snapshot, pinnedRevision])
/** Virtual SidebarTab objects for the pinned entries (stable references
* via useMemo so TabContent's memo comparator holds). */
const pinnedVirtualTabs = useMemo(
() => pinnedEntries.map(createPinnedVirtualTab),
[pinnedEntries],
)
/** The right panel's split tree with pinned virtual tabs injected into the
* first leaf. When `activePinnedTabId` is set, that leaf's `active` is
* overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
() => state === undefined ? undefined : injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
// 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
@@ -1263,46 +1257,75 @@ 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.
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
* detected by the `pinned:` prefix and routed to the HOME session via
* reduceFor (which doesn't notify — the revision bump is the local signal).
*/
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 */ })
const wrappedActions = useMemo<WorkbenchActions>(() => {
if (pinnedVirtualTabs.length === 0) return actions
const closePinnedInHome = (virtualId: string): void => {
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
store.reduceFor(homeSessionId, s => {
const leaf = leafWithTab(s.splits, originalId) ?? leafWithTab(s.bottomSplits, originalId)
if (leaf !== undefined) return closeTab(s, leaf.id, originalId)
if (s.floats.some(f => f.tab.id === originalId)) return closeFloatByTab(s, originalId)
return s
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
} else {
void api.ptyClose({ sessionId: homeSessionId }, originalId).catch(() => { /* already released */ })
}
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
}
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])
return {
...actions,
activateTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
setActivePinnedTabId(tabId)
} else {
setActivePinnedTabId(null)
actions.activateTab(paneId, tabId)
}
},
closeTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
closePinnedInHome(tabId)
} else {
actions.closeTab(paneId, tabId)
}
},
moveTabBefore: (payload, toPane, beforeTabId) => {
if (isPinnedVirtualId(payload.tabId)) return
if (isPinnedVirtualId(beforeTabId)) {
actions.moveTabToEdge(payload, toPane, 'center')
} else {
actions.moveTabBefore(payload, toPane, beforeTabId)
}
},
moveTabToEdge: (payload, toPane, zone) => {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
floatTab: (tabId) => {
if (isPinnedVirtualId(tabId)) return
actions.floatTab(tabId)
},
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
} else {
actions.pinTab?.(tabId, scope)
}
},
}
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
/**
* The explorer's @-reference button: append `@<relative path>` to the
@@ -1409,31 +1432,40 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// the panels do (the AGENTS §7.5 contract; plugin components honor
// `visible` to pause work, so tying floats to panelOpen would blank them
// the moment the sidebar collapses).
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => (
<TabContent
tab={tab}
paneId={paneId}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
revealed={state.revealed ?? []}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={
placement === 'float'
? true
: placement === 'bottom'
? state.bottomOpen && active
: state.panelOpen && active
}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
tabsVersion={tabsVersion}
/>
)
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => {
// Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
// TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
// the descriptor component receives the ORIGINAL tab id (the virtual id
// is only a display key). Regular tabs: effectiveTabId is undefined (no
// override), scope is the current session's.
const home = getPinnedHomeScope(tab)
return (
<TabContent
tab={tab}
effectiveTabId={home?.tabId}
paneId={paneId}
sessionId={home?.sessionId ?? sessionId}
cwd={home?.cwd ?? cwd}
expanded={state.expanded}
revealed={state.revealed ?? []}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={
placement === 'float'
? true
: placement === 'bottom'
? state.bottomOpen && active
: state.panelOpen && active
}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
tabsVersion={tabsVersion}
/>
)
}
return (
<div data-dsh-panel-host {...osFileDragShield}>
@@ -1534,16 +1566,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
/>
)}
<div className={css.panelBody}>
<PinnedRail
entries={pinnedEntries}
onFocus={onFocusPinned}
onUnpin={onUnpinPinned}
onClose={onClosePinned}
/>
<Workbench
state={state}
tree={augmentedTree}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
actions={wrappedActions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
+23 -6
View File
@@ -15,6 +15,8 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { isAgentTabId } from './state.ts'
import { isPinnedVirtualTab } from './pinned.ts'
import { IconPinOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -181,13 +183,15 @@ export function TabBar(props: {
}}
>
<div ref={listRef} className={css.tabList}>
{tabs.map(tab => (
{tabs.map(tab => {
const pinned = isPinnedVirtualTab(tab)
return (
<div
key={tab.id}
className={clsx(css.tab, active === tab.id && css.tabActive)}
className={clsx(css.tab, active === tab.id && css.tabActive, pinned && css.pinnedTab)}
title={tab.title}
draggable
onDragStart={(event) => {
draggable={!pinned}
onDragStart={pinned ? undefined : (event) => {
setTabDragging(true)
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
event.dataTransfer.effectAllowed = 'move'
@@ -195,6 +199,7 @@ export function TabBar(props: {
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
onDrop={(event) => {
if (pinned) { event.stopPropagation(); return }
event.preventDefault()
event.stopPropagation()
setTabDragging(false)
@@ -223,6 +228,7 @@ export function TabBar(props: {
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
}}
>
{pinned && <IconPinOutline16 size={16} />}
{getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
@@ -238,7 +244,8 @@ export function TabBar(props: {
<IconCloseFill14 />
</button>
</div>
))}
)
})}
{/*
The + sits immediately after the rightmost tab (sticky at the
right edge of the scrollport when the tabs overflow, so it stays
@@ -287,9 +294,13 @@ export function TabBar(props: {
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
// row (pinned). Non-terminal tabs and missing onPinTab get no
// pin entry at all — the menu stays exactly the legacy 5-item
// shape.
// shape. Pinned VIRTUAL tabs (injected from other sessions)
// get a stripped menu: only Unpin + Close (no float, no
// close-others/left/right — those are pane-scoped operations
// that don't apply to cross-session virtual tabs).
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
const isTerminal = targetTab?.type === 'terminal'
const isPinnedVirtual = targetTab !== undefined && isPinnedVirtualTab(targetTab)
const pinEntries = isTerminal && onPinTab !== undefined
? targetTab!.pin !== undefined
? [{ id: 'unpin', label: t('unpinTerminal') }]
@@ -302,6 +313,12 @@ export function TabBar(props: {
],
}]
: []
if (isPinnedVirtual) {
return [
...pinEntries,
{ id: 'close', label: t('close') },
]
}
return [
{ id: 'float', label: t('moveToFreeWindow') },
...pinEntries,
+90 -6
View File
@@ -3,9 +3,12 @@
*
* A pinned terminal tab lives in its HOME session's state (the only
* authoritative copy) — switching sessions never copies or projects it.
* The PinnedRail renders the tabs OTHER sessions have pinned that should
* surface in the CURRENT (viewer) session, so it needs a read-only
* resolution pass over every cached session state.
* The viewer session's TabBar renders the tabs OTHER sessions have pinned
* as VIRTUAL tabs appended to the first leaf's tab list, so the user sees
* them inline with their own tabs. Clicking a virtual tab activates it
* in-place: TerminalView connects to the home session's PTY via WebSocket
* (sessionId + tab query params resolve to the home PTY on the host side),
* so the terminal renders in the current workbench without jumping sessions.
*
* Visibility rule:
*
@@ -20,11 +23,11 @@
* resolves, the next store notify re-runs the resolver with the real cwd.
*
* The viewer's OWN session is excluded: its pinned tabs are already on its
* tab strip, so rendering them again in the rail would double-show. Tabs
* whose `pin` field is missing or whose `type` is not `'terminal'` are
* own tab strip, so rendering them again as virtual tabs would double-show.
* Tabs whose `pin` field is missing or whose `type` is not `'terminal'` are
* ignored — only terminal tabs can be pinned.
*/
import type { SidebarState, SidebarTab } from './state.ts'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
/** A pinned terminal surfaced to the viewer, paired with its home session. */
export interface PinnedTabEntry {
@@ -38,6 +41,87 @@ export interface PinnedViewer {
cwd: string | undefined
}
/** The home-session scope stored on a pinned virtual tab's meta, so
* TerminalView connects to the home session's PTY (not the viewer's). */
export interface PinnedHomeScope {
sessionId: string
cwd: string | undefined
/** The original tab id in the home session (TerminalView's `tab` param). */
tabId: string
}
const PINNED_META_KEY = '__pinnedHome'
const PINNED_VID_PREFIX = 'pinned:'
/** Whether a tab id is a pinned virtual id (prefixed). */
export function isPinnedVirtualId(tabId: string): boolean {
return tabId.startsWith(PINNED_VID_PREFIX)
}
/** Parse a pinned virtual id into its home session id and original tab id.
* Format: `pinned:<homeSessionId>:<originalTabId>` — session ids are UUIDs
* (no colons), so the first colon after the prefix delimits the session. */
export function parsePinnedVirtualId(tabId: string): { homeSessionId: string; tabId: string } {
const rest = tabId.slice(PINNED_VID_PREFIX.length)
const sep = rest.indexOf(':')
if (sep < 0) return { homeSessionId: rest, tabId: '' }
return { homeSessionId: rest.slice(0, sep), tabId: rest.slice(sep + 1) }
}
/** Extract the home scope from a pinned virtual tab's meta (undefined for
* regular tabs). */
export function getPinnedHomeScope(tab: SidebarTab): PinnedHomeScope | undefined {
const meta = tab.meta as Record<string, unknown> | undefined
return (meta?.[PINNED_META_KEY] as PinnedHomeScope | undefined) ?? undefined
}
/** Whether a tab is a pinned virtual tab (injected from another session). */
export function isPinnedVirtualTab(tab: SidebarTab): boolean {
return getPinnedHomeScope(tab) !== undefined
}
/** Create a virtual SidebarTab for a pinned entry. The virtual id is unique
* (prefixed with home session) to avoid collision with the viewer's own
* tab ids; the original id is stored in meta for TerminalView. */
export function createPinnedVirtualTab(entry: PinnedTabEntry): SidebarTab {
const { tab, homeSessionId } = entry
const home: PinnedHomeScope = {
sessionId: homeSessionId,
cwd: tab.pin?.homeCwd,
tabId: tab.id,
}
return {
...tab,
id: PINNED_VID_PREFIX + homeSessionId + ':' + tab.id,
meta: { ...(tab.meta as Record<string, unknown> | undefined ?? {}), [PINNED_META_KEY]: home },
}
}
/** Inject pinned virtual tabs into the first leaf of a split tree, and
* override that leaf's `active` when a pinned tab is activated. Returns
* the original tree when there are no pinned tabs and no active override. */
export function injectPinnedIntoTree(
tree: SplitNode,
pinned: readonly SidebarTab[],
activePinnedId: string | null,
): SplitNode {
if (pinned.length === 0 && activePinnedId === null) return tree
if (tree.kind === 'leaf') {
return {
...tree,
tabs: pinned.length > 0 ? [...tree.tabs, ...pinned] : tree.tabs,
active: activePinnedId ?? tree.active,
}
}
return {
...tree,
children: [
injectPinnedIntoTree(tree.children[0]!, pinned, activePinnedId),
...tree.children.slice(1),
],
}
}
/**
* Whether a pinned tab is visible to the viewer session. Conservative on
* unknown cwd: a `workspace` pin with no `homeCwd` is visible everywhere
+7 -75
View File
@@ -458,7 +458,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.toggleCluster,
.toggleButton,
.tabBar,
.pinnedRail,
.floatHeader {
-webkit-app-region: no-drag;
}
@@ -866,85 +865,18 @@ 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;
}
/* ── Pinned-terminal tab (v0.17.0+) ───────────────────────────────────── */
/* Pinned virtual tabs (injected from other sessions) render inline in the
tab bar with a pin icon and a subtle tint to distinguish them from the
session's own tabs. Token-driven so skins follow. */
.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;
color: var(--dsw-alias-label-tertiary);
font-style: italic;
}
.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);
color: var(--dsw-alias-label-secondary);
}
/* ── Explorer ──────────────────────────────────────────────────────────── */
+7 -1
View File
@@ -27,6 +27,11 @@ export interface TabContentMemoKey {
revealed: string[]
localeRevision: string
tabsVersion: number
/** When the tab is a pinned virtual tab (injected from another session),
* this overrides `tab.id` passed to the tab descriptor's component so
* TerminalView connects to the home session's PTY by the original id.
* Undefined for regular tabs (no override). */
effectiveTabId: string | undefined
}
/** True when the cell may skip a re-render (all render-affecting fields
@@ -44,6 +49,7 @@ export function tabContentCompare(prev: TabContentMemoKey, next: TabContentMemoK
prev.expanded === next.expanded &&
prev.revealed === next.revealed &&
prev.localeRevision === next.localeRevision &&
prev.tabsVersion === next.tabsVersion
prev.tabsVersion === next.tabsVersion &&
prev.effectiveTabId === next.effectiveTabId
)
}