fix(sidebar): drag-release bounce, session-delete terminal cleanup, silent resize

- Sidebar.tsx: useLayoutEffect for the layout-push variables so cleanup+setup
  land in the same pre-paint frame (no full-width flash on drag release);
  applyDrag writes the width only while the panel is open (a closed right
  panel no longer squeezes the conversation when dragging the bottom height);
  flush the final drag frame before stopDragScheduling on all three handles;
  sync centerRect.right on width/corner release so the bottom panel does not
  jump.
- index.ts/pty-manager.ts: close a session's UI terminals immediately on
  session/disposed (they used to linger until reconnectGraceMs expired).
- sidebar.module.css: no highlight on the resize handles while dragging
  (local preference; the corner hover hint stays).
This commit is contained in:
chalei1122-cell
2026-08-16 17:43:29 +08:00
parent 3c196fffc3
commit 165ab03ea6
4 changed files with 77 additions and 16 deletions
+60 -12
View File
@@ -25,7 +25,7 @@
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { createElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -491,14 +491,23 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag. */
const applyDrag = (width: number, height: number): void => {
panelRef.current?.style.setProperty('width', `${width}px`)
// Fix (bounce): the width must only be written while the right panel is
// OPEN. When it is closed, `state.width` still holds the last used width,
// so dragging the bottom panel height used to write a stale non-zero
// `--dsh-sidebar-width` and squeezed the conversation left for the whole
// drag (it only recovered on release).
const effWidth = state?.panelOpen === true ? width : 0
const baseWidth = state?.panelOpen === true ? (state?.width ?? 0) : 0
panelRef.current?.style.setProperty('width', `${effWidth}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
// centerRect.right is the center column's right edge at the committed
// width (innerWidth - state.width - detailsWidth), so this equals
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
// `effWidth + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag). With the panel closed
// both effWidth and baseWidth are 0, so the bottom panel keeps only the
// native details-column offset (0 on current DSH builds).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (effWidth - baseWidth)}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${effWidth}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// The corner handle positions itself relative to the panel (CSS
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
@@ -542,7 +551,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// crush the app shell to zero. Dragging disables the layout transition.
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
// conversation keeps the full width behind the drawer.
useEffect(() => {
useLayoutEffect(() => {
const width = !narrow && snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
@@ -556,8 +565,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// HMR), the CSS variables would otherwise stay on <html> and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. removeProperty restores the CSS fallback
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
// same commit on state changes, so there is no visible flicker.
// (var(--dsh-sidebar-width, 0px)).
//
// Fix (bounce): this effect was a passive `useEffect`, which runs AFTER
// paint. On release the cleanup removes the variables and the setup
// re-adds them, but the two phases can straddle a paint (React 18/19
// flush passive effects in chunks), so the browser paints the
// intermediate state where `--dsh-sidebar-width` is missing and #root's
// margin-right falls back to 0 — the conversation flashes back to full
// width and then returns ("bounce"). A layout effect runs synchronously
// before paint, so cleanup+setup land in the same frame and the
// intermediate state is never painted, while the unmount cleanup above
// still releases the push exactly as before.
return () => {
document.documentElement.style.removeProperty('--dsh-sidebar-width')
document.documentElement.style.removeProperty('--dsh-sidebar-height')
@@ -798,8 +817,23 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
// Fix (bounce): flush the FINAL frame BEFORE stopDragScheduling.
// The stop cancels the pending rAF, so the DOM variables would
// otherwise stay at the second-to-last frame's width while the
// store commits the final one — a few px that re-animate when
// the drag transition comes back.
const finalWidth = clampWidth(startWidth + (startX - event.clientX))
const finalHeight = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
applyDrag(finalWidth, finalHeight)
// Keep the bottom panel's measured right edge in sync with the
// committed width (details-column offset preserved), so the
// release render doesn't paint the pre-drag edge.
const detailsOffset = Math.max(0, window.innerWidth - centerRect.right - (state.panelOpen ? state.width : 0))
setCenterRect(prev => prev.right === centerRect.right
? { left: prev.left, right: window.innerWidth - finalWidth - detailsOffset }
: prev)
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
store.reduce(s => setWidth(s, finalWidth))
setDraggingWidth(false)
}}
/>
@@ -852,8 +886,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
// Fix (bounce): flush the final frame before stopDragScheduling
// (same as the width/bottom drags).
const finalWidth = clampWidth(startWidth + (startX - event.clientX))
const finalHeight = clampHeight(startHeight + (startY - event.clientY))
applyDrag(finalWidth, finalHeight)
const detailsOffset = Math.max(0, window.innerWidth - centerRect.right - state.width)
setCenterRect(prev => prev.right === centerRect.right
? { left: prev.left, right: window.innerWidth - finalWidth - detailsOffset }
: prev)
stopDragScheduling()
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
store.reduce(s => setBottomHeight(setWidth(s, finalWidth), finalHeight))
setDraggingCorner(false)
}}
/>
@@ -908,8 +951,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
// Fix (bounce): flush the final frame before stopDragScheduling
// (same as the width drag — the pending rAF would otherwise be
// dropped and the height would snap back a few px on release).
const finalHeight = clampHeight(startHeight + (startY - event.clientY))
applyDrag(Math.min(state.width, window.innerWidth), finalHeight)
stopDragScheduling()
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
store.reduce(s => setBottomHeight(s, finalHeight))
setDraggingBottom(false)
}}
/>
+4 -4
View File
@@ -132,7 +132,7 @@
}
.panelResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
/* Drag feedback disabled by local preference: no highlight while resizing. */
}
.panelBody {
@@ -195,7 +195,7 @@
}
.bottomResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
/* Drag feedback disabled by local preference: no highlight while resizing. */
}
/* The bottom panel's close control: the app's icon-button pattern (28px
@@ -274,8 +274,8 @@
touch-action: none;
}
.cornerHandle:hover,
.cornerHandle[data-dragging] {
.cornerHandle:hover {
/* Only hover feedback stays; dragging highlight disabled by local preference. */
background: var(--dsw-alias-interactive-bg-hover-accent);
}
+7
View File
@@ -705,6 +705,13 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
// Close all UI terminals of a session when the conversation is deleted —
// terminals follow the session, not the reconnect grace. Agent terminals
// are owned by the agent lifecycle and are not touched here.
ctx.on('session/disposed', (session) => {
try { ptyManager.closeSession(session?.id) } catch {}
}, { global: true })
}
/** Push the live agent-terminal list for one session to a connected sidebar view. */
+6
View File
@@ -184,6 +184,12 @@ export class PtyManager {
}
}
/** Close every terminal of one session (the conversation was deleted —
* terminals must follow the session, not linger until the grace expires). */
closeSession(sessionId: string): void {
for (const key of this.keysOf(sessionId)) this.close(key)
}
/** Close every terminal (plugin teardown). */
disposeAll(): void {
for (const timer of this.pendingCloses.values()) clearTimeout(timer)