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:
+60
-12
@@ -25,7 +25,7 @@
|
|||||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||||
* the right tree.
|
* 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 { useSyncExternalStore } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
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
|
* 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. */
|
* written directly or the bottom panel would lag the sidebar mid-drag. */
|
||||||
const applyDrag = (width: number, height: number): void => {
|
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`)
|
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||||
// centerRect.right is the center column's right edge at the committed
|
// centerRect.right is the center column's right edge at the committed
|
||||||
// width (innerWidth - state.width - detailsWidth), so this equals
|
// width (innerWidth - state.width - detailsWidth), so this equals
|
||||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
// `effWidth + detailsWidth` — derived from the measured column, keeping the
|
||||||
// drag write-only (no React re-render mid-drag).
|
// drag write-only (no React re-render mid-drag). With the panel closed
|
||||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
// both effWidth and baseWidth are 0, so the bottom panel keeps only the
|
||||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
// 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`)
|
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||||
// The corner handle positions itself relative to the panel (CSS
|
// The corner handle positions itself relative to the panel (CSS
|
||||||
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
|
// `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.
|
// crush the app shell to zero. Dragging disables the layout transition.
|
||||||
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
|
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
|
||||||
// conversation keeps the full width behind the drawer.
|
// conversation keeps the full width behind the drawer.
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const width = !narrow && snapshot.state?.panelOpen === true
|
const width = !narrow && snapshot.state?.panelOpen === true
|
||||||
? Math.min(snapshot.state.width, window.innerWidth)
|
? Math.min(snapshot.state.width, window.innerWidth)
|
||||||
: 0
|
: 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
|
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||||
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
||||||
// hidden" until a full reload. removeProperty restores the CSS fallback
|
// hidden" until a full reload. removeProperty restores the CSS fallback
|
||||||
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
|
// (var(--dsh-sidebar-width, 0px)).
|
||||||
// same commit on state changes, so there is no visible flicker.
|
//
|
||||||
|
// 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 () => {
|
return () => {
|
||||||
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
||||||
document.documentElement.style.removeProperty('--dsh-sidebar-height')
|
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
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||||
const { startX, startWidth } = widthDrag.current
|
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()
|
stopDragScheduling()
|
||||||
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
|
store.reduce(s => setWidth(s, finalWidth))
|
||||||
setDraggingWidth(false)
|
setDraggingWidth(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -852,8 +886,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
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()
|
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)
|
setDraggingCorner(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -908,8 +951,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||||
const { startY, startHeight } = bottomDrag.current
|
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()
|
stopDragScheduling()
|
||||||
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
|
store.reduce(s => setBottomHeight(s, finalHeight))
|
||||||
setDraggingBottom(false)
|
setDraggingBottom(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panelResizeActive {
|
.panelResizeActive {
|
||||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
/* Drag feedback disabled by local preference: no highlight while resizing. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelBody {
|
.panelBody {
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottomResizeActive {
|
.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
|
/* The bottom panel's close control: the app's icon-button pattern (28px
|
||||||
@@ -274,8 +274,8 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cornerHandle:hover,
|
.cornerHandle:hover {
|
||||||
.cornerHandle[data-dragging] {
|
/* Only hover feedback stays; dragging highlight disabled by local preference. */
|
||||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -705,6 +705,13 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
|||||||
wss.close()
|
wss.close()
|
||||||
agentListWss.close()
|
agentListWss.close()
|
||||||
}, 'dsh-better-sidebar: teardown')
|
}, '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. */
|
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||||
|
|||||||
@@ -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). */
|
/** Close every terminal (plugin teardown). */
|
||||||
disposeAll(): void {
|
disposeAll(): void {
|
||||||
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
|
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
|
||||||
|
|||||||
Reference in New Issue
Block a user