refactor(client): 抽取 pinned 虚拟 tab 注入与动作包装到 sidebar/use-pinned-tabs.ts

Sidebar.tsx 拆分第四块:activePinnedTabId/pinnedRevision 状态、
pinnedEntries/pinnedVirtualTabs/augmentedTree 三个 memo(含
exhaustive-deps 豁免注释随迁)与 wrappedActions(虚拟 id 路由回
HOME 会话的 reduceFor + ptyClose 语义)原样迁出,注释逐字保留。
调用点移到 actions memo 之后(hook 无 effect,仅 useState/useMemo,
位置调整不改变行为)。

行为不变:memo 依赖数组逐字相同;wrappedActions 的 actions 入参即
原先闭包捕获的同一个 memo 产物。
This commit is contained in:
Menghuan1918
2026-09-03 21:08:03 +08:00
parent d5b1201ad8
commit b4ab3f154f
2 changed files with 161 additions and 124 deletions
+8 -124
View File
@@ -35,13 +35,13 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts' import type { Context } from '../context-types.ts'
import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { appendToDraft, insertFileReference } from './conversation-draft.ts'
import { import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab,
isAgentTabId, leafWithTab, migrateBottomTabs, isAgentTabId, leafWithTab, migrateBottomTabs,
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat,
resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts' } from './state.ts'
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts' import { getPinnedHomeScope } from './pinned.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
@@ -53,6 +53,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts'
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { useCenterColumn } from './sidebar/use-center-column.ts' import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts' import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { FreeWindow } from './FreeWindow.tsx' import { FreeWindow } from './FreeWindow.tsx'
import { relativeTo } from './paths.ts' import { relativeTo } from './paths.ts'
@@ -349,53 +350,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// piece the render side consumes (renderTab's onSubagentJump arms it). // piece the render side consumes (renderTab's onSubagentJump arms it).
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId }) const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
/**
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
* the home session's PTY via WS, no session jump).
*
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
* (which doesn't notify — targeted opens must not re-render the active
* session). The `pinnedRevision` state bump forces the pinnedEntries
* useMemo to recompute after such an action.
*/
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
const [pinnedRevision, setPinnedRevision] = useState(0)
/**
* Cross-session pinned-tab collection. 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 })
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
// above): they recompute this memo for changes that carry no dep of their own.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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],
)
// Center-column tracking (sidebar/use-center-column.ts): the bottom panel // Center-column tracking (sidebar/use-center-column.ts): the bottom panel
// spans ONLY the app shell's center column ("squeezes the agent output // spans ONLY the app shell's center column ("squeezes the agent output
// area"); the hook locates the AppFrame's center column DOM (host anchor // area"); the hook locates the AppFrame's center column DOM (host anchor
@@ -855,81 +809,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, },
}), [store, sessionId, cwd, ctx, centerColRef]) }), [store, sessionId, cwd, ctx, centerColRef])
/** // Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from // tabs inject into the right panel's first leaf, and the actions are
* other sessions). Regular tab ids pass through unchanged. Virtual ids are // wrapped so pinned virtual ids route to the HOME session (reduceFor +
* detected by the `pinned:` prefix and routed to the HOME session via // revision bump).
* reduceFor (which doesn't notify — the revision bump is the local signal). const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
*/
const wrappedActions = useMemo<WorkbenchActions>(() => {
if (pinnedVirtualTabs.length === 0) return actions
const closePinnedInHome = (virtualId: string): void => {
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
// The home cwd lives in the virtual tab's meta (snapshotted at pin
// time) — pass it to ptyClose so the host resolves the PTY in the
// correct workspace container (same scope the WS open used).
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
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, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
}
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. Directories append the folder mention * The explorer's @-reference button. Directories append the folder mention
+153
View File
@@ -0,0 +1,153 @@
/**
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
* leaf of the right panel's split tree, and the workbench actions are
* wrapped so virtual ids route back to the HOME session. The shell only
* consumes the augmented tree and the wrapped actions.
*/
import { useMemo, useState } from 'react'
import {
agentUuidOf, closeFloatByTab, closeTab, isAgentTabId, leafWithTab, setTabPin,
type SidebarSnapshot, type SidebarStore, type SplitNode,
} from '../state.ts'
import {
collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree,
isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry,
} from '../pinned.ts'
import type { WorkbenchActions } from '../split-pane.tsx'
import { api } from '../api.ts'
export function usePinnedTabs(input: {
store: SidebarStore
sessionId: string | undefined
cwd: string | undefined
snapshot: SidebarSnapshot
actions: WorkbenchActions
}): { augmentedTree: SplitNode | undefined; wrappedActions: WorkbenchActions } {
const { store, sessionId, cwd, snapshot, actions } = input
const state = snapshot.state
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the right panel's split
* tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
* the home session's PTY via WS, no session jump).
*
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
* (which doesn't notify — targeted opens must not re-render the active
* session). The `pinnedRevision` state bump forces the pinnedEntries
* useMemo to recompute after such an action.
*/
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
const [pinnedRevision, setPinnedRevision] = useState(0)
/**
* Cross-session pinned-tab collection. 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 })
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
// above): they recompute this memo for changes that carry no dep of their own.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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],
)
/**
* 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 wrappedActions = useMemo<WorkbenchActions>(() => {
if (pinnedVirtualTabs.length === 0) return actions
const closePinnedInHome = (virtualId: string): void => {
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
// The home cwd lives in the virtual tab's meta (snapshotted at pin
// time) — pass it to ptyClose so the host resolves the PTY in the
// correct workspace container (same scope the WS open used).
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
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, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
}
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])
return { augmentedTree, wrappedActions }
}