Merge branch 'master' into feat/turn-running-time

This commit is contained in:
imccyu
2026-08-04 16:03:31 +08:00
committed by GitHub
55 changed files with 1525 additions and 605 deletions

View File

@@ -88,11 +88,11 @@
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
line-height: 24px;
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
once the composer hits its height cap, so the thumb takes the l2 pair.
Declared on the card because the elevation belongs to the surface, and the
custom properties inherit down to the textarea that actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
/* Elevated surface in dark, same as the menus: the draft scrollport inside
scrolls once the composer hits its height cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the box that actually scrolls
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@@ -112,8 +112,21 @@
height: 0;
}
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
/* The draft's scrollport, and the ONLY scrolling box in the composer: the
caret is the textarea's and every visible glyph is the backdrop's, so the two
layers stay together only by riding one offset the browser applies to both at
once. Scrolling one box and assigning the offset to the other cannot hold —
a wheel gesture is composited off the main thread, so the assignment lands
frames late and the words visibly trail the caret. The 14-line cap lives here
because this is the box the cap describes. */
.scroll {
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
}
/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
MUST share font, line-height, padding and wrapping rules or heights diverge. */
.grow {
position: relative;
@@ -165,7 +178,11 @@
width: 100%;
height: 100%;
resize: none;
overflow-y: auto;
/* Never a scroller of its own: it is as tall as the draft, so it has no
scrollable overflow to hold an offset that could differ from the glyphs'.
The browser still reveals the caret — the scroll-into-view walks up to
.scroll and moves both layers together. */
overflow: hidden;
border: none;
outline: none;
background: transparent;
@@ -189,22 +206,24 @@
share the stack, so placeholder advances agree by construction. */
font-family: 'DshChipCell', var(--dsw-font-family);
font-size: inherit;
/* Three consumers, not two: the mirror sizes the stack, the layers must break
lines identically, and the caret reveal parses this value to step one line
down for a caret that sits after a newline. That parse needs a length, so a
theme resolving this to `normal` would make the reveal a silent no-op. */
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
/* These three MUST wrap at one width, because InputBar mirrors a single
scroll offset between .input and .backdrop and a layer that wraps onto
more lines is taller, has a larger scroll maximum, and clamps the mirrored
offset below the caret. Only .input scrolls, so only .input can lose
content width to a scrollbar that consumes layout space.
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
removed after measuring: WebKit applies it to overflow-y:auto but not to
the overflow:hidden layers, so it left .input at 768 against 776 — the
same gap it was meant to close — while costing chromium 8px of text width
unconditionally. The gap it would have closed is measured and recorded in
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
closing it needs one geometry every engine agrees on, not this property. */
/* These three MUST wrap at one width: the mirror decides the box height
the other two are laid out in, and a glyph layer that breaks lines
elsewhere than the textarea puts the words under the wrong caret. They do
so by construction now that all three sit INSIDE .scroll — a scrollbar
that consumes layout space narrows the scrollport, which is their shared
containing block, so it costs all three the same width on every engine.
Scrolling the textarea itself is what used to break this, and no property
fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
and not for the overflow:hidden layers beside it, leaving them 8px apart
(768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
@@ -222,10 +241,6 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24

View File

@@ -63,7 +63,8 @@ export function InputBar({
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const backdropRef = useRef<HTMLDivElement | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -88,29 +89,101 @@ export function InputBar({
const locked = disabled
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked, sessionId])
// Scroll the draft scrollport the minimum that brings `caret` into view — the
// browser's own behavior for typing, performed for the paths where it does
// not act.
//
// The mirror is the caret's ruler: it renders the same draft at the same
// metrics and the same wrap width in the same stack (that is what makes it
// the height authority), so a Range collapsed at the caret's index reports
// where the caret is without a caret API.
const revealCaret = (caret: number): void => {
const scrollEl = scrollRef.current
const mirrorEl = mirrorRef.current
const text = mirrorEl?.firstChild
if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return
// A box that cannot scroll has nothing to reveal: the draft fits, so every
// caret is already in view and the assignment below would clamp to itself.
if (scrollEl.scrollHeight <= scrollEl.clientHeight) return
const at = Math.min(caret, text.data.length)
// A caret straight after a newline sits on a line with nothing on it to
// measure — the shape a trailing-newline draft ends in — and the engines
// disagree there: chromium returns NO client rects at all (an all-zero box,
// which would scroll the wrong way), firefox reports the line above, WebKit
// the right one. Measure the newline itself instead, which is the line the
// caret just left, and step one line down; that they all agree on.
const afterNewline = at > 0 && text.data[at - 1] === '\n'
const range = document.createRange()
range.setStart(text, afterNewline ? at - 1 : at)
if (afterNewline) range.setEnd(text, at)
else range.collapse(true)
const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0
const rect = range.getBoundingClientRect()
const box = scrollEl.getBoundingClientRect()
if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom
else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line
}
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
// the inert state renders the same element disabled).
//
// wheel — active conversation scrollport: chain the gesture. While the
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
// direction, keep the native scroll; only at its own edge forward delta to
// the host so a short draft never traps the gesture and a long draft stays
// scrollable. Hero mounts have no host and keep native wheel scrolling.
//
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place. Every way the box moves ends
// in a `scroll` event, edits included (the caret is scrolled into view), and
// the layers share an extent, so a draft that shrinks past the offset clamps
// both to the same maximum — one listener covers the coupling.
// Reveal the focus end of the current selection. Today's entry paths leave a
// collapsed selection, but honoring direction keeps a future range-preserving
// path from revealing its anchor instead of its focus.
const revealSelectionFocus = (el: HTMLTextAreaElement): void => {
// selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them.
const caret = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd
// oxlint-disable-next-line typescript/no-unnecessary-condition
revealCaret(caret ?? el.value.length)
}
// Unlock (mount / session switch) returns focus to the box, and owns the
// reveal that comes with it. `preventScroll` because this focus is ours, not
// a gesture: the textarea is as tall as the draft, so the browser's reveal
// would walk up to the conversation scrollport and move the transcript under
// a user who only switched session. That leaves the caret to us — the DOM is
// reused across sessions, so switching to a longer draft keeps the previous
// offset while the value swap puts the caret at the new draft's end, which is
// off screen (measured on all three engines: offset 0 with the caret 940px
// down). Suppress the walk, then reveal in our own box.
useEffect(() => {
const el = inputRef.current
if (locked || el === null) return
el.focus({ preventScroll: true })
revealSelectionFocus(el)
}, [locked, sessionId])
// A persisted draft arrives AFTER the unlock effect: ConversationSession
// adopts it in its own mount effect, and a parent's mount effect runs after
// its children's. Reveal when the draft becomes non-empty so a restored long
// draft does not stay at its head with the caret at its end. This effect does
// not focus: send-clear, failed-send restore, and first-character transitions
// must not steal focus from another control the user moved to.
useEffect(() => {
const el = inputRef.current
if (locked || draft === '' || el === null) return
revealSelectionFocus(el)
}, [draft !== ''])
// Caret restore after an edit the composer performs itself. The machine owns
// the draft and the undo log, so paste and cut suppress the native edit and
// write the value through the machine — and a
// programmatic selection change reveals nothing: measured in chromium and
// WebKit, pasting a long block leaves the view where it was while the caret
// sits at the end of the draft. Native typing gets its reveal from the
// browser; these two have to ask for it, so they share one restore.
const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
requestAnimationFrame(() => {
el.setSelectionRange(caret, caret)
revealCaret(caret)
})
}
// Wheel chaining on the draft scrollport, one lifetime (it is never
// unmounted — the inert state renders the same element disabled). While the
// capped box can still move in this direction, keep the native scroll; only
// at its own edge forward the delta to the active conversation scrollport, so
// a short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
useEffect(() => {
const el = scrollRef.current
if (el === null) return
const onWheel = (e: WheelEvent): void => {
const host = el.closest('[data-conversation-scroll]')
@@ -121,16 +194,8 @@ export function InputBar({
e.preventDefault()
host.scrollTop += e.deltaY
}
const onScroll = (): void => {
const backdropEl = backdropRef.current
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}
el.addEventListener('wheel', onWheel, { passive: false })
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
@@ -234,7 +299,7 @@ export function InputBar({
e.clipboardData.setData('text/plain', text)
if (cut && !machineBusy && !locked) {
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
requestAnimationFrame(() => { el.setSelectionRange(start, start) })
restoreCaret(el, start)
}
void slice
}
@@ -253,7 +318,7 @@ export function InputBar({
// land (paste-upgrade). The DOM layer only starts the transaction.
keyboard.pasteBegin(text, sel)
const caret = sel.start + text.length
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
restoreCaret(el, caret)
keyboard.track(keyboard.snapshot.draft, caret)
}
@@ -264,10 +329,13 @@ export function InputBar({
void e
}
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
// Button presses steal focus from the textarea; suppress at mousedown so
// typing continues seamlessly. `preventScroll` for the same reason as the
// unlock effect, and with no reveal of its own: the caret has not moved, and
// the next keystroke gets the browser's native one.
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}
const onToggleCommandMenu = (): void => {
@@ -369,22 +437,6 @@ export function InputBar({
const displayHint = translated !== hintKey ? translated : deco.hint
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
}
// Trailing-line sentinel, the same one the mirror div carries and for the
// same reason: a textarea reserves a line box for the caret after a final
// newline, while `white-space: pre-wrap` collapses a text node's trailing
// newline and generates none. Without it a draft ending in a newline makes
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
// offset at the very bottom clamps and the glyphs sit a line behind the
// caret. The extra newline is absorbed by that same collapse when the draft
// does not end in one, so it costs no height in the ordinary case.
//
// The mirror only fails one way — a backdrop SHORTER than the textarea
// clamps the assignment, while a taller one takes every offset exactly and
// hides the surplus below the clip. That is why the ghost hint needs no
// handling of its own: it can only add content after the draft and before
// this sentinel, never remove a line box, so it moves the pair to equal or
// to the safe side.
backdrop.push('\n')
}
return (
@@ -402,32 +454,38 @@ export function InputBar({
<div className={css.card} data-composer-card>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
glyphs to the backdrop, so they can only stay together by moving together: one scroll
offset the browser applies to both layers at once, never a JS mirror between two boxes,
which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
<div ref={scrollRef} className={css.scroll} data-input-scroll>
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={(e) => { onCopyOrCut(e, false) }}
onCut={(e) => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
</div>
</div>
<div className={css.row}>
<div className={css.tools}>

View File

@@ -4,7 +4,7 @@
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,6 +18,18 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
// is absent — and the composer measures the caret with one when it restores the
// selection after an edit it performed itself. Every case here runs against a
// zero rect; the reveal case below substitutes its own and restores this one.
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
Range.prototype.getBoundingClientRect = ZERO_RECT
// Read through the descriptor so the native method is never referenced unbound;
// the reveal case below wraps it to record what it was asked to measure.
const NATIVE_SET_START = Object.getOwnPropertyDescriptor(Range.prototype, 'setStart')!
.value as (this: Range, node: Node, offset: number) => void
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
@@ -321,7 +333,7 @@ describe('running and lock semantics (queue cut 1)', () => {
expect((textarea).value).toBe('typed')
})
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
@@ -337,17 +349,18 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
const { view, textarea } = bench()
host.appendChild(view.container)
document.body.appendChild(host)
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
let scrollTop = 150
Object.defineProperty(textarea, 'scrollTop', {
Object.defineProperty(scrollport, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value },
@@ -371,35 +384,151 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
it('the caret layer and the glyph layer ride one scrollport', () => {
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Every later move tracks too, including back to the top — a one-shot
// mirror would leave the glyphs parked at the first offset it saw.
textarea.scrollTop = 0
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(0)
// The caret is the textarea's and every visible glyph is the backdrop's, so
// one box has to carry both or an offset can exist in one and not the other.
// jsdom has no layout and loads no stylesheet — which box scrolls is the
// browser scenario's to assert; what is checkable here is that the
// scrollport element holds both layers.
expect(scroll.contains(textarea)).toBe(true)
expect(scroll.contains(backdrop)).toBe(true)
// The glyph layer carries the draft and nothing else: with one scrollport
// it no longer pads its own height to match a second box's scroll extent.
expect(backdrop.textContent).toBe('line\n'.repeat(40))
})
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
// (the browser scenario owns that); what is checkable is that the backdrop's
// text is the draft plus exactly one newline. A textarea reserves a line box
// after a final newline and `pre-wrap` collapses one, so without the
// sentinel a draft ending in a newline leaves the backdrop a line short and
// the mirrored offset clamps.
const withNewline = bench({ draft: 'alpha\nbeta\n' })
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
const withoutNewline = bench({ draft: 'alpha\nbeta' })
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
expect(plain.textContent).toBe('alpha\nbeta\n')
it('an edit the composer performs itself scrolls the caret back into view', async () => {
// Paste and cut suppress the native edit, so no engine reveals the caret
// for them. jsdom has no layout: the rects are stubbed,
// and what is asserted is the arithmetic — minimal scroll, in both
// directions, and nothing at all for a caret already inside the box.
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
expect(mirror.firstChild).toBeInstanceOf(Text)
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
// jsdom reports scrollHeight === clientHeight for every element, which is
// the composer's own "nothing to reveal" case; a scrollable box is what
// puts the reveal on the table at all.
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
onTestFinished(() => {
Range.prototype.getBoundingClientRect = ZERO_RECT
Range.prototype.setStart = NATIVE_SET_START
})
// Which layer the caret is measured against, and at which index: the stub
// records `setStart` so a helper that measured the backdrop instead, or
// always collapsed at 0, fails here rather than only in the browser lane.
let measured: { node: Node; offset: number } | null = null
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
measured = { node, offset }
NATIVE_SET_START.call(this, node, offset)
}
const caretAt = (top: number): void => {
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
}
const settle = async (): Promise<void> => {
await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
}
// Pasted text lands below the fold: scroll down by exactly the overshoot.
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
await settle()
expect(scroll.scrollTop).toBe(88) // 524 - 436
// Measured on the mirror's own text, at the index the paste left the caret
// (an empty draft's selection start, 0, plus the pasted length).
expect(measured!.node).toBe(mirror.firstChild)
expect(measured!.offset).toBe('pasted'.length)
// A caret already inside the box does not move it.
caretAt(200)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
await settle()
expect(scroll.scrollTop).toBe(88)
// Above the fold (a cut can leave it there): scroll back up.
caretAt(60)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
await settle()
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
// A caret straight after a newline has nothing on its line to measure, so
// the newline it just left is measured instead and one line is added.
// chromium reports no client rects at all for the collapsed position.
mirror.style.lineHeight = '24px'
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } })
await settle()
// The four pastes accumulate at the draft's head, so the caret is at the
// end of what they inserted — and the measured index is the newline before it.
expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1)
expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436
})
it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => {
// The composer DOM is reused across sessions, so the previous session's
// offset survives while the value swap puts the caret at the new draft's
// end. `preventScroll` keeps the browser from revealing it through the
// conversation scrollport, which leaves the reveal to the effect itself.
const { view, textarea, props } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
// The draft ends in a newline, so the reveal takes the after-newline path
// and needs a resolvable line-height (jsdom computes `normal`).
mirror.style.lineHeight = '24px'
// Which index the effect reveals at, not merely that it scrolled: a
// revealCaret(0) would land the same offset without this.
onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START })
let measured: { node: Node; offset: number } | null = null
Range.prototype.setStart = function setStart(node: Node, offset: number): void {
measured = { node, offset }
NATIVE_SET_START.call(this, node, offset)
}
const focused: (boolean | undefined)[] = []
textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) }
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
expect(focused).toEqual([true])
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
// The draft ends in a newline, so the rule measures that newline: the
// caret's own index is the mirror text's length minus its sentinel.
expect(measured!.node).toBe(mirror.firstChild)
expect(measured!.offset).toBe(textarea.value.length - 1)
})
it('a persisted draft adopted after mount gets its caret revealed too', () => {
// ConversationSession seeds the stored draft in its own mount effect, which
// runs after this component's: the first reveal measures an empty mirror,
// so the draft's arrival has to run it again without reclaiming focus.
const { view, textarea, shell } = bench()
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
// The restored draft ends in a newline, so the reveal takes the
// after-newline path and needs a resolvable line-height (jsdom says `normal`).
mirror.style.lineHeight = '24px'
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
Object.defineProperty(scroll, 'clientHeight', { value: 336, configurable: true })
Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true })
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect
const other = document.createElement('input')
document.body.appendChild(other)
onTestFinished(() => { other.remove() })
other.focus()
expect(scroll.scrollTop).toBe(0)
act(() => { shell.setDraft('restored\n'.repeat(40)) })
expect(document.activeElement).toBe(other)
// The caret the machine left at the draft's end, revealed once the draft exists.
expect(textarea.selectionStart).toBe(textarea.value.length)
expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {

View File

@@ -7,10 +7,11 @@
mid-slide. */
.root {
--dsh-sidebar-inline-padding: 12px;
display: flex;
flex-direction: column;
height: 100%;
padding: 6px 12px;
padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
@@ -189,16 +190,22 @@
max-width: 0;
}
/* Region seat: always mounted so the foot never moves; the browser inside
handles its own wide/rail content. */
/* Region seat: always mounted so the foot never moves. Its trailing margin
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
overflow: hidden;
}
.collapsed .regionArea {
margin-right: 0;
}
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
trigger row inside owns its own geometry (49px wide row / 36px rail
circle) and hover chrome. */

View File

@@ -0,0 +1,38 @@
/** Sidebar shell inset contract shared with the nested workspace browser. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one exact selector, keyed by property.
* @param selector - exact selector text.
* @returns the normalized declarations, or undefined when absent.
*/
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return undefined
}
describe('SidebarRoot.module.css inset', () => {
it('shares and cancels the wide shell trailing padding structurally', () => {
const root = declarations('.root')
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)')
expect(declarations('.regionArea')?.get('margin-right')).toBe(
'calc(-1 * var(--dsh-sidebar-inline-padding))',
)
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
})
})

View File

@@ -4,10 +4,19 @@
rail state renders only the two 36x36 icon controls. */
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 8px;
--dsh-session-list-scrollbar-offset: 2px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
padding-right: 0;
}
.iconButton {
@@ -167,9 +176,14 @@
min-height: 0;
display: flex;
flex-direction: column;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
overflow: hidden;
}
.rail .listArea {
margin-right: 0;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
@@ -184,7 +198,7 @@
.fade {
position: absolute;
left: 0;
right: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
@@ -200,29 +214,28 @@
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
/* List: the only scrolling region. Block children keep their design heights
under content overflow. The 2px edge offset, stable 8px themed scrollbar,
and remaining padding equal the shell's right inset, with or without
overflow, so moving the bar does not move the rows. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-right: var(--dsh-session-list-scrollbar-offset);
padding-right: calc(
var(--dsh-session-list-edge-inset)
- var(--dsh-session-list-scrollbar-width)
- var(--dsh-session-list-scrollbar-offset)
);
padding-bottom: 12px;
/* Row trailing content (the relative time, and the hover action buttons
that replace it) sits flush against the row's 8px right padding, so an
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
the rows instead of on top of them; `stable` holds the reservation when
the list is short enough not to scroll, so expanding a group does not
shift every row left. */
scrollbar-gutter: stable;
}
.list > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
}
.searchTree > [role='treeitem'] + [role='treeitem'] {
margin-top: 4px;
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;
}
.searchStatus,
@@ -237,22 +250,11 @@
color: var(--dsw-alias-label-secondary);
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
/* One workspace section: header row + a compact expanded session run. */
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);

View File

@@ -238,7 +238,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
<div className={clsx(css.list, css.flatList)} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div>
)}

View File

@@ -1,8 +1,7 @@
/**
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
* on disk: the session list reserves its scrollbar gutter so the scrollbar
* cannot overlay row trailing content, and reserves it whether or not the list
* currently overflows so expanding a group does not shift rows sideways.
* WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
* row fills share the shell's trailing inset, the stable scrollbar counts
* inside it, and flat, grouped, and search views keep their intended rhythm.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
@@ -11,38 +10,62 @@ import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
/**
* Declarations of one class rule, keyed by property with whitespace collapsed.
* Declarations of one selector rule, keyed by property with whitespace collapsed.
* Declaration order and trailing semicolons are normalized away.
* @param className - local class name, without the leading dot.
* @param selector - one exact selector, including a leading dot for local classes.
* @returns the rule's declarations, or undefined when no such rule exists.
*/
function declarations(className: string): Map<string, string> | undefined {
function declarations(selector: string): Map<string, string> | undefined {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
if (match === null) return undefined
const found = new Map<string, string>()
// The body group is unconditional in the pattern; the fallback only satisfies
// noUncheckedIndexedAccess.
for (const part of (match[2] ?? '').split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
const found = new Map<string, string>()
for (const part of body.split(';')) {
const colon = part.indexOf(':')
if (colon === -1) continue
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
}
return found
}
return found
return undefined
}
describe('WorkspaceBrowser.module.css list', () => {
const list = declarations('list')
const root = declarations('.root')
const listArea = declarations('.listArea')
const list = declarations('.list')
it('is the scrolling region', () => {
expect(list).toBeDefined()
expect(list!.get('overflow-y')).toBe('auto')
})
it('reserves the scrollbar gutter unconditionally', () => {
// Row trailing content sits flush against the row's right padding, so an
// overlay scrollbar covers it. `stable` keeps the reservation when the list
// is short enough not to scroll, so expanding a group does not shift rows.
it('counts the themed scrollbar inside the shell trailing inset', () => {
expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px')
expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)')
expect(list?.get('padding-right')).toBe([
'calc(',
'var(--dsh-session-list-edge-inset)',
'- var(--dsh-session-list-scrollbar-width)',
'- var(--dsh-session-list-scrollbar-offset)',
')',
].join(' '))
expect(declarations('.list::-webkit-scrollbar')).toBeUndefined()
})
it('reserves the scrollbar whether or not the list overflows', () => {
expect(list!.get('scrollbar-gutter')).toBe('stable')
})
it('keeps 2px between rows and 4px between workspace groups', () => {
expect(declarations('.flatList > * + *')?.get('margin-top')).toBe('2px')
expect(declarations(".searchTree > [role='treeitem'] + [role='treeitem']")?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
})
})