From 165ab03ea613bb5046f55dff990ca5ce07543a9e Mon Sep 17 00:00:00 2001 From: chalei1122-cell Date: Sun, 16 Aug 2026 17:43:29 +0800 Subject: [PATCH] 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). --- src/client/Sidebar.tsx | 72 +++++++++++++++++++++++++++++------ src/client/sidebar.module.css | 8 ++-- src/index.ts | 7 ++++ src/pty-manager.ts | 6 +++ 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 07035d0..d73d468 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 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) }} /> diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 2426173..c003ba4 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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); } diff --git a/src/index.ts b/src/index.ts index 3933f4f..805a21c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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. */ diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 860145a..7623b16 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -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)