Merge remote-tracking branch 'origin/master' into worktree/composer-scrollbar-gutter

This commit is contained in:
creatixchu
2026-08-04 16:20:33 +08:00
59 changed files with 1077 additions and 489 deletions

View File

@@ -27,6 +27,9 @@ export interface AssistantMarkdownProps {
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
* the parent withholds chrome (mid-turn content assistants). */
time?: number | undefined
/** Turn wall time in ms for the IconActions run-time label; omitted when the
* turn's triggering input is outside the loaded window. */
runMs?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
@@ -79,7 +82,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -95,7 +98,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
// Footer only under settled content text; Think-only / streaming omit it.
const showActions = !streaming && time !== undefined && hasContentText(blocks)
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root>
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
@@ -121,6 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
<MessageIconActions
text={copyText(blocks)}
time={time}
runMs={runMs}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}

View File

@@ -99,6 +99,15 @@
animation: dsh-turn-status-shimmer 1.8s linear infinite;
}
.turnStatusClock {
margin-left: 8px;
font: var(--dsw-font-xs-13);
font-weight: 400;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-caption);
-webkit-text-fill-color: var(--dsw-alias-label-caption);
}
@keyframes dsh-turn-status-shimmer {
to {
background-position: 0 0;

View File

@@ -30,11 +30,12 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -282,10 +283,37 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
})
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus() {
function TurnStatus({ startTime, t }: {
/** The running turn's logged `turn/start` time; null falls back to mount
* time when that boundary is outside the window. */
startTime: number | null
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}) {
const [mountedAt] = useState(() => Date.now())
// Anchored to turn/start so a mid-turn reload keeps the real
// elapsed time and the final footer's Ran-for label matches this clock.
const anchor = startTime ?? mountedAt
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - anchor))
useEffect(() => {
const tick = (): void => {
setElapsedMs(Math.max(0, Date.now() - anchor))
}
tick()
const id = setInterval(tick, 1000)
return () => { clearInterval(id) }
}, [anchor])
// Short turns keep the plain label; the clock only appears once the turn
// has clearly been running for a while.
const showClock = elapsedMs >= 15_000
return (
<div className={css.turnStatus} role="status" aria-live="polite">
Deep diving...
{showClock && (
<span className={css.turnStatusClock} aria-hidden>
{formatRunDuration(elapsedMs, t)}
</span>
)}
</div>
)
}
@@ -309,6 +337,7 @@ export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
const turnEnds = useSession(s => s.turnEnds)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
@@ -332,6 +361,7 @@ export function ChatView({
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
@@ -568,12 +598,16 @@ export function ChatView({
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
return (
<AssistantMarkdown
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={actionSeqs.has(node.seq) ? node.time : undefined}
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
@@ -651,7 +685,7 @@ export function ChatView({
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnStatus />}
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
{pendingSteering.map(item => (
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
))}

View File

@@ -1,5 +1,6 @@
/* Shared message IconActions row (user + assistant). Parent modules own
layout offsets via the composed className. Always visible when mounted. */
layout offsets via the composed className. Icons stay visible when mounted;
the time label is hover-revealed inside a data-time-hover-root scope. */
.actions {
display: flex;
@@ -25,6 +26,26 @@
white-space: nowrap;
}
/* Separator between the clock and the run-time label (time · Ran for 15s). */
.runTimeDot {
margin: 0 10px;
}
/* Message containers opt in with data-time-hover-root: the time label fades
in on message hover (or keyboard focus within). Opacity keeps the layout
stable, and devices without hover keep the label always visible. */
@media (hover: hover) {
[data-time-hover-root] :is(.timeStart, .timeEnd) {
opacity: 0;
transition: opacity 80ms ease;
}
[data-time-hover-root]:hover :is(.timeStart, .timeEnd),
[data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;

View File

@@ -6,7 +6,7 @@ import {
IconBranchOutline16, IconCopyOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { formatMessageClock, formatRunDuration, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -15,6 +15,8 @@ export interface MessageIconActionsProps {
text: string
/** Unix epoch ms for the clock label; omitted for transient messages. */
time?: number | undefined
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
runMs?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
@@ -35,7 +37,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -45,6 +47,12 @@ export function MessageIconActions({
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, t, day)}
{runMs !== undefined && (
<>
<span className={css.runTimeDot} aria-hidden>·</span>
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
</>
)}
</span>
)
return (

View File

@@ -183,7 +183,7 @@ function UserStyleBubble({
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow} data-pending-steering={pending || undefined}>
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}

View File

@@ -9,7 +9,7 @@
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ToolResultNode,
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
@@ -47,6 +47,21 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
return new Set(lastByTurn.values())
}
/**
* Exact start time of the latest in-window turn without a matching end time.
* @param turnTimings - In-window turn timings in event order.
* @returns Unix epoch ms, or null when the running turn started outside the window.
*/
export function runningTurnStartTime(
turnTimings: ConversationSnapshot['turnTimings'],
): number | null {
let latest: number | null = null
for (const timing of turnTimings.values()) {
if (timing.endTime === undefined) latest = timing.startTime
}
return latest
}
/**
* Seq set of message rows that may fork: the last transcript node of a
* completed turn, when that node owns message chrome. A later tool, reasoning,

View File

@@ -6,6 +6,9 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
/** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/** The elapsed-duration share of the conversation dictionary. */
export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
@@ -71,6 +74,21 @@ export function msUntilNextLocalMidnight(ms: number): number {
return Math.max(next.getTime() - ms, 1)
}
/**
* Localized elapsed-time label shared by running and settled turn chrome.
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
* @param t - Translate seat supplying the duration templates.
* @returns Display string in whole seconds.
*/
export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
const total = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(total / 60)
const seconds = total % 60
return minutes > 0
? t('duration.minutes', { minutes, seconds: String(seconds).padStart(2, '0') })
: t('duration.seconds', { seconds })
}
/**
* Compact local timestamp for message IconActions. Same calendar day →
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other

View File

@@ -68,6 +68,9 @@ export const zh = {
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.ranFor': '用时 {duration}',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -176,6 +179,9 @@ export const en = {
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.ranFor': 'Ran for {duration}',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',

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}>