refactor(client): 抽取中心列定位/测量链到 sidebar/use-center-column.ts
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 零告警所需。
This commit is contained in:
+16
-160
@@ -46,12 +46,12 @@ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
|||||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||||
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
||||||
import { layoutPushSize } from './layout-push.ts'
|
import { layoutPushSize } from './layout-push.ts'
|
||||||
import { resolveCenterColumn } from './center-column.ts'
|
|
||||||
import { parseDesktopEnv } from './desktop-env.ts'
|
import { parseDesktopEnv } from './desktop-env.ts'
|
||||||
import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
||||||
import { getShellPreset } from './shell-presets.ts'
|
import { getShellPreset } from './shell-presets.ts'
|
||||||
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||||
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
|
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
|
||||||
|
import { useCenterColumn } from './sidebar/use-center-column.ts'
|
||||||
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
import { TAB_DRAG_TYPE, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||||
import { FreeWindow } from './FreeWindow.tsx'
|
import { FreeWindow } from './FreeWindow.tsx'
|
||||||
import { relativeTo } from './paths.ts'
|
import { relativeTo } from './paths.ts'
|
||||||
@@ -635,161 +635,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
[state, pinnedVirtualTabs, activePinnedTabId],
|
[state, pinnedVirtualTabs, activePinnedTabId],
|
||||||
)
|
)
|
||||||
|
|
||||||
// The app shell's center column: the bottom panel spans ONLY that column
|
// Center-column tracking (sidebar/use-center-column.ts): the bottom panel
|
||||||
// ("squeezes the agent output area") — it starts at the app sidebar's
|
// spans ONLY the app shell's center column ("squeezes the agent output
|
||||||
// right edge and ends at the details column's left edge (the details
|
// area"); the hook locates the AppFrame's center column DOM (host anchor
|
||||||
// column sits between the center and the right panel). Measured directly
|
// observers + slow retry), measures its edges into a ref, and writes them
|
||||||
// from the AppFrame's center column DOM (the parent of the
|
// straight to the bottom panel element — per-frame tracking without
|
||||||
// [data-slot="conversation"] wrapper — layout.css's center column) so the
|
// re-rendering the shell (the comments live with the hook now).
|
||||||
// bottom panel tracks the column's real
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
// horizontal edges — including the animated AppFrame padding reservation
|
const { centerColRef, centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
|
||||||
// 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<HTMLElement | null>(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 <html>'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 <html>. 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 <html>'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])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Free windows — drag-out detection. The tab strips already drive HTML5
|
* 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('dragend', clear, true)
|
||||||
window.removeEventListener('blur', clear)
|
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
|
* 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
|
// move, which is the visible drag lag. The store is committed once on
|
||||||
// pointer up (clamping + persistence).
|
// pointer up (clamping + persistence).
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||||
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
|
||||||
@@ -924,7 +778,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
draggingRef.current = anyDragging
|
draggingRef.current = anyDragging
|
||||||
if (!anyDragging) measureCenter()
|
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
|
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
|
||||||
// re-clamps on commit; these keep the panels from overshooting mid-drag).
|
// 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) => {
|
pinTab: (tabId, scope) => {
|
||||||
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
|
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
|
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
|
||||||
|
|||||||
@@ -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<HTMLElement | null>(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 <html>'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 <html>. 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 <html>'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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user