Merge remote-tracking branch 'origin/master' into mergebot/pr1389

# Conflicts:
#	apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md
#	packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx
#	packages/client/ui-conversation/src/client/chat/message-chrome.ts
This commit is contained in:
imccyu
2026-08-04 16:33:55 +08:00
66 changed files with 916 additions and 94 deletions
@@ -16,14 +16,8 @@
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
/* Scrollbar hides until the pointer is over the sidebar: rebind the
ui-theme scrollbar indirection (styles/scrollbar.css) to transparent;
the session list's scrollbar-gutter keeps layout stable either way. */
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
.root:hover {
/* The sidebar is elevated above the conversation surface, so a revealed
scrollbar uses the l2 pair. .quietBars hides it without changing layout. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@@ -35,6 +29,19 @@
padding: 18px 10px 6px;
}
/* Scrollbars in the column are a pointer affordance: the shell adds this
class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
and rebinding ui-theme's indirection pair to `transparent` takes the thumb
out of every scroll region nested under it — the workspace browser's
session list today. `transparent` rather than `display: none` on the bar:
the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
revealing the thumb never reflows a row. Rebinding contract and the two
rendering paths it reaches: ui-theme's README. */
.root.quietBars {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
/* Collapse phase 1: the whole frozen-width content fades out in place over
150ms; at settle the children unmount/snap to the rail layout. */
.fading > * {
@@ -8,6 +8,11 @@
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
* is the `sidebar.settings` registrant's; the shell hands them the wide flag
* (plus an expand request callback for the browser).
*
* The column also owns whether the scroll regions nested in it draw a
* scrollbar at all: the shell tracks the pointer and rebinds ui-theme's
* scrollbar indirection away while it is elsewhere, so a list the user is not
* pointing at carries no bar.
*/
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -22,6 +27,14 @@ import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
const COLLAPSE_SETTLE_MS = 150
/**
* How long the column's scrollbars stay drawn after the pointer leaves it.
* The bar is a pointer affordance here, and hiding it on the leave event
* itself makes it blink out while the pointer is only crossing the column's
* edge — on the way to the conversation, or around a portalled menu.
*/
const SCROLLBAR_LINGER_MS = 2000
/**
* Render the sidebar column shell.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
@@ -56,10 +69,62 @@ export function SidebarRoot({
const everWide = useRef(!collapsed)
if (!collapsed) everWide.current = true
// Scrollbars in the column follow the pointer (.quietBars rebinds them
// away): drawn while it is inside, and for SCROLLBAR_LINGER_MS after it
// leaves. A pointer that returns within that window cancels the pending
// hide rather than restarting from a hidden bar.
const column = useRef<HTMLDivElement>(null)
const [pointerInside, setPointerInside] = useState(false)
const lingerTimer = useRef<number | undefined>(undefined)
const armLinger = (): void => {
if (lingerTimer.current !== undefined) return
lingerTimer.current = window.setTimeout(() => {
lingerTimer.current = undefined
setPointerInside(false)
}, SCROLLBAR_LINGER_MS)
}
const cancelLinger = (): void => {
window.clearTimeout(lingerTimer.current)
lingerTimer.current = undefined
}
// Leaving is decided by the column's BOX, not by DOM containment, and only
// while the bars are drawn. ui-settings renders its full-viewport panel as a
// fixed-position DESCENDANT of this column, so a pointer moved onto that
// panel — or onto the conversation once it closes — fires no `pointerleave`
// here, and the bars would stay drawn over a column nobody is pointing at.
// The element's own leave stays as the one signal geometry cannot give: a
// pointer that leaves the window emits no further moves.
useEffect(() => {
if (!pointerInside) return
const onMove = (event: PointerEvent): void => {
const rect = column.current?.getBoundingClientRect()
/* v8 ignore next -- the listener only exists while the column is mounted and revealed. */
if (rect === undefined) return
const inside = event.clientX >= rect.left && event.clientX < rect.right
&& event.clientY >= rect.top && event.clientY < rect.bottom
if (inside) cancelLinger()
else armLinger()
}
document.addEventListener('pointermove', onMove)
return () => {
document.removeEventListener('pointermove', onMove)
cancelLinger()
}
}, [pointerInside])
return (
<div
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
ref={column}
className={clsx(
css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn,
collapsed && wide && css.fading, !pointerInside && css.quietBars,
)}
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
onPointerEnter={() => {
cancelLinger()
setPointerInside(true)
}}
onPointerLeave={() => { armLinger() }}
>
<div className={css.logoRow}>
{/* Expanded, the wordmark doubles as a New Session shortcut; the