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:
@@ -356,7 +356,7 @@ ctx.effect(() => {
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `sidechat` | 35 | 否(createTab 铸造 `sidechat:<uuid>`/按 `meta.threadId` 去重) | 否 | 侧边对话(Codex 风格,**每个对话一个独立 Tab**):打开 Tab 即自动创建空线程(composer 拥有首条消息,host 侧包裹边界提示 + 创建时停泊的进行中快照,首条消息赢得真实标签并同步 Tab 标题);线程 = 插件自建子会话(自定义种子继承父会话完整上下文,进行中回合以 `interrupted` 冻结诚实闭合;种子尾部带合法 `subagent/descriptor`——否则 cold 线程在宿主 subagents.list 里是 corrupt 诊断行——SubagentView/subagent-detect 按 `Side: ` 前缀过滤保持拓扑零噪音),`origin:'subagent'` 隐藏于主列表;生命周期走自有 `/sidebar/api/sidechat.*` 路由(`ctx.agents.create/resume` + `agent.followup/cancel` + `sidechat.info` 读 Agent 身份/状态);头部菜单可切换/重开既有线程(`parkSidechatReopen` + `sidechat:<threadId>` 确定性 id),关闭 Tab 释放 live agent(历史保留);重开 Tab 经 `collectOwnEvents` 大页回源到种子边界(cold 读会展开 chunk 压缩行,小窗口会丢早期 tool/call 行);「保存为新会话」= `session.fork` 提升顶层会话(必须方法调用形态,`this` 敏感)。UI 对齐主对话区(用户气泡 `--dsw-specific-bubble`、assistant 通栏 markdown、胶囊 composer + 圆形发送/停止钮、运行扫光状态行)。见 [设计文档](docs/plans/2026-08-20-sidechat-tab-design.md) |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增)。v0.17.0+ 支持右键「固定到工作区 / 固定到全局」:固定后切换会话不消失,在面板顶部 PinnedRail 跨会话呈现(global 任意会话可见;workspace 仅同 cwd 会话可见);Agent 终端(`agent:<uuid>`)被 reconcile 移除时豁免保留为「已断开」(xterm 重连失败 banner 承担断开信号);pin 元数据 `tab.pin = { scope, homeCwd? }` 随宿主会话 state 持久化,渲染期跨会话解析(`collectPinnedTabs`),不写跨会话投影 |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增)。v0.17.0+ 支持右键「固定到工作区 / 固定到全局」:固定后切换会话不消失,在 TabBar 内联呈现为虚拟 Tab(跨会话注入第一个 leaf 的 tabs 尾部,virtual id = `pinned:<homeSessionId>:<tabId>`,meta 携带 home scope);点击就地激活(TerminalView 按 home sessionId+tab 连接宿主 PTY,不跳转会话);global 任意会话可见;workspace 仅同 cwd 会话可见;Agent 终端(`agent:<uuid>`)被 reconcile 移除时豁免;pin 元数据 `tab.pin = { scope, homeCwd? }` 随宿主会话 state 持久化,渲染期跨会话解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`),不写跨会话投影 |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🪟 自由窗口**:把标签栏的任一 tab 拖到主会话区域——成为可移动 / 缩放 / 置顶的悬浮窗口(默认 390×780),拖回侧边栏 pane 即停靠,随会话持久化;`features` 含 `'floatWindows'`,插件 tab 无差别支持
|
||||
- **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在面板顶部 PinnedRail 跨会话呈现(点击切回宿主会话并激活,PTY 保活无需重连);Agent 终端被 reconcile 移除时豁免保留为「已断开」
|
||||
- **📌 固定终端**:右键终端 Tab 可「固定到工作区 / 固定到全局」——固定后切换会话不消失,在 TabBar 内联呈现(跨会话虚拟 Tab,点击就地激活,PTY 按 home 会话 id+tab 直连宿主 PTY,无需切回宿主会话);Agent 终端被 reconcile 移除时豁免保留
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@
|
||||
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
|
||||
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
|
||||
- **🪟 Free Windows**: drag any tab onto the main conversation area to turn it into a movable / resizable / raiseable floating window (default 390×780); drag it back onto a pane to dock; persisted per session. `features` includes `'floatWindows'` and plugin tabs are supported identically
|
||||
- **📌 Pinned Terminals**: right-click a terminal tab to "Pin to Workspace / Pin Globally" — pinned terminals survive session switches and surface in a PinnedRail at the top of the panel across sessions (click jumps back to the home session and activates; PTY stays alive, no reconnect needed); agent terminals exempted from reconcile removal
|
||||
- **📌 Pinned Terminals**: right-click a terminal tab to "Pin to Workspace / Pin Globally" — pinned terminals survive session switches and surface inline in the TabBar as virtual tabs (click activates in-place, PTY connects directly to the home session's PTY via WS, no session jump needed); agent terminals exempted from reconcile removal
|
||||
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
|
||||
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
|
||||
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
|
||||
@@ -120,9 +120,19 @@ getSessionStates(): ReadonlyMap<string, SidebarState>
|
||||
|
||||
设计 §6 原表「Agent 终端被 reconcile 移除」行提到「标题渲染时追加 `(已断开)`」。实施时收敛为:**M3 只做豁免,断开的用户可见信号由 xterm 现有重连失败 banner 承担**。原因:rail/tab 标题渲染处无法知道宿主列表(`reconcileAgentTerminals` 的 uuid 匹配是 state 级的,渲染层不持有该信息),改 meta 会写持久化(违反「reconcile uuid 匹配不受影响」)。xterm 的 WS 连接 1011/超时已有 fatal UI,足以承担断开信号。设计 §6 表相应行实施时修正为「banner 承担断开信号」。
|
||||
|
||||
### M4:railRevision 状态
|
||||
### M4:内联虚拟 Tab(取代 PinnedRail)
|
||||
|
||||
设计 §3 提到「store 任意会话更新已全局 notify,rail 随 uSES 自动重渲染」。实施时发现 `reduceFor` **不 notify**(targeted opens 的契约:UI 不跟随)。rail 的 unpin/close 走 `reduceFor`(目标是其他会话),store 不 notify → rail 不重渲染。解决方案:Sidebar 维护 `railRevision` 状态,rail action 后 bump,强制 `collectPinnedTabs` useMemo 重算。这是自包含的组件级方案,不改变 `reduceFor` 的 notify 契约。
|
||||
设计 §3 原描述「PinnedRail 组件——面板顶部的紧凑条,渲染其他会话的 pinned tab」。实施时改为**内联虚拟 Tab**:pinned tab 作为虚拟 `SidebarTab` 注入到当前会话 split tree 的第一个 leaf 的 `tabs` 数组尾部,与普通 tab 并排在 TabBar 中渲染。
|
||||
|
||||
**变更原因**:用户反馈「固定的终端是永远显示在 Tabs 栏处,而不是单独有一个地方让我点击后回到那个会话」。PinnedRail 是面板顶部的独立条,点击跳回宿主会话——这两个设计点都被否定。
|
||||
|
||||
**新设计**:
|
||||
- **虚拟 Tab 注入**:`injectPinnedIntoTree(state.splits, pinnedVirtualTabs, activePinnedTabId)` 将虚拟 tab 追加到第一个 leaf,并在 `activePinnedTabId` 设置时覆盖 leaf 的 `active`
|
||||
- **就地激活**:点击虚拟 tab 设 `activePinnedTabId`(本地 state),TerminalView 用 home session 的 scope(sessionId + cwd)连接宿主 PTY 的 WS(`/sidebar/ws/terminal?sessionId=<home>&tab=<originalId>`),不跳转会话
|
||||
- **effectiveTabId**:`TabContent` 新增 `effectiveTabId` prop,将虚拟 tab 的原始 id 传给 descriptor component(TerminalView 的 `tabId` 参数),虚拟 id 仅作 React key
|
||||
- **不可拖拽**:TabBar 检测 `isPinnedVirtualTab(tab)` → `draggable={false}`,跳过 drop handler
|
||||
- **右键菜单精简**:pinned 虚拟 tab 右键只有 Unpin / Close(无 float / close-others / close-left / close-right)
|
||||
- **reduceFor 不 notify**:`pinnedRevision` state bump 替代 `railRevision`,force `pinnedEntries` useMemo 重算
|
||||
|
||||
## 7. 测试计划(Unit + 组件,vitest)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+120
-93
@@ -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.
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
* 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 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])
|
||||
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 => {
|
||||
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 => {
|
||||
// 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)
|
||||
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
|
||||
})
|
||||
// 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 */ })
|
||||
if (isAgentTabId(originalId)) {
|
||||
void api.agentPtyClose(agentUuidOf(originalId)).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 */ })
|
||||
void api.ptyClose({ sessionId: homeSessionId }, originalId).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])
|
||||
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
|
||||
setPinnedRevision(v => v + 1)
|
||||
}
|
||||
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,12 +1432,20 @@ 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') => (
|
||||
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={sessionId}
|
||||
cwd={cwd}
|
||||
sessionId={home?.sessionId ?? sessionId}
|
||||
cwd={home?.cwd ?? cwd}
|
||||
expanded={state.expanded}
|
||||
revealed={state.revealed ?? []}
|
||||
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
||||
@@ -1434,6 +1465,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* 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<string, unknown>).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<PinnedTabEntry> & { 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<typeof vi.fn>
|
||||
onUnpin: ReturnType<typeof vi.fn>
|
||||
onClose: ReturnType<typeof vi.fn>
|
||||
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<HTMLElement>('[data-dsh-pinned-rail]')
|
||||
const entryEls = [...container.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLButtonElement>('[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()
|
||||
}
|
||||
})
|
||||
})
|
||||
+125
-1
@@ -10,7 +10,10 @@ import {
|
||||
floatTab, makeDefaultState, openTabInActivePane, setTabPin, toggleBottomPanel,
|
||||
type SidebarState,
|
||||
} from '../src/client/state.ts'
|
||||
import { collectPinnedTabs, pinnedVisibleTo, type PinnedViewer } from '../src/client/pinned.ts'
|
||||
import { collectPinnedTabs, pinnedVisibleTo, type PinnedViewer,
|
||||
isPinnedVirtualId, parsePinnedVirtualId, isPinnedVirtualTab, getPinnedHomeScope,
|
||||
createPinnedVirtualTab, injectPinnedIntoTree,
|
||||
} from '../src/client/pinned.ts'
|
||||
|
||||
/** A pinned terminal tab in a fresh state, opened and pinned in one go. */
|
||||
function stateWithPinnedTerminal(
|
||||
@@ -140,3 +143,124 @@ describe('collectPinnedTabs', () => {
|
||||
expect(entries.map(e => e.tab.id)).toEqual(['terminal:pinned'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPinnedVirtualId / parsePinnedVirtualId', () => {
|
||||
it('detects a pinned virtual id by prefix', () => {
|
||||
expect(isPinnedVirtualId('pinned:home:terminal:3')).toBe(true)
|
||||
expect(isPinnedVirtualId('pinned:abc-123:agent:def-456')).toBe(true)
|
||||
expect(isPinnedVirtualId('terminal:3')).toBe(false)
|
||||
expect(isPinnedVirtualId('agent:uuid')).toBe(false)
|
||||
})
|
||||
|
||||
it('parses home session id and original tab id (terminal)', () => {
|
||||
const { homeSessionId, tabId } = parsePinnedVirtualId('pinned:home-sess:terminal:3')
|
||||
expect(homeSessionId).toBe('home-sess')
|
||||
expect(tabId).toBe('terminal:3')
|
||||
})
|
||||
|
||||
it('parses home session id and original tab id (agent)', () => {
|
||||
const { homeSessionId, tabId } = parsePinnedVirtualId('pinned:abc-123:agent:def-456')
|
||||
expect(homeSessionId).toBe('abc-123')
|
||||
expect(tabId).toBe('agent:def-456')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPinnedVirtualTab', () => {
|
||||
it('creates a virtual tab with a prefixed id and home scope in meta', () => {
|
||||
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'global' as const, homeCwd: '/p' } }
|
||||
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home' })
|
||||
expect(vtab.id).toBe('pinned:home:terminal:3')
|
||||
expect(vtab.type).toBe('terminal')
|
||||
expect(vtab.title).toBe('T3')
|
||||
expect(vtab.pin).toEqual({ scope: 'global', homeCwd: '/p' })
|
||||
})
|
||||
|
||||
it('stores the home scope (sessionId, cwd, original tabId) in meta', () => {
|
||||
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'workspace' as const, homeCwd: '/proj' } }
|
||||
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home-sess' })
|
||||
const home = getPinnedHomeScope(vtab)
|
||||
expect(home).toBeDefined()
|
||||
expect(home!.sessionId).toBe('home-sess')
|
||||
expect(home!.cwd).toBe('/proj')
|
||||
expect(home!.tabId).toBe('terminal:3')
|
||||
})
|
||||
|
||||
it('preserves existing meta fields alongside the pinned home scope', () => {
|
||||
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'global' as const }, meta: { custom: 42 } }
|
||||
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home' })
|
||||
expect((vtab.meta as Record<string, unknown>).custom).toBe(42)
|
||||
expect(isPinnedVirtualTab(vtab)).toBe(true)
|
||||
})
|
||||
|
||||
it('cwd is undefined for a global pin without homeCwd', () => {
|
||||
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3', pin: { scope: 'global' as const } }
|
||||
const vtab = createPinnedVirtualTab({ tab, homeSessionId: 'home' })
|
||||
expect(getPinnedHomeScope(vtab)!.cwd).toBeUndefined()
|
||||
})
|
||||
|
||||
it('isPinnedVirtualTab returns false for a regular tab', () => {
|
||||
const tab = { id: 'terminal:3', type: 'terminal' as const, title: 'T3' }
|
||||
expect(isPinnedVirtualTab(tab)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('injectPinnedIntoTree', () => {
|
||||
it('returns the original tree when no pinned tabs and no active override', () => {
|
||||
const tree = makeDefaultState().splits
|
||||
expect(injectPinnedIntoTree(tree, [], null)).toBe(tree)
|
||||
})
|
||||
|
||||
it('appends pinned virtual tabs to the first leaf', () => {
|
||||
const s = makeDefaultState()
|
||||
const vtab = createPinnedVirtualTab({
|
||||
tab: { id: 'terminal:3', type: 'terminal', title: 'T3', pin: { scope: 'global' } },
|
||||
homeSessionId: 'home',
|
||||
})
|
||||
const result = injectPinnedIntoTree(s.splits, [vtab], null)
|
||||
expect(result.kind).toBe('leaf')
|
||||
if (result.kind === 'leaf') {
|
||||
expect(result.tabs[result.tabs.length - 1]!.id).toBe('pinned:home:terminal:3')
|
||||
}
|
||||
})
|
||||
|
||||
it('overrides the first leaf active when activePinnedId is set', () => {
|
||||
const s = openTabInActivePane(makeDefaultState(), { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
const vtab = createPinnedVirtualTab({
|
||||
tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'global' } },
|
||||
homeSessionId: 'home',
|
||||
})
|
||||
const result = injectPinnedIntoTree(s.splits, [vtab], vtab.id)
|
||||
if (result.kind === 'leaf') {
|
||||
expect(result.active).toBe(vtab.id)
|
||||
expect(result.tabs.map(t => t.id)).toContain(vtab.id)
|
||||
expect(result.tabs.map(t => t.id)).toContain('terminal:1')
|
||||
}
|
||||
})
|
||||
|
||||
it('injects into the first child of a split tree', () => {
|
||||
let s = makeDefaultState()
|
||||
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'T1' })
|
||||
// Create a split (splits the active pane into two)
|
||||
const leafId = s.splits.kind === 'leaf' ? s.splits.id : ''
|
||||
s = { ...s, splits: { kind: 'split', id: 'split:1', dir: 'row', sizes: [0.5, 0.5], children: [
|
||||
{ kind: 'leaf', id: leafId, tabs: s.splits.kind === 'leaf' ? s.splits.tabs : [], active: 'terminal:1' },
|
||||
{ kind: 'leaf', id: 'pane:2', tabs: [], active: null },
|
||||
] } }
|
||||
const vtab = createPinnedVirtualTab({
|
||||
tab: { id: 'terminal:2', type: 'terminal', title: 'T2', pin: { scope: 'global' } },
|
||||
homeSessionId: 'home',
|
||||
})
|
||||
const result = injectPinnedIntoTree(s.splits, [vtab], null)
|
||||
expect(result.kind).toBe('split')
|
||||
if (result.kind === 'split') {
|
||||
const first = result.children[0]!
|
||||
if (first.kind === 'leaf') {
|
||||
expect(first.tabs.map(t => t.id)).toContain(vtab.id)
|
||||
}
|
||||
const second = result.children[1]!
|
||||
if (second.kind === 'leaf') {
|
||||
expect(second.tabs.map(t => t.id)).not.toContain(vtab.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeKey(overrides: Partial<TabContentMemoKey> = {}): TabContentMemoKey
|
||||
revealed: [],
|
||||
localeRevision: 'en-US',
|
||||
tabsVersion: 0,
|
||||
effectiveTabId: undefined,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user