From 6bccd21a49743aaca1bfab1772ba81625a4052fb Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 21:03:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor(client):=20=E6=8A=BD=E5=8F=96=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=88=97=E5=AE=9A=E4=BD=8D/=E6=B5=8B=E9=87=8F?= =?UTF-8?q?=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 } +}