From dbc73f0bf3910ff225c5b12c4cfb2089c273d2f5 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 21:00:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=8F=96=20Ta?= =?UTF-8?q?bContent=20=E5=8D=95=E5=85=83=E6=A0=BC=E4=B8=8E=20+=20=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=9E=84=E5=BB=BA=E5=88=B0=20sidebar/TabContent.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar.tsx 拆分第一块:memo 化的 TabContent(含 TabContentProps 与 tabContentCompare 比较器)和 buildNewTabOptions 原样迁出到 src/client/sidebar/TabContent.tsx,注释逐字保留。 行为不变:组件体零改动,仅模块路径与 import 调整(相对路径加一层 ../);Sidebar.tsx 改为 import 该模块,对外导出面不变(仍只有 Sidebar 具名导出)。 --- src/client/Sidebar.tsx | 68 ++-------------------------- src/client/sidebar/TabContent.tsx | 74 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 65 deletions(-) create mode 100644 src/client/sidebar/TabContent.tsx diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index e2fbe0a..acdbb9f 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -28,7 +28,7 @@ * drawer floats). Widening does not migrate back: the tabs keep living in * the right tree. */ -import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -51,17 +51,14 @@ import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' import { computeTitleBarStrip } from './titlebar-strip.ts' -import type { NewTabOption } from './TabBar.tsx' +import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { FreeWindow } from './FreeWindow.tsx' import { relativeTo } from './paths.ts' -import { OrphanedTab } from './OrphanedTab.tsx' -import { RenderBoundary } from './RenderBoundary.tsx' -import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts' import { detectNewDirectSubagent } from './subagent-detect.ts' import { detectNewJob } from './subagent-jobs.ts' import { t } from './locales.ts' -import { api, type SessionScope } from './api.ts' +import { api } from './api.ts' import css from './sidebar.module.css' /** How many consecutive reconnect failures stop the agent-terminals push loop @@ -119,65 +116,6 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem return tag } -/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus - * the runtime objects/callbacks the cell renders with. The memo comparator - * is the pure `tabContentCompare`; anything in the key decides a re-render - * must propagate, anything outside it must be a stable object (ctx/store) - * or covered by a compared field (paneId covers onOpenDiff's captured - * pane; sessionId/cwd cover onReferenceFile). */ -interface TabContentProps extends TabContentMemoKey { - onToggleDir: (path: string) => void - onReferenceFile: (path: string, isDir: boolean) => void - ctx: Context - store: SidebarStore - /** Fired before a topology node jumps to its child session (see Sidebar). */ - onSubagentJump: (childSessionId: string) => void - /** Open a diff tab from the git panel (placement handled by the store). */ - onOpenDiff: (tab: SidebarTab) => void -} - -/** Render the content of one tab (dispatched by type). */ -const TabContent = memo(function TabContent(props: TabContentProps) { - 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 - } - // 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: componentTab, visible, expanded, revealed, - onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, - }), - ) -}, tabContentCompare) - -/** The + menu options for the current state, driven by the tab registry. - * Hidden tabs (editor/diff) never show; `available` returning false shows - * a disabled row (e.g. terminal at capacity) instead of hiding the option. - * Tabs the user disabled in the side card settings are filtered out - * entirely — re-enabling them is the settings page's job. */ -function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] { - const service = ctx.get('betterSidebar') - if (service === undefined) return [] - return service.getTabs() - .filter(d => !d.hidden && service.isTabEnabled(d.id)) - .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) - .map(d => ({ - id: d.id, - label: typeof d.title === 'function' ? d.title() : d.title, - disabled: !(d.available?.(ctx, scope, state) ?? true), - icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, - })) -} - export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { ctx, store } = props diff --git a/src/client/sidebar/TabContent.tsx b/src/client/sidebar/TabContent.tsx new file mode 100644 index 0000000..4755ec4 --- /dev/null +++ b/src/client/sidebar/TabContent.tsx @@ -0,0 +1,74 @@ +/** + * The tab-content cell + the + menu builder, extracted from Sidebar.tsx + * (behavior identical): one memoized cell dispatches a tab to its registry + * descriptor's component, and buildNewTabOptions derives the + menu rows + * from the same registry. + */ +import { createElement, memo } from 'react' +import type { Context } from '../../context-types.ts' +import type { SidebarState, SidebarStore, SidebarTab } from '../state.ts' +import type { SessionScope } from '../api.ts' +import { OrphanedTab } from '../OrphanedTab.tsx' +import { RenderBoundary } from '../RenderBoundary.tsx' +import { tabContentCompare, type TabContentMemoKey } from '../tab-content-memo.ts' +import type { NewTabOption } from '../TabBar.tsx' +import css from '../sidebar.module.css' + +/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus + * the runtime objects/callbacks the cell renders with. The memo comparator + * is the pure `tabContentCompare`; anything in the key decides a re-render + * must propagate, anything outside it must be a stable object (ctx/store) + * or covered by a compared field (paneId covers onOpenDiff's captured + * pane; sessionId/cwd cover onReferenceFile). */ +interface TabContentProps extends TabContentMemoKey { + onToggleDir: (path: string) => void + onReferenceFile: (path: string, isDir: boolean) => void + ctx: Context + store: SidebarStore + /** Fired before a topology node jumps to its child session (see Sidebar). */ + onSubagentJump: (childSessionId: string) => void + /** Open a diff tab from the git panel (placement handled by the store). */ + onOpenDiff: (tab: SidebarTab) => void +} + +/** Render the content of one tab (dispatched by type). */ +export const TabContent = memo(function TabContent(props: TabContentProps) { + 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 + } + // 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: componentTab, visible, expanded, revealed, + onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump, + }), + ) +}, tabContentCompare) + +/** The + menu options for the current state, driven by the tab registry. + * Hidden tabs (editor/diff) never show; `available` returning false shows + * a disabled row (e.g. terminal at capacity) instead of hiding the option. + * Tabs the user disabled in the side card settings are filtered out + * entirely — re-enabling them is the settings page's job. */ +export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] { + const service = ctx.get('betterSidebar') + if (service === undefined) return [] + return service.getTabs() + .filter(d => !d.hidden && service.isTabEnabled(d.id)) + .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)) + .map(d => ({ + id: d.id, + label: typeof d.title === 'function' ? d.title() : d.title, + disabled: !(d.available?.(ctx, scope, state) ?? true), + icon: typeof d.icon === 'function' ? d.icon(16) : d.icon, + })) +} From 6bccd21a49743aaca1bfab1772ba81625a4052fb Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 21:03:14 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=88=97=E5=AE=9A=E4=BD=8D/=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=E9=93=BE=E5=88=B0=20sidebar/use-center-column.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar.tsx 拆分第二块:centerRectRef/centerMeasured/centerColRef/ draggingRef、measureCenter(ref + 直写 DOM,避免动画帧级 re-render) 与整条 locate/ResizeObserver/MutationObserver/1.5s 重试链原样迁出到 src/client/sidebar/use-center-column.ts,注释逐字保留。bottomRef 与 state?.bottomOpen 由调用方传入(bottomRef 在主文件先建再共享,行为与 原先 measureCenter 闭包捕获一致)。 行为不变:hook 每次 render 全量重跑,effect 依赖与值完全相同;唯一 文字差异是三处依赖数组补列了跨 hook 边界后失去 useRef 溯源的稳定 ref 对象(centerColRef/draggingRef,标识恒定,不改变触发时机), 为 react-hooks/exhaustive-deps 零告警所需。 --- src/client/Sidebar.tsx | 176 +++--------------------- src/client/sidebar/use-center-column.ts | 171 +++++++++++++++++++++++ 2 files changed, 187 insertions(+), 160 deletions(-) create mode 100644 src/client/sidebar/use-center-column.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index acdbb9f..e558407 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -46,12 +46,12 @@ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { layoutPushSize } from './layout-push.ts' -import { resolveCenterColumn } from './center-column.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' import { computeTitleBarStrip } from './titlebar-strip.ts' import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' +import { useCenterColumn } from './sidebar/use-center-column.ts' import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { FreeWindow } from './FreeWindow.tsx' import { relativeTo } from './paths.ts' @@ -635,161 +635,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { [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 - // right edge and ends at the details column's left edge (the details - // column sits between the center and the right panel). Measured directly - // from the AppFrame's center column DOM (the parent of the - // [data-slot="conversation"] wrapper — layout.css's center column) so the - // bottom panel tracks the column's real - // horizontal edges — including the animated AppFrame padding reservation - // while the right panel opens/closes; a frame that never appears keeps the - // initial zero-size fallback (the panel renders at 0 width until measured). - // The rect lives in a REF (not state): the open/close transition resizes - // the center column EVERY frame for its duration, and reacting per frame - // with setState re-renders the whole Sidebar (every mounted tab) at - // animation cadence — the visible toggle jank (#315). measureCenter - // writes the bottom panel's edges directly (same DOM-write pattern as - // applyDrag), so the panel still tracks the column per frame with zero - // React work; `centerMeasured` flips ONCE to gate the hidden→visible - // first-paint fallback. - const centerRectRef = useRef({ left: 0, right: 0 }) - const [centerMeasured, setCenterMeasured] = useState(false) - // Refs keep the measure step stable across renders and let it skip work - // mid-drag: during a width/corner drag the layout push resizes the center - // column every frame, and reacting (setCenterRect → re-render) would - // re-introduce the drag lag this shell deliberately avoids. applyDrag - // writes the bottom panel's edges directly, so measurement pauses then. - const centerColRef = useRef(null) - const draggingRef = useRef(false) - const measureCenter = useCallback((): void => { - if (draggingRef.current) return - const col = centerColRef.current - if (col === null) return - if (!col.isConnected) { - // The observed column was detached (HMR re-render swapped the node - // in place): its rect is stale garbage. Drop the ref — the locate - // chain re-runs on the next mutation/interval tick and picks up the - // new column node (issue #248). - centerColRef.current = null - return - } - const rect = col.getBoundingClientRect() - // Ref + direct DOM write (see the centerRectRef comment): the bottom - // panel keeps tracking the center column per frame during the right - // panel's open/close animation without re-rendering the shell. The - // one-shot measured flip renders the panel visible once (a stale - // {0,0} fallback would flash full-width). - centerRectRef.current = { left: rect.left, right: rect.right } - const bottom = bottomRef.current - if (bottom !== null) { - bottom.style.setProperty('left', `${rect.left}px`) - bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`) - } - setCenterMeasured(prev => (prev ? prev : true)) - }, []) - useEffect(() => { - let disposed = false - let observer: ResizeObserver | undefined - // Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in - // [data-slot] containers: the conversation slot wrapper - // ([data-slot="conversation"]) sits directly inside the center column, - // so its parent IS that column — no hashed-class or positional - // dependency (layout.css uses the same anchor). The shell swaps the - // boot page for the AppFrame only AFTER boot settles, so the first - // query may miss it. Never give up: watch #root's subtree (the swap and - // HMR re-renders mutate it) and re-run this locator — querying once and - // bailing would strand the panel at the zero-size fallback forever - // (observed: a 1px sliver at the viewport's left edge). - const locate = (): void => { - if (disposed) return - // Hot path (#403): streaming output mutates #root at token cadence. - // Reuse a still-connected center column and only query after boot/HMR - // detached the cached node. - const col = resolveCenterColumn(centerColRef.current) - if (col === undefined || !col.isConnected) { - if (centerColRef.current !== null) { - centerColRef.current.removeAttribute('data-dsh-center-col') - centerColRef.current = null - observer?.disconnect() - observer = undefined - } - return - } - if (centerColRef.current !== col) { - // A NEW column node (boot swap, HMR re-render, or a previous locate - // that found nothing): attach the ResizeObserver to THIS node and - // measure it once. Same-node size changes are the ResizeObserver's - // job — no forced measurement here, because a forced - // getBoundingClientRect per mutation would reflow the shell at - // mutation cadence. The tag retargets with the ref: layout.css's - // bottom-push rule anchors on [data-dsh-center-col], so exactly the - // measured node carries it (a stale tag on a swapped-out node would - // leave the push rule anchorless or doubled). - centerColRef.current?.removeAttribute('data-dsh-center-col') - centerColRef.current = col - col.setAttribute('data-dsh-center-col', '') - observer?.disconnect() - observer = new ResizeObserver(measureCenter) - observer.observe(col) - measureCenter() - } - } - locate() - // rAF-debounce the mutation watchers: #root's subtree changes at chat - // cadence (streaming turns), and locate() itself must stay cheap. - let locateFrame: number | null = null - const scheduleLocate = (): void => { - if (locateFrame !== null) return - // Mid-drag every frame writes --dsh-sidebar-* on 's style - // attribute, which is the mutation this watcher observes — relocating - // per drag frame is pointless (the center column node cannot change - // while the pointer is captured) and adds locator work to every - // frame's budget (#315). The 1.5s retry below still covers any node - // swap that somehow lands mid-drag. - if (draggingRef.current) return - locateFrame = requestAnimationFrame(() => { - locateFrame = null - locate() - }) - } - const watcher = new MutationObserver(scheduleLocate) - const root = document.getElementById('root') - if (root !== null) watcher.observe(root, { childList: true, subtree: true }) - // The layout push writes --dsh-sidebar-* on . A HMR re-activation - // clears those variables on teardown and re-writes them on setup — and - // that is also the moment the shell may have re-created the center - // column under a REUSED #root child (React swaps nodes in place, so - // #root's childList never changes and the watcher above never fires). - // Watching 's style attribute catches that re-sync: the push - // rewrite re-locates and re-measures, so the bottom panel recovers - // instead of staying hidden on a stale {0,0} center rect. - const htmlStyleWatcher = new MutationObserver(scheduleLocate) - htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) - // Last-resort safety net (issue #248): no watcher is guaranteed to fire - // for every HMR teardown/setup interleaving (e.g. the style attribute - // may end up byte-identical, and the col may be swapped before the - // subtree watcher attaches). A slow unconditional re-locate makes the - // panel converge on the real column within a couple of seconds no - // matter what sequence the shell used. locate() is query-free while the - // cached column stays connected; only a detached/missing cache falls - // back to the document selector. - const retry = window.setInterval(locate, 1500) - return () => { - disposed = true - if (locateFrame !== null) cancelAnimationFrame(locateFrame) - window.clearInterval(retry) - observer?.disconnect() - watcher.disconnect() - htmlStyleWatcher.disconnect() - centerColRef.current?.removeAttribute('data-dsh-center-col') - centerColRef.current = null - } - // Opening the bottom panel re-runs the whole locate/measure chain: a - // panel opened before the center column was ever found must not stay - // invisible forever (the HMR recovery path depends on the observers - // above, this is the belt-and-braces retry for the open moment itself). - }, [measureCenter, state?.bottomOpen]) + // Center-column tracking (sidebar/use-center-column.ts): the bottom panel + // spans ONLY the app shell's center column ("squeezes the agent output + // area"); the hook locates the AppFrame's center column DOM (host anchor + // observers + slow retry), measures its edges into a ref, and writes them + // straight to the bottom panel element — per-frame tracking without + // re-rendering the shell (the comments live with the hook now). + const bottomRef = useRef(null) + const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen) /** * Free windows — drag-out detection. The tab strips already drive HTML5 @@ -868,7 +721,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { window.removeEventListener('dragend', clear, true) window.removeEventListener('blur', clear) } - }, [narrow, sessionId, store]) + // centerColRef is a stable ref object from useCenterColumn (it lost its + // useRef provenance crossing the hook boundary, so it is listed here). + }, [narrow, sessionId, store, centerColRef]) /** * Bottom-panel first-expansion auto terminal: the FIRST time the user @@ -908,7 +763,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // move, which is the visible drag lag. The store is committed once on // pointer up (clamping + persistence). const panelRef = useRef(null) - const bottomRef = useRef(null) const widthDrag = useRef({ startX: 0, startWidth: 0 }) const [draggingWidth, setDraggingWidth] = useState(false) const bottomDrag = useRef({ startY: 0, startHeight: 0 }) @@ -924,7 +778,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { useEffect(() => { draggingRef.current = anyDragging if (!anyDragging) measureCenter() - }, [anyDragging, measureCenter]) + // draggingRef is a stable ref object from useCenterColumn (same + // provenance note as the float effect above). + }, [anyDragging, measureCenter, draggingRef]) // Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store // re-clamps on commit; these keep the panels from overshooting mid-drag). @@ -1236,7 +1092,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { pinTab: (tabId, scope) => { store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd })) }, - }), [store, sessionId, cwd, ctx]) + }), [store, sessionId, cwd, ctx, centerColRef]) /** * Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from diff --git a/src/client/sidebar/use-center-column.ts b/src/client/sidebar/use-center-column.ts new file mode 100644 index 0000000..2b5d076 --- /dev/null +++ b/src/client/sidebar/use-center-column.ts @@ -0,0 +1,171 @@ +/** + * Center-column tracking (extracted from Sidebar.tsx, behavior identical): + * the bottom panel spans ONLY the app shell's center column ("squeezes the + * agent output area") — it starts at the app sidebar's right edge and ends + * at the details column's left edge (the details column sits between the + * center and the right panel). Measured directly from the AppFrame's center + * column DOM (the parent of the [data-slot="conversation"] wrapper — + * layout.css's center column) so the bottom panel tracks the column's real + * horizontal edges — including the animated AppFrame padding reservation + * while the right panel opens/closes; a frame that never appears keeps the + * initial zero-size fallback (the panel renders at 0 width until measured). + * The rect lives in a REF (not state): the open/close transition resizes + * the center column EVERY frame for its duration, and reacting per frame + * with setState re-renders the whole Sidebar (every mounted tab) at + * animation cadence — the visible toggle jank (#315). measureCenter + * writes the bottom panel's edges directly (same DOM-write pattern as + * applyDrag), so the panel still tracks the column per frame with zero + * React work; `centerMeasured` flips ONCE to gate the hidden→visible + * first-paint fallback. + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { resolveCenterColumn } from '../center-column.ts' + +export function useCenterColumn( + /** The bottom panel element: measureCenter writes its edges directly. */ + bottomRef: { readonly current: HTMLDivElement | null }, + /** Re-runs the whole locate/measure chain on change (opening the bottom + * panel re-runs the chain: a panel opened before the center column was + * ever found must not stay invisible forever). */ + bottomOpen: boolean | undefined, +) { + const centerRectRef = useRef({ left: 0, right: 0 }) + const [centerMeasured, setCenterMeasured] = useState(false) + // Refs keep the measure step stable across renders and let it skip work + // mid-drag: during a width/corner drag the layout push resizes the center + // column every frame, and reacting (setCenterRect → re-render) would + // re-introduce the drag lag this shell deliberately avoids. applyDrag + // writes the bottom panel's edges directly, so measurement pauses then. + const centerColRef = useRef(null) + const draggingRef = useRef(false) + const measureCenter = useCallback((): void => { + if (draggingRef.current) return + const col = centerColRef.current + if (col === null) return + if (!col.isConnected) { + // The observed column was detached (HMR re-render swapped the node + // in place): its rect is stale garbage. Drop the ref — the locate + // chain re-runs on the next mutation/interval tick and picks up the + // new column node (issue #248). + centerColRef.current = null + return + } + const rect = col.getBoundingClientRect() + // Ref + direct DOM write (see the centerRectRef comment): the bottom + // panel keeps tracking the center column per frame during the right + // panel's open/close animation without re-rendering the shell. The + // one-shot measured flip renders the panel visible once (a stale + // {0,0} fallback would flash full-width). + centerRectRef.current = { left: rect.left, right: rect.right } + const bottom = bottomRef.current + if (bottom !== null) { + bottom.style.setProperty('left', `${rect.left}px`) + bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`) + } + setCenterMeasured(prev => (prev ? prev : true)) + }, [bottomRef]) + useEffect(() => { + let disposed = false + let observer: ResizeObserver | undefined + // Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in + // [data-slot] containers: the conversation slot wrapper + // ([data-slot="conversation"]) sits directly inside the center column, + // so its parent IS that column — no hashed-class or positional + // dependency (layout.css uses the same anchor). The shell swaps the + // boot page for the AppFrame only AFTER boot settles, so the first + // query may miss it. Never give up: watch #root's subtree (the swap and + // HMR re-renders mutate it) and re-run this locator — querying once and + // bailing would strand the panel at the zero-size fallback forever + // (observed: a 1px sliver at the viewport's left edge). + const locate = (): void => { + if (disposed) return + // Hot path (#403): streaming output mutates #root at token cadence. + // Reuse a still-connected center column and only query after boot/HMR + // detached the cached node. + const col = resolveCenterColumn(centerColRef.current) + if (col === undefined || !col.isConnected) { + if (centerColRef.current !== null) { + centerColRef.current.removeAttribute('data-dsh-center-col') + centerColRef.current = null + observer?.disconnect() + observer = undefined + } + return + } + if (centerColRef.current !== col) { + // A NEW column node (boot swap, HMR re-render, or a previous locate + // that found nothing): attach the ResizeObserver to THIS node and + // measure it once. Same-node size changes are the ResizeObserver's + // job — no forced measurement here, because a forced + // getBoundingClientRect per mutation would reflow the shell at + // mutation cadence. The tag retargets with the ref: layout.css's + // bottom-push rule anchors on [data-dsh-center-col], so exactly the + // measured node carries it (a stale tag on a swapped-out node would + // leave the push rule anchorless or doubled). + centerColRef.current?.removeAttribute('data-dsh-center-col') + centerColRef.current = col + col.setAttribute('data-dsh-center-col', '') + observer?.disconnect() + observer = new ResizeObserver(measureCenter) + observer.observe(col) + measureCenter() + } + } + locate() + // rAF-debounce the mutation watchers: #root's subtree changes at chat + // cadence (streaming turns), and locate() itself must stay cheap. + let locateFrame: number | null = null + const scheduleLocate = (): void => { + if (locateFrame !== null) return + // Mid-drag every frame writes --dsh-sidebar-* on 's style + // attribute, which is the mutation this watcher observes — relocating + // per drag frame is pointless (the center column node cannot change + // while the pointer is captured) and adds locator work to every + // frame's budget (#315). The 1.5s retry below still covers any node + // swap that somehow lands mid-drag. + if (draggingRef.current) return + locateFrame = requestAnimationFrame(() => { + locateFrame = null + locate() + }) + } + const watcher = new MutationObserver(scheduleLocate) + const root = document.getElementById('root') + if (root !== null) watcher.observe(root, { childList: true, subtree: true }) + // The layout push writes --dsh-sidebar-* on . A HMR re-activation + // clears those variables on teardown and re-writes them on setup — and + // that is also the moment the shell may have re-created the center + // column under a REUSED #root child (React swaps nodes in place, so + // #root's childList never changes and the watcher above never fires). + // Watching 's style attribute catches that re-sync: the push + // rewrite re-locates and re-measures, so the bottom panel recovers + // instead of staying hidden on a stale {0,0} center rect. + const htmlStyleWatcher = new MutationObserver(scheduleLocate) + htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) + // Last-resort safety net (issue #248): no watcher is guaranteed to fire + // for every HMR teardown/setup interleaving (e.g. the style attribute + // may end up byte-identical, and the col may be swapped before the + // subtree watcher attaches). A slow unconditional re-locate makes the + // panel converge on the real column within a couple of seconds no + // matter what sequence the shell used. locate() is query-free while the + // cached column stays connected; only a detached/missing cache falls + // back to the document selector. + const retry = window.setInterval(locate, 1500) + return () => { + disposed = true + if (locateFrame !== null) cancelAnimationFrame(locateFrame) + window.clearInterval(retry) + observer?.disconnect() + watcher.disconnect() + htmlStyleWatcher.disconnect() + centerColRef.current?.removeAttribute('data-dsh-center-col') + centerColRef.current = null + } + // Opening the bottom panel re-runs the whole locate/measure chain: a + // panel opened before the center column was ever found must not stay + // invisible forever (the HMR recovery path depends on the observers + // above, this is the belt-and-braces retry for the open moment itself). + }, [measureCenter, bottomOpen]) + + return { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } +} From d5b1201ad8b3b2d36fadc40c8d2dc77161e2999a Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 21:05:41 +0800 Subject: [PATCH 3/5] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=8E=A8=E9=80=81=E4=B8=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E8=A7=A6=E5=8F=91=E5=99=A8=E5=88=B0=20sideba?= =?UTF-8?q?r/use-host-feeds.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar.tsx 拆分第三块:agent-terminals / agent-opens 两条 WS 推送 (含重连退避与失败上限)、子代理自动激活(去抖重估)、后台任务自动 激活、拓扑跳回(subagentJumpRef)连同 FAILURE_LIMIT / AUTO_OPEN_DEBOUNCE_MS 常量原样迁出,注释逐字保留。Sidebar 侧仅 消费返回的 subagentJumpRef(renderTab 的 onSubagentJump 装填)。 行为不变:五个 effect 的依赖数组与闭包值逐字相同,hook 每 render 无条件调用,effect 挂载顺序保持(feeds → center → float → bottomAuto → drag)。 --- src/client/Sidebar.tsx | 255 +------------------------ src/client/sidebar/use-host-feeds.ts | 269 +++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 247 deletions(-) create mode 100644 src/client/sidebar/use-host-feeds.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index e558407..8e07f9e 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -32,12 +32,12 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as Re import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context, SidebarSessionList } from '../context-types.ts' +import type { Context } from '../context-types.ts' import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs, - moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals, + moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' @@ -52,29 +52,14 @@ import { getShellPreset } from './shell-presets.ts' import { computeTitleBarStrip } from './titlebar-strip.ts' import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { useCenterColumn } from './sidebar/use-center-column.ts' +import { useHostFeeds } from './sidebar/use-host-feeds.ts' import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx' import { FreeWindow } from './FreeWindow.tsx' import { relativeTo } from './paths.ts' -import { detectNewDirectSubagent } from './subagent-detect.ts' -import { detectNewJob } from './subagent-jobs.ts' import { t } from './locales.ts' import { api } from './api.ts' import css from './sidebar.module.css' -/** How many consecutive reconnect failures stop the agent-terminals push loop - * (mirror of the terminal view's own cap; the loop restarts on session switch). */ -const FAILURE_LIMIT = 3 - -/** - * Subagent auto-open debounce (ms). The host delivers a new child's origin - * and its title in SEPARATE frames: a Side Chat thread's first visible - * frame still shows a fallback title (no 'Side: ' prefix), so an immediate - * 0→N decision mistakes it for a genuine subagent and pops the task page. - * The trigger therefore re-evaluates against the live snapshot once the - * title frame has had time to land. - */ -const AUTO_OPEN_DEBOUNCE_MS = 500 - /** * OS file drags over the sidebar belong to the sidebar, not to the chat: * DSH's composer (InputBar) listens for file drags on the DOCUMENT and @@ -358,235 +343,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { [state, ctx, sessionId, cwd], ) - /** - * Agent terminals push: subscribe to the host's live list of agent-owned - * terminals for this session (created by the model through the - * `terminal_create` tool). The host pushes a JSON array on every - * create / close / exit; the sidebar reconciles the list into tabs - * (id `agent:`, title from the agent). A disconnected socket - * retries with a short backoff so a refresh or transient drop reattaches - * the same shell without losing the agent's work — capped like the - * terminal view's own reconnect loop, so a refused endpoint never spins - * forever (the next session switch restarts the loop). - * While the terminal tab type is disabled in settings, pushes are - * ignored (no auto-added tabs); re-enabling makes the next push converge. - */ - useEffect(() => { - if (sessionId === undefined) return - let socket: WebSocket | null = null - let retry: number | undefined - let closed = false - let failures = 0 - const connect = (): void => { - if (closed) return - const url = new URL('/sidebar/ws/agent-terminals', location.origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.search = new URLSearchParams({ sessionId }).toString() - socket = new WebSocket(url.toString()) - socket.onmessage = (event) => { - if (typeof event.data !== 'string') return - try { - const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }> - if (!Array.isArray(list)) return - store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false - ? s - : reconcileAgentTerminals(s, list)) - } catch { - // Malformed push: ignore (the next push will reconcile). - } - } - socket.onclose = () => { - if (closed) return - failures += 1 - if (failures >= FAILURE_LIMIT) { - console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId) - return - } - retry = window.setTimeout(connect, 2000) - } - socket.onerror = () => { socket?.close() } - } - connect() - return () => { - closed = true - window.clearTimeout(retry) - socket?.close() - } - }, [sessionId, ctx, store]) - - /** - * Agent opens push: subscribe to the host's `sidebar_open` requests for - * this session (the model actively opens a file / folder / HTTP(S) page). - * The host pushes one JSON request per open; the sidebar routes it to the - * matching built-in tab: a file opens in the editor (per-path dedupe), a - * folder opens a file window whose tree is rooted at the folder - * (`meta.dir`), and a URL opens in the browser tab. A disconnected socket - * retries with a short backoff (mirror of the agent-terminals loop): the - * host queue keeps undelivered requests and replays them on the first - * attach, so a refresh or a session switch lands the opens the model - * queued while no view was connected. - * While the side-card setting is off, pushes are ignored as a defensive - * gate — the host already unregisters the tool and drains the queue. - */ - useEffect(() => { - if (sessionId === undefined) return - let socket: WebSocket | null = null - let retry: number | undefined - let closed = false - let failures = 0 - const connect = (): void => { - if (closed) return - const url = new URL('/sidebar/ws/agent-opens', location.origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.search = new URLSearchParams({ sessionId }).toString() - socket = new WebSocket(url.toString()) - socket.onmessage = (event) => { - if (typeof event.data !== 'string') return - try { - const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown } - if (request === null || typeof request !== 'object') return - if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return - if (typeof request.target !== 'string' || request.target === '') return - if (store.getPrefs().agentOpenTools !== true) return - const scope = { sessionId } - const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined - if (request.kind === 'url') { - ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope) - } else if (request.kind === 'folder') { - ctx.get('betterSidebar')?.openTab({ - type: 'editor', - title, - path: request.target, - id: `editor:${request.target}`, - meta: { dir: true }, - }, scope) - } else { - ctx.get('betterSidebar')?.openFile(scope, request.target, title) - } - } catch { - // Malformed push: ignore (the next push carries its own request). - } - } - socket.onclose = () => { - if (closed) return - failures += 1 - if (failures >= FAILURE_LIMIT) { - console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId) - return - } - retry = window.setTimeout(connect, 2000) - } - socket.onerror = () => { socket?.close() } - } - connect() - return () => { - closed = true - window.clearTimeout(retry) - socket?.close() - } - }, [sessionId, ctx, store]) - - /** - * Subagent auto-activation: the moment the current conversation spawns its - * FIRST direct subagent (a 0 → N transition on the list feed), the "auto - * open" pref is on, and the Tasks tab type is enabled in settings, activate - * the Tasks page. Single-instance semantics focus an existing pane tab in - * place or raise an existing free window; a new tab lands in the right pane - * and is never duplicated. On wide viewports the right panel also expands; - * on narrow viewports background activity never forces the full-screen - * drawer open over the chat. - * Switching to a session that already has subagents never triggers — its - * baseline starts at the current count — so a deliberate layout is never - * fought. - * - * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread - * is also a subagent-origin child, and its 'Side: ' title lands one frame - * after its origin — an immediate check would misread that first frame as - * a new subagent and pop this page on every thread creation. The timer - * re-evaluates the ORIGINAL baseline against the live snapshot; by then - * the title filter (isSideThreadSummary) sees the settled label. - */ - const listBaselineRef = useRef(undefined) - const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null) - useEffect(() => { - const prev = listBaselineRef.current - listBaselineRef.current = sessionList - if (sessionId === undefined || prev === undefined) return - if (autoOpenPendingRef.current !== null) return - if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return - const baseline = prev - const timer = window.setTimeout(() => { - autoOpenPendingRef.current = null - if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return - if (!store.getPrefs().autoOpenSubagent) return - if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return - // Read the viewport when the delayed activation fires: a resize while - // the debounce is armed must not let background activity force the - // narrow full-screen drawer open over the chat. - if (!isNarrowWidth(window.innerWidth)) { - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - } - // Choose the right panel as the landing pane for a newly created Tasks - // tab. Single-instance dedupe still activates an existing pane tab in - // place or raises an existing free window. - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) - }, AUTO_OPEN_DEBOUNCE_MS) - autoOpenPendingRef.current = { baseline, timer } - }, [sessionList, sessionId, store, ctx]) - - // A session switch (or unmount) voids any armed auto-open recheck. - useEffect(() => () => { - const pending = autoOpenPendingRef.current - if (pending !== null) window.clearTimeout(pending.timer) - autoOpenPendingRef.current = null - }, [sessionId]) - - /** - * Job auto-activation: the moment a NEW background job appears for the - * current conversation (a job id the previous snapshot lacked), the - * auto-open pref is on, and the Tasks tab type is enabled, activate the Tasks - * page that contains the background-jobs section. The right panel expands - * only on wide viewports. Unlike the subagent trigger (0 → N only), ANY - * new job id triggers: the agent may start several jobs in one session, and - * each should surface. A fresh page load never triggers — its baseline starts - * at the current snapshot. - */ - const jobBaselineRef = useRef(undefined) - useEffect(() => { - const prev = jobBaselineRef.current - jobBaselineRef.current = sessionList - if (sessionId === undefined || prev === undefined) return - if (!detectNewJob(prev, sessionList, sessionId)) return - if (!store.getPrefs().autoOpenJobs) return - if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return - if (!isNarrowWidth(window.innerWidth)) { - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - } - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) - }, [sessionList, sessionId, store, ctx]) - - /** - * Topology jump-back: clicking a subagent node on the Subagent page calls - * the official `openSubagent`, which switches the sidebar to that child - * session's OWN layout (a fresh child session defaults to the explorer). - * The README contract says the Subagent page must stay open with the jumped - * node highlighted — so once the current session becomes the recorded jump - * target, re-open the Subagent page on top of the child's layout (expanding - * the panel first if it is collapsed). Only this explicit node click arms - * the flag, so switching to a subagent session by any other means keeps - * that session's own layout untouched. - */ - const subagentJumpRef = useRef(undefined) - useEffect(() => { - const pending = subagentJumpRef.current - if (pending === undefined || sessionId !== pending) return - subagentJumpRef.current = undefined - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) - }, [sessionId, store, ctx]) + // Host feeds (sidebar/use-host-feeds.ts): the agent-terminals / agent-opens + // WebSocket pushes and the subagent / background-job auto-activation + // triggers, all keyed on the current session. The jump-back ref is the one + // piece the render side consumes (renderTab's onSubagentJump arms it). + const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId }) /** /** diff --git a/src/client/sidebar/use-host-feeds.ts b/src/client/sidebar/use-host-feeds.ts new file mode 100644 index 0000000..0028690 --- /dev/null +++ b/src/client/sidebar/use-host-feeds.ts @@ -0,0 +1,269 @@ +/** + * Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical): + * the WebSocket pushes (agent terminals, agent opens) and the session-list + * driven auto-activation triggers (subagent, background jobs, topology + * jump-back). All of it reacts to the host's live feeds for the CURRENT + * session; the sidebar shell only consumes the returned jump-back ref. + */ +import { useEffect, useRef } from 'react' +import type { Context, SidebarSessionList } from '../../context-types.ts' +import { firstLeaf, reconcileAgentTerminals, togglePanel, type SidebarStore } from '../state.ts' +import { isNarrowWidth } from '../breakpoints.ts' +import { detectNewDirectSubagent } from '../subagent-detect.ts' +import { detectNewJob } from '../subagent-jobs.ts' +import { t } from '../locales.ts' + +/** How many consecutive reconnect failures stop the agent-terminals push loop + * (mirror of the terminal view's own cap; the loop restarts on session switch). */ +const FAILURE_LIMIT = 3 + +/** + * Subagent auto-open debounce (ms). The host delivers a new child's origin + * and its title in SEPARATE frames: a Side Chat thread's first visible + * frame still shows a fallback title (no 'Side: ' prefix), so an immediate + * 0→N decision mistakes it for a genuine subagent and pops the task page. + * The trigger therefore re-evaluates against the live snapshot once the + * title frame has had time to land. + */ +const AUTO_OPEN_DEBOUNCE_MS = 500 + +export function useHostFeeds(feeds: { + ctx: Context + store: SidebarStore + sessionList: SidebarSessionList + sessionId: string | undefined +}): { subagentJumpRef: { current: string | undefined } } { + const { ctx, store, sessionList, sessionId } = feeds + + /** + * Agent terminals push: subscribe to the host's live list of agent-owned + * terminals for this session (created by the model through the + * `terminal_create` tool). The host pushes a JSON array on every + * create / close / exit; the sidebar reconciles the list into tabs + * (id `agent:`, title from the agent). A disconnected socket + * retries with a short backoff so a refresh or transient drop reattaches + * the same shell without losing the agent's work — capped like the + * terminal view's own reconnect loop, so a refused endpoint never spins + * forever (the next session switch restarts the loop). + * While the terminal tab type is disabled in settings, pushes are + * ignored (no auto-added tabs); re-enabling makes the next push converge. + */ + useEffect(() => { + if (sessionId === undefined) return + let socket: WebSocket | null = null + let retry: number | undefined + let closed = false + let failures = 0 + const connect = (): void => { + if (closed) return + const url = new URL('/sidebar/ws/agent-terminals', location.origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.search = new URLSearchParams({ sessionId }).toString() + socket = new WebSocket(url.toString()) + socket.onmessage = (event) => { + if (typeof event.data !== 'string') return + try { + const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }> + if (!Array.isArray(list)) return + store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false + ? s + : reconcileAgentTerminals(s, list)) + } catch { + // Malformed push: ignore (the next push will reconcile). + } + } + socket.onclose = () => { + if (closed) return + failures += 1 + if (failures >= FAILURE_LIMIT) { + console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId) + return + } + retry = window.setTimeout(connect, 2000) + } + socket.onerror = () => { socket?.close() } + } + connect() + return () => { + closed = true + window.clearTimeout(retry) + socket?.close() + } + }, [sessionId, ctx, store]) + + /** + * Agent opens push: subscribe to the host's `sidebar_open` requests for + * this session (the model actively opens a file / folder / HTTP(S) page). + * The host pushes one JSON request per open; the sidebar routes it to the + * matching built-in tab: a file opens in the editor (per-path dedupe), a + * folder opens a file window whose tree is rooted at the folder + * (`meta.dir`), and a URL opens in the browser tab. A disconnected socket + * retries with a short backoff (mirror of the agent-terminals loop): the + * host queue keeps undelivered requests and replays them on the first + * attach, so a refresh or a session switch lands the opens the model + * queued while no view was connected. + * While the side-card setting is off, pushes are ignored as a defensive + * gate — the host already unregisters the tool and drains the queue. + */ + useEffect(() => { + if (sessionId === undefined) return + let socket: WebSocket | null = null + let retry: number | undefined + let closed = false + let failures = 0 + const connect = (): void => { + if (closed) return + const url = new URL('/sidebar/ws/agent-opens', location.origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.search = new URLSearchParams({ sessionId }).toString() + socket = new WebSocket(url.toString()) + socket.onmessage = (event) => { + if (typeof event.data !== 'string') return + try { + const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown } + if (request === null || typeof request !== 'object') return + if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return + if (typeof request.target !== 'string' || request.target === '') return + if (store.getPrefs().agentOpenTools !== true) return + const scope = { sessionId } + const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined + if (request.kind === 'url') { + ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope) + } else if (request.kind === 'folder') { + ctx.get('betterSidebar')?.openTab({ + type: 'editor', + title, + path: request.target, + id: `editor:${request.target}`, + meta: { dir: true }, + }, scope) + } else { + ctx.get('betterSidebar')?.openFile(scope, request.target, title) + } + } catch { + // Malformed push: ignore (the next push carries its own request). + } + } + socket.onclose = () => { + if (closed) return + failures += 1 + if (failures >= FAILURE_LIMIT) { + console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId) + return + } + retry = window.setTimeout(connect, 2000) + } + socket.onerror = () => { socket?.close() } + } + connect() + return () => { + closed = true + window.clearTimeout(retry) + socket?.close() + } + }, [sessionId, ctx, store]) + + /** + * Subagent auto-activation: the moment the current conversation spawns its + * FIRST direct subagent (a 0 → N transition on the list feed), the "auto + * open" pref is on, and the Tasks tab type is enabled in settings, activate + * the Tasks page. Single-instance semantics focus an existing pane tab in + * place or raise an existing free window; a new tab lands in the right pane + * and is never duplicated. On wide viewports the right panel also expands; + * on narrow viewports background activity never forces the full-screen + * drawer open over the chat. + * Switching to a session that already has subagents never triggers — its + * baseline starts at the current count — so a deliberate layout is never + * fought. + * + * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread + * is also a subagent-origin child, and its 'Side: ' title lands one frame + * after its origin — an immediate check would misread that first frame as + * a new subagent and pop this page on every thread creation. The timer + * re-evaluates the ORIGINAL baseline against the live snapshot; by then + * the title filter (isSideThreadSummary) sees the settled label. + */ + const listBaselineRef = useRef(undefined) + const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null) + useEffect(() => { + const prev = listBaselineRef.current + listBaselineRef.current = sessionList + if (sessionId === undefined || prev === undefined) return + if (autoOpenPendingRef.current !== null) return + if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return + const baseline = prev + const timer = window.setTimeout(() => { + autoOpenPendingRef.current = null + if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return + if (!store.getPrefs().autoOpenSubagent) return + if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return + // Read the viewport when the delayed activation fires: a resize while + // the debounce is armed must not let background activity force the + // narrow full-screen drawer open over the chat. + if (!isNarrowWidth(window.innerWidth)) { + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + } + // Choose the right panel as the landing pane for a newly created Tasks + // tab. Single-instance dedupe still activates an existing pane tab in + // place or raises an existing free window. + store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + }, AUTO_OPEN_DEBOUNCE_MS) + autoOpenPendingRef.current = { baseline, timer } + }, [sessionList, sessionId, store, ctx]) + + // A session switch (or unmount) voids any armed auto-open recheck. + useEffect(() => () => { + const pending = autoOpenPendingRef.current + if (pending !== null) window.clearTimeout(pending.timer) + autoOpenPendingRef.current = null + }, [sessionId]) + + /** + * Job auto-activation: the moment a NEW background job appears for the + * current conversation (a job id the previous snapshot lacked), the + * auto-open pref is on, and the Tasks tab type is enabled, activate the Tasks + * page that contains the background-jobs section. The right panel expands + * only on wide viewports. Unlike the subagent trigger (0 → N only), ANY + * new job id triggers: the agent may start several jobs in one session, and + * each should surface. A fresh page load never triggers — its baseline starts + * at the current snapshot. + */ + const jobBaselineRef = useRef(undefined) + useEffect(() => { + const prev = jobBaselineRef.current + jobBaselineRef.current = sessionList + if (sessionId === undefined || prev === undefined) return + if (!detectNewJob(prev, sessionList, sessionId)) return + if (!store.getPrefs().autoOpenJobs) return + if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return + if (!isNarrowWidth(window.innerWidth)) { + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + } + store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + }, [sessionList, sessionId, store, ctx]) + + /** + * Topology jump-back: clicking a subagent node on the Subagent page calls + * the official `openSubagent`, which switches the sidebar to that child + * session's OWN layout (a fresh child session defaults to the explorer). + * The README contract says the Subagent page must stay open with the jumped + * node highlighted — so once the current session becomes the recorded jump + * target, re-open the Subagent page on top of the child's layout (expanding + * the panel first if it is collapsed). Only this explicit node click arms + * the flag, so switching to a subagent session by any other means keeps + * that session's own layout untouched. + */ + const subagentJumpRef = useRef(undefined) + useEffect(() => { + const pending = subagentJumpRef.current + if (pending === undefined || sessionId !== pending) return + subagentJumpRef.current = undefined + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) + ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') }) + }, [sessionId, store, ctx]) + + return { subagentJumpRef } +} From b4ab3f154fd5600a92e424226a310172224ff1bb Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 21:08:03 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=8F=96=20pi?= =?UTF-8?q?nned=20=E8=99=9A=E6=8B=9F=20tab=20=E6=B3=A8=E5=85=A5=E4=B8=8E?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E5=8C=85=E8=A3=85=E5=88=B0=20sidebar/use-pin?= =?UTF-8?q?ned-tabs.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 产物。 --- src/client/Sidebar.tsx | 132 ++-------------------- src/client/sidebar/use-pinned-tabs.ts | 153 ++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 124 deletions(-) create mode 100644 src/client/sidebar/use-pinned-tabs.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8e07f9e..645bbd2 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -35,13 +35,13 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { - BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, + BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, } 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 { Workbench, type WorkbenchActions } from './split-pane.tsx' import { isNarrowWidth, useViewportSize } from './breakpoints.ts' @@ -53,6 +53,7 @@ import { computeTitleBarStrip } from './titlebar-strip.ts' import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { useCenterColumn } from './sidebar/use-center-column.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 { FreeWindow } from './FreeWindow.tsx' 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). 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(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 // spans ONLY the app shell's center column ("squeezes the agent output // 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]) - /** - * 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(() => { - 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]) + // Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned + // tabs inject into the right panel's first leaf, and the actions are + // wrapped so pinned virtual ids route to the HOME session (reduceFor + + // revision bump). + const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions }) /** * The explorer's @-reference button. Directories append the folder mention diff --git a/src/client/sidebar/use-pinned-tabs.ts b/src/client/sidebar/use-pinned-tabs.ts new file mode 100644 index 0000000..ce75248 --- /dev/null +++ b/src/client/sidebar/use-pinned-tabs.ts @@ -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(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(() => { + 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 } +} From e105b01f2b6d2088baebc3f9e71d96221dea4998 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 21:09:20 +0800 Subject: [PATCH 5/5] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=8F=96?= =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=AA=97=E5=8F=A3=E6=8B=96=E5=87=BA=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E6=B5=AE=E7=AA=97=E5=B1=82=E5=88=B0=20sideba?= =?UTF-8?q?r/free-windows.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar.tsx 拆分第五块:useFloatDragout(document 捕获态 dragover/ drop/dragend/blur 监听、会话列矩形命中测试、hint 状态去抖)与 FreeWindowLayer(floats.map 渲染 + 拖出 hint 覆盖层,fragment 渲染 保证 DOM 顺序与原先逐字一致)原样迁出,注释逐字保留。 行为不变:effect 依赖与回调闭包相同;层组件接收的 renderTab/ getTabIcon/store/ctx/sessionId/cwd 即原先 JSX 内联闭包捕获的同一批 每 render 新建的值,FreeWindow 收到的 props 与回调语义不变。 --- src/client/Sidebar.tsx | 140 ++++------------------- src/client/sidebar/free-windows.tsx | 167 ++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 117 deletions(-) create mode 100644 src/client/sidebar/free-windows.tsx diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 645bbd2..f20ce7a 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -35,10 +35,10 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { - BOTTOM_MIN, PANEL_MIN, agentUuidOf, dockFloat, firstLeaf, floatTab, + BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, floatTab, isAgentTabId, leafWithTab, migrateBottomTabs, - moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, - resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, + moveTab, moveTabToEdge, openDiffTab, resizeSplitIn, + setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' import { getPinnedHomeScope } from './pinned.ts' @@ -54,8 +54,8 @@ import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx' import { useCenterColumn } from './sidebar/use-center-column.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 { FreeWindow } from './FreeWindow.tsx' +import { FreeWindowLayer, useFloatDragout } from './sidebar/free-windows.tsx' +import type { TabDragPayload } from './TabBar.tsx' import { relativeTo } from './paths.ts' import { t } from './locales.ts' import { api } from './api.ts' @@ -359,86 +359,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const bottomRef = useRef(null) const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen) - /** - * Free windows — drag-out detection. The tab strips already drive HTML5 - * DnD (payload application/x-dsh-tab) with drops owned by the panes - * (split/merge); this shell watches the DOCUMENT (capture) for the same - * drag hovering OUTSIDE the panel host: while the pointer is over the - * conversation column it arms the drop (preventDefault) and shows a hint - * overlay there, and the drop floats the tab at the release point. Targets - * inside the host are ignored here, so pane drops keep their behavior - * untouched. Only OUR tab drags count (the body flag is the tab strip's; - * OS file drags and any DSH drags pass through). Narrow viewports skip - * the gesture — the merged drawer covers the conversation, leaving - * nothing to drop onto (the tab context menu entry still floats tabs). - */ - const [floatHint, setFloatHint] = useState<{ left: number; top: number; width: number; height: number } | null>(null) - const floatHintRef = useRef(false) - useEffect(() => { - if (narrow || sessionId === undefined) return - const inPanelHost = (target: EventTarget | null): boolean => - target instanceof Element && target.closest('[data-dsh-panel-host]') !== null - /** The conversation column's rect when the pointer is over it (and not - * over our own surfaces); null otherwise. */ - const overConversation = (event: DragEvent): DOMRect | null => { - if (inPanelHost(event.target)) return null - const col = centerColRef.current - if (col === null || !col.isConnected) return null - const rect = col.getBoundingClientRect() - if (rect.width === 0 || rect.height === 0) return null - const { clientX: x, clientY: y } = event - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null - return rect - } - const onDragOver = (event: DragEvent): void => { - if (!document.body.hasAttribute('data-dsh-tab-dragging')) return - const rect = overConversation(event) - if (rect !== null) { - // preventDefault on dragover is what makes the browser deliver the - // drop (and drop the "no" cursor) over the conversation area. - event.preventDefault() - setFloatHint((prev) => { - const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height } - if (prev !== null && prev.left === next.left && prev.top === next.top - && prev.width === next.width && prev.height === next.height) return prev - return next - }) - floatHintRef.current = true - } else if (floatHintRef.current) { - floatHintRef.current = false - setFloatHint(null) - } - } - const onDrop = (event: DragEvent): void => { - if (!floatHintRef.current) return - floatHintRef.current = false - setFloatHint(null) - const rect = overConversation(event) - if (rect === null) return - event.preventDefault() - event.stopPropagation() - const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '') - if (payload === null) return - store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY)) - } - const clear = (): void => { - if (!floatHintRef.current) return - floatHintRef.current = false - setFloatHint(null) - } - document.addEventListener('dragover', onDragOver, true) - document.addEventListener('drop', onDrop, true) - window.addEventListener('dragend', clear, true) - window.addEventListener('blur', clear) - return () => { - document.removeEventListener('dragover', onDragOver, true) - document.removeEventListener('drop', onDrop, true) - window.removeEventListener('dragend', clear, true) - window.removeEventListener('blur', clear) - } - // centerColRef is a stable ref object from useCenterColumn (it lost its - // useRef provenance crossing the hook boundary, so it is listed here). - }, [narrow, sessionId, store, centerColRef]) + // Free-window drag-out gesture (sidebar/free-windows.tsx): watches the + // document for OUR tab drags hovering the conversation column and floats + // the tab on release. Returns the drop-zone hint geometry for the layer. + const { floatHint } = useFloatDragout({ narrow, sessionId, store, centerColRef }) /** * Bottom-panel first-expansion auto terminal: the FIRST time the user @@ -1232,39 +1156,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { )} {/* - Free windows: tabs dragged out onto the conversation area (or floated - from the tab context menu). They live in the panel host like the - panels (viewport coordinates, immune to desktop-shell transforms) but - are independent of panel state — a window stays up while panels - collapse. The floats array's order is the stacking order; the content - reuses the regular tab renderer, so every tab type floats unchanged. + Free windows + the drag-out hint: the layer renders the floats (the + array's order is the stacking order) reusing the regular tab + renderer, followed by the dashed drop-zone overlay (sidebar/ + free-windows.tsx renders both as one fragment — DOM unchanged). */} - {state.floats.map(float => ( - renderTab(tab, active, paneId, 'float')} - getTabIcon={tabIconOf} - onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }} - onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }} - onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }} - onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }} - onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }} - /> - ))} - {/* - The drag-out hint: while a tab drag hovers the conversation column, - a dashed overlay marks the drop zone there (pointer-transparent — it - must not disturb the drag it describes). - */} - {floatHint !== null && ( -
- {t('floatDropHint')} -
- )} + ) } diff --git a/src/client/sidebar/free-windows.tsx b/src/client/sidebar/free-windows.tsx new file mode 100644 index 0000000..56e13b8 --- /dev/null +++ b/src/client/sidebar/free-windows.tsx @@ -0,0 +1,167 @@ +/** + * Free windows (extracted from Sidebar.tsx, behavior identical): the + * drag-out gesture that floats a tab onto the conversation column, plus the + * render layer for the floating windows and its drop-zone hint overlay. + */ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import type { Context } from '../../context-types.ts' +import { + dockFloat, floatTab, moveFloat, raiseFloat, resizeFloat, + type FloatWindow, type SidebarStore, type SidebarTab, +} from '../state.ts' +import { TAB_DRAG_TYPE, parseDrag } from '../TabBar.tsx' +import { FreeWindow } from '../FreeWindow.tsx' +import { t } from '../locales.ts' +import css from '../sidebar.module.css' + +/** The dashed drop-zone hint's geometry (viewport coordinates). */ +export interface FloatDropHint { + left: number + top: number + width: number + height: number +} + +/** + * Free windows — drag-out detection. The tab strips already drive HTML5 + * DnD (payload application/x-dsh-tab) with drops owned by the panes + * (split/merge); this hook watches the DOCUMENT (capture) for the same + * drag hovering OUTSIDE the panel host: while the pointer is over the + * conversation column it arms the drop (preventDefault) and shows a hint + * overlay there, and the drop floats the tab at the release point. Targets + * inside the host are ignored here, so pane drops keep their behavior + * untouched. Only OUR tab drags count (the body flag is the tab strip's; + * OS file drags and any DSH drags pass through). Narrow viewports skip + * the gesture — the merged drawer covers the conversation, leaving + * nothing to drop onto (the tab context menu entry still floats tabs). + */ +export function useFloatDragout(input: { + narrow: boolean + sessionId: string | undefined + store: SidebarStore + /** The AppFrame center column (from useCenterColumn); stable ref object. */ + centerColRef: { readonly current: HTMLElement | null } +}): { floatHint: FloatDropHint | null } { + const { narrow, sessionId, store, centerColRef } = input + const [floatHint, setFloatHint] = useState(null) + const floatHintRef = useRef(false) + useEffect(() => { + if (narrow || sessionId === undefined) return + const inPanelHost = (target: EventTarget | null): boolean => + target instanceof Element && target.closest('[data-dsh-panel-host]') !== null + /** The conversation column's rect when the pointer is over it (and not + * over our own surfaces); null otherwise. */ + const overConversation = (event: DragEvent): DOMRect | null => { + if (inPanelHost(event.target)) return null + const col = centerColRef.current + if (col === null || !col.isConnected) return null + const rect = col.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return null + const { clientX: x, clientY: y } = event + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return null + return rect + } + const onDragOver = (event: DragEvent): void => { + if (!document.body.hasAttribute('data-dsh-tab-dragging')) return + const rect = overConversation(event) + if (rect !== null) { + // preventDefault on dragover is what makes the browser deliver the + // drop (and drop the "no" cursor) over the conversation area. + event.preventDefault() + setFloatHint((prev) => { + const next = { left: rect.left, top: rect.top, width: rect.width, height: rect.height } + if (prev !== null && prev.left === next.left && prev.top === next.top + && prev.width === next.width && prev.height === next.height) return prev + return next + }) + floatHintRef.current = true + } else if (floatHintRef.current) { + floatHintRef.current = false + setFloatHint(null) + } + } + const onDrop = (event: DragEvent): void => { + if (!floatHintRef.current) return + floatHintRef.current = false + setFloatHint(null) + const rect = overConversation(event) + if (rect === null) return + event.preventDefault() + event.stopPropagation() + const payload = parseDrag(event.dataTransfer?.getData(TAB_DRAG_TYPE) ?? '') + if (payload === null) return + store.reduce(s => floatTab(s, payload.tabId, event.clientX, event.clientY)) + } + const clear = (): void => { + if (!floatHintRef.current) return + floatHintRef.current = false + setFloatHint(null) + } + document.addEventListener('dragover', onDragOver, true) + document.addEventListener('drop', onDrop, true) + window.addEventListener('dragend', clear, true) + window.addEventListener('blur', clear) + return () => { + document.removeEventListener('dragover', onDragOver, true) + document.removeEventListener('drop', onDrop, true) + window.removeEventListener('dragend', clear, true) + window.removeEventListener('blur', clear) + } + // centerColRef is a stable ref object from useCenterColumn (it lost its + // useRef provenance crossing the hook boundary, so it is listed here). + }, [narrow, sessionId, store, centerColRef]) + + return { floatHint } +} + +/** + * The free-window render layer: tabs dragged out onto the conversation area + * (or floated from the tab context menu). They live in the panel host like + * the panels (viewport coordinates, immune to desktop-shell transforms) but + * are independent of panel state — a window stays up while panels collapse. + * The floats array's order is the stacking order; the content reuses the + * regular tab renderer, so every tab type floats unchanged. Renders a + * fragment so the DOM is exactly the floats followed by the drag-out hint. + */ +export function FreeWindowLayer(props: { + floats: readonly FloatWindow[] + hint: FloatDropHint | null + renderTab: (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float') => ReactNode + getTabIcon: (tab: SidebarTab) => ReactNode + store: SidebarStore + ctx: Context + sessionId: string + cwd: string | undefined +}) { + const { floats, hint, renderTab, getTabIcon, store, ctx, sessionId, cwd } = props + return ( + <> + {floats.map(float => ( + renderTab(tab, active, paneId, 'float')} + getTabIcon={getTabIcon} + onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }} + onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }} + onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }} + onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }} + onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }} + /> + ))} + {/* + The drag-out hint: while a tab drag hovers the conversation column, + a dashed overlay marks the drop zone there (pointer-transparent — it + must not disturb the drag it describes). + */} + {hint !== null && ( +
+ {t('floatDropHint')} +
+ )} + + ) +}