diff --git a/AGENTS.md b/AGENTS.md index 8c086c9..ea43586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -356,7 +356,7 @@ ctx.effect(() => { | `git` | 20 | 是 | 否 | Git 面板 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `sidechat` | 35 | 否(createTab 铸造 `sidechat:`/按 `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:` 确定性 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:`)被 reconcile 移除时豁免保留为「已断开」(xterm 重连失败 banner 承担断开信号);pin 元数据 `tab.pin = { scope, homeCwd? }` 随宿主会话 state 持久化,渲染期跨会话解析(`collectPinnedTabs`),不写跨会话投影 | +| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增)。v0.17.0+ 支持右键「固定到工作区 / 固定到全局」:固定后切换会话不消失,在 TabBar 内联呈现为虚拟 Tab(跨会话注入第一个 leaf 的 tabs 尾部,virtual id = `pinned::`,meta 携带 home scope);点击就地激活(TerminalView 按 home sessionId+tab 连接宿主 PTY,不跳转会话);global 任意会话可见;workspace 仅同 cwd 会话可见;Agent 终端(`agent:`)被 reconcile 移除时豁免;pin 元数据 `tab.pin = { scope, homeCwd? }` 随宿主会话 state 持久化,渲染期跨会话解析(`collectPinnedTabs` → `createPinnedVirtualTab` → `injectPinnedIntoTree`),不写跨会话投影 | | `browser` | 50 | 否(createTab 铸造 browser:``,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) | diff --git a/README.md b/README.md index fa0b30e..669c674 100644 --- a/README.md +++ b/README.md @@ -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)) diff --git a/README_EN.md b/README_EN.md index 4ae9127..4ebc6a5 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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)) diff --git a/docs/plans/2026-08-26-pinned-terminal-design.md b/docs/plans/2026-08-26-pinned-terminal-design.md index 03ecf2b..c00bb97 100644 --- a/docs/plans/2026-08-26-pinned-terminal-design.md +++ b/docs/plans/2026-08-26-pinned-terminal-design.md @@ -120,9 +120,19 @@ getSessionStates(): ReadonlyMap 设计 §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=&tab=`),不跳转会话 +- **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) diff --git a/src/client/PinnedRail.tsx b/src/client/PinnedRail.tsx deleted file mode 100644 index c484612..0000000 --- a/src/client/PinnedRail.tsx +++ /dev/null @@ -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 ( -
- {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 28d7789..8cd7838 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 } - // 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(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(() => { + 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 `@` 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') => ( - { 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 ( + { 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 (
@@ -1534,16 +1566,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { /> )}
-
- {tabs.map(tab => ( + {tabs.map(tab => { + const pinned = isPinnedVirtualTab(tab) + return (
{ + 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 && } {getTabIcon?.(tab) ?? null} {getTabBadge?.(tab) ?? null} {tab.title} @@ -238,7 +244,8 @@ export function TabBar(props: {
- ))} + ) + })} {/* 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, diff --git a/src/client/pinned.ts b/src/client/pinned.ts index c489d57..6ebdcb6 100644 --- a/src/client/pinned.ts +++ b/src/client/pinned.ts @@ -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::` — 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 | 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 | 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 diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 74ffb19..9db4434 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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 ──────────────────────────────────────────────────────────── */ diff --git a/src/client/tab-content-memo.ts b/src/client/tab-content-memo.ts index c10affa..e8084eb 100644 --- a/src/client/tab-content-memo.ts +++ b/src/client/tab-content-memo.ts @@ -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 ) } diff --git a/tests/pinned-rail.spec.tsx b/tests/pinned-rail.spec.tsx deleted file mode 100644 index be12bfb..0000000 --- a/tests/pinned-rail.spec.tsx +++ /dev/null @@ -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).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/pinned.spec.ts b/tests/pinned.spec.ts index 5428280..3250a5c 100644 --- a/tests/pinned.spec.ts +++ b/tests/pinned.spec.ts @@ -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).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) + } + } + }) +}) diff --git a/tests/tab-content-memo.spec.ts b/tests/tab-content-memo.spec.ts index 0eb2370..e09b3ba 100644 --- a/tests/tab-content-memo.spec.ts +++ b/tests/tab-content-memo.spec.ts @@ -21,6 +21,7 @@ function makeKey(overrides: Partial = {}): TabContentMemoKey revealed: [], localeRevision: 'en-US', tabsVersion: 0, + effectiveTabId: undefined, ...overrides, } }