Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # apps/web/tests/snapshots/queue-actions/preserved.expected.md # packages/client/runtime/src/client/sessions/session.ts
This commit is contained in:
@@ -317,6 +317,8 @@ export interface ConversationSnapshot {
|
||||
sessionId: SessionId
|
||||
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
|
||||
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
/** In-window completed turn number -> its `turn/end` event seq. */
|
||||
turnEnds: ReadonlyMap<number, number>
|
||||
partial: PartialAssistant | null
|
||||
|
||||
@@ -115,6 +115,11 @@ export class Session implements SessionFace {
|
||||
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
|
||||
private derivedRev = 0
|
||||
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Exact turn timing retained from the raw window so presentation never
|
||||
* infers elapsed time from transcript content. */
|
||||
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
|
||||
private turnTimingsRev = 0
|
||||
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
|
||||
/** Completed turn boundaries retained from the raw window so presentation
|
||||
* actions never infer a safe fork point from transcript content alone. */
|
||||
private turnEnds = new Map<number, number>()
|
||||
@@ -803,6 +808,8 @@ export class Session implements SessionFace {
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
this.lastStepByTurn.set(event.data.turn, 0)
|
||||
this.turnTimings.set(event.data.turn, { startTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
return
|
||||
case 'step/start':
|
||||
this.lastStepByTurn.set(event.data.turn, event.data.step)
|
||||
@@ -837,6 +844,11 @@ export class Session implements SessionFace {
|
||||
}
|
||||
case 'turn/end': {
|
||||
const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0
|
||||
const timing = this.turnTimings.get(event.data.turn)
|
||||
if (timing !== undefined) {
|
||||
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
|
||||
this.turnTimingsRev++
|
||||
}
|
||||
this.turnEnds.set(event.data.turn, event.seq)
|
||||
this.turnEndsRev++
|
||||
if (event.data.reason.kind === 'aborted') {
|
||||
@@ -932,6 +944,8 @@ export class Session implements SessionFace {
|
||||
this.callsRev++
|
||||
this.derivedNodes = []
|
||||
this.derivedRev++
|
||||
this.turnTimings = new Map()
|
||||
this.turnTimingsRev++
|
||||
this.turnEnds = new Map()
|
||||
this.turnEndsRev++
|
||||
this.codeDispatches = new Map()
|
||||
@@ -965,6 +979,9 @@ export class Session implements SessionFace {
|
||||
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
|
||||
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
|
||||
}
|
||||
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
|
||||
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
|
||||
}
|
||||
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
|
||||
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
|
||||
}
|
||||
@@ -981,6 +998,7 @@ export class Session implements SessionFace {
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
nodes,
|
||||
turnTimings: this.turnTimingsCache.value,
|
||||
turnEnds: this.turnEndsCache.value,
|
||||
partial,
|
||||
runningCalls: this.callsCache.value,
|
||||
|
||||
@@ -46,6 +46,11 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.turnTimings.get(3)).toEqual({
|
||||
startTime: 1_700_000_000_010,
|
||||
endTime: 1_700_000_000_015,
|
||||
})
|
||||
expect(snapshot.turnEnds.get(3)).toBe(15)
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -241,11 +246,21 @@ describe('live event path', () => {
|
||||
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
|
||||
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
|
||||
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
|
||||
const retryStart = retryTurn.find(event => event.type === 'turn/start')
|
||||
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start')
|
||||
const retryEnd = retryTurn.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
|
||||
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
|
||||
expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({
|
||||
startTime: retryStart.time,
|
||||
endTime: retryEnd.time,
|
||||
})
|
||||
|
||||
const replay = makeSession()
|
||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
|
||||
await replay.session.open()
|
||||
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
|
||||
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
|
||||
expect(replay.session.getSnapshot().partial).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1258,6 +1273,7 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.turnTimings).toBe(before.turnTimings)
|
||||
expect(after.turnEnds).toBe(before.turnEnds)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
|
||||
|
||||
@@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
return {
|
||||
sessionId,
|
||||
nodes: [],
|
||||
turnTimings: new Map(),
|
||||
turnEnds: new Map(),
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -182,7 +182,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} />)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -67,7 +67,7 @@ function snapshotWith(
|
||||
runningCalls: RunningToolCall[] = [],
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
|
||||
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -36,7 +37,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -241,6 +242,25 @@ describe('chat-flow derivation', () => {
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000 }],
|
||||
]))).toBe(6_000)
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000, endTime: 9_000 }],
|
||||
]))).toBeNull()
|
||||
})
|
||||
|
||||
it('formatRunDuration localizes units and floors partial seconds', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
expect(formatRunDuration(0, t)).toBe('0秒')
|
||||
expect(formatRunDuration(-500, t)).toBe('0秒')
|
||||
expect(formatRunDuration(15_999, t)).toBe('15秒')
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
@@ -498,6 +518,42 @@ describe('ChatView', () => {
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
||||
})
|
||||
|
||||
it('the actions-owning assistant footer shows the turn run time', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'), // time 1_000
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(16, 'final answer'),
|
||||
toolResult(18, 'trailing'),
|
||||
],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The exact turn/end includes trailing tool activity after the final text.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('the run-time label is withheld when the turn start is outside the window', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [assistant(16, 'tail without trigger')],
|
||||
turnEnds: new Map([[1, 16]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/用时/)).toBeNull()
|
||||
})
|
||||
|
||||
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
||||
@@ -658,6 +714,30 @@ describe('ChatView', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
||||
const startTime = Date.now() - 125_000
|
||||
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
||||
const h = makeHarness({
|
||||
nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Freshly mounted (as after a reload) yet already past the 15s gate.
|
||||
const status = view.getByRole('status')
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
|
||||
act(() => {
|
||||
h.set({ queue: [{
|
||||
id: 'steering-occurrence' as never,
|
||||
messageId: 'steering-message' as never,
|
||||
placement: 'steering',
|
||||
content: [{ type: 'text', text: 'also' }],
|
||||
preview: 'also',
|
||||
text: 'also',
|
||||
}] })
|
||||
})
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -477,7 +477,7 @@ describe('DetailsPanel Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('DetailsPanel web Output section', () => {
|
||||
|
||||
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
|
||||
README.md: 19c2d1033de4475816249aa8429f4a589eeb6481
|
||||
README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2
|
||||
README.md: 5bb697b3d2f9b5eaea9c382765d2510fa24806ce
|
||||
README.zh.md: 302f66c540774b1f209fc797201e41c56b849310
|
||||
|
||||
@@ -8,6 +8,8 @@ New Session starts the runtime's page-local frontend Session Intent; a real Work
|
||||
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
|
||||
|
||||
Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows.
|
||||
|
||||
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).
|
||||
|
||||
@@ -8,6 +8,8 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work
|
||||
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
|
||||
|
||||
栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。
|
||||
|
||||
页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。
|
||||
|
||||
`/client` 导出表层只包含插件主体(`apply`/`inject`)及契约类型:SidebarRoot、行组件和树派生均属于内部实现(slot 注册通过闭包引用它们;测试直接导入 src 路径)。
|
||||
|
||||
@@ -25,6 +25,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
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<div
|
||||
class="root collapsed railIn"
|
||||
class="root collapsed railIn quietBars"
|
||||
style=""
|
||||
>
|
||||
<div
|
||||
@@ -65,7 +65,7 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<div
|
||||
class="root"
|
||||
class="root quietBars"
|
||||
style="width: 300px;"
|
||||
>
|
||||
<div
|
||||
@@ -135,7 +135,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<div
|
||||
class="root"
|
||||
class="root quietBars"
|
||||
style="width: 300px;"
|
||||
>
|
||||
<div
|
||||
|
||||
157
packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx
Normal file
157
packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Pointer-revealed scrollbars, the shell's half: which class state the column
|
||||
* carries as the pointer crosses it. The stylesheet rule that state drives is
|
||||
* asserted in scrollbar-quiet-styles.spec.ts (node environment — a jsdom spec
|
||||
* has no file: module URL to read the sheet through).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
/** Pinned column box; the shell compares pointer coordinates against it. */
|
||||
const COLUMN_WIDTH = 280
|
||||
const COLUMN_HEIGHT = 600
|
||||
|
||||
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
|
||||
/** The shell never reads the global hooks; the props share carries them regardless. */
|
||||
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Render the shell and expose its column element.
|
||||
* @returns the column element and whether it currently carries the quiet state.
|
||||
*/
|
||||
function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
|
||||
const view = render(
|
||||
<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
|
||||
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
|
||||
<div data-testid="region" data-wide={owner.wide} />) as SidebarRootComponentProps['renderSlot']}
|
||||
/>,
|
||||
)
|
||||
const column = view.container.firstElementChild
|
||||
if (!(column instanceof HTMLElement)) throw new Error('sidebar column not rendered')
|
||||
// jsdom lays nothing out, and the leave decision is geometric: pin the box
|
||||
// the shell reads so a coordinate can be inside or outside it.
|
||||
Object.defineProperty(column, 'getBoundingClientRect', {
|
||||
value: () => ({
|
||||
left: 0, top: 0, right: COLUMN_WIDTH, bottom: COLUMN_HEIGHT,
|
||||
x: 0, y: 0, width: COLUMN_WIDTH, height: COLUMN_HEIGHT, toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
// CSS-module locals are hashed in this bench, so the state is read as a
|
||||
// substring of the class list rather than as an exact local name.
|
||||
return { column, quiet: () => [...column.classList].some(name => name.includes('quietBars')) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross the pointer into or out of the column. React synthesizes
|
||||
* `pointerenter`/`pointerleave` from `pointerover`/`pointerout`, so the raw
|
||||
* enter and leave events it does not listen to would assert nothing.
|
||||
* @param column - the sidebar column element.
|
||||
* @param direction - `in` to enter the column, `out` to leave it.
|
||||
*/
|
||||
function movePointer(column: HTMLElement, direction: 'in' | 'out'): void {
|
||||
const outside = document.body
|
||||
if (direction === 'in') fireEvent.pointerOver(column, { relatedTarget: outside })
|
||||
else fireEvent.pointerOut(column, { relatedTarget: outside })
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the pointer over the document, as a pointer crossing a fixed overlay
|
||||
* that is a DOM descendant of the column does.
|
||||
* @param x - client x coordinate.
|
||||
* @param y - client y coordinate.
|
||||
*/
|
||||
function movePointerOverDocument(x: number, y: number): void {
|
||||
fireEvent.pointerMove(document, { clientX: x, clientY: y })
|
||||
}
|
||||
|
||||
describe('SidebarRoot pointer-revealed scrollbars', () => {
|
||||
it('draws them only while the pointer is inside, and lingers on the way out', () => {
|
||||
vi.useFakeTimers()
|
||||
const { column, quiet } = mountColumn()
|
||||
// At rest — the pointer has never been over the column — the bars are off.
|
||||
expect(quiet()).toBe(true)
|
||||
movePointer(column, 'in')
|
||||
expect(quiet()).toBe(false)
|
||||
movePointer(column, 'out')
|
||||
// The linger: still drawn just before the window closes, gone just after.
|
||||
act(() => { vi.advanceTimersByTime(1999) })
|
||||
expect(quiet()).toBe(false)
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(quiet()).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels a pending hide when the pointer comes back', () => {
|
||||
vi.useFakeTimers()
|
||||
const { column, quiet } = mountColumn()
|
||||
movePointer(column, 'in')
|
||||
movePointer(column, 'out')
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
movePointer(column, 'in')
|
||||
// The first leave's timer would fire here; a cancelled one leaves the bars
|
||||
// drawn, which is what keeps a pointer skirting the edge from blinking them.
|
||||
act(() => { vi.advanceTimersByTime(5000) })
|
||||
expect(quiet()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when the pointer moves outside the column box without leaving its subtree', () => {
|
||||
// ui-settings renders its full-viewport panel as a fixed-position
|
||||
// DESCENDANT of the column, so DOM containment reports the pointer as
|
||||
// still inside while it is visually somewhere else entirely.
|
||||
vi.useFakeTimers()
|
||||
const { column, quiet } = mountColumn()
|
||||
movePointer(column, 'in')
|
||||
expect(quiet()).toBe(false)
|
||||
movePointerOverDocument(COLUMN_WIDTH + 400, 300)
|
||||
act(() => { vi.advanceTimersByTime(2000) })
|
||||
expect(quiet()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restart the window when the pointer keeps moving outside', () => {
|
||||
vi.useFakeTimers()
|
||||
const { column, quiet } = mountColumn()
|
||||
movePointer(column, 'in')
|
||||
movePointer(column, 'out')
|
||||
act(() => { vi.advanceTimersByTime(1500) })
|
||||
// A pending hide is left alone rather than re-armed: otherwise a pointer
|
||||
// resting outside the column would keep pushing the bars' disappearance
|
||||
// out, one move at a time.
|
||||
movePointerOverDocument(COLUMN_WIDTH + 400, 300)
|
||||
act(() => { vi.advanceTimersByTime(600) })
|
||||
expect(quiet()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps them drawn while the pointer moves inside the column box', () => {
|
||||
vi.useFakeTimers()
|
||||
const { column, quiet } = mountColumn()
|
||||
movePointer(column, 'in')
|
||||
movePointer(column, 'out')
|
||||
// A move landing back inside the box cancels the pending hide, the same
|
||||
// way re-entering the element does.
|
||||
movePointerOverDocument(COLUMN_WIDTH - 10, 300)
|
||||
act(() => { vi.advanceTimersByTime(5000) })
|
||||
expect(quiet()).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the pending hide when the column unmounts', () => {
|
||||
vi.useFakeTimers()
|
||||
const { column } = mountColumn()
|
||||
movePointer(column, 'in')
|
||||
movePointer(column, 'out')
|
||||
cleanup()
|
||||
// A timer surviving the unmount would call setState on a dead component.
|
||||
expect(() => { vi.advanceTimersByTime(5000) }).not.toThrow()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The quiet-column rule as CSS text: the state SidebarRoot toggles
|
||||
* (pointer-scrollbars.spec.tsx) hides a scrollbar only through this rule, and
|
||||
* ui-theme's gate checks the rebinding contract's shape without knowing which
|
||||
* sheet states which half.
|
||||
*/
|
||||
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 only: the sheet's prose names the properties it explains. */
|
||||
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
describe('SidebarRoot.module.css quiet column', () => {
|
||||
it('rebinds the ui-theme indirection pair to transparent', () => {
|
||||
// The pair, not the resting thumb alone: rebinding one leaves the other
|
||||
// painting its base-surface colour the moment the pointer reaches the bar.
|
||||
const rule = /\.root\.quietBars\s*\{([^{}]*)\}/.exec(declarationText)
|
||||
expect(rule).not.toBeNull()
|
||||
const declarations = (rule![1] ?? '').split(';').map(part => part.trim()).filter(Boolean).sort()
|
||||
expect(declarations).toEqual([
|
||||
'--dsh-scrollbar-thumb-hover: transparent',
|
||||
'--dsh-scrollbar-thumb: transparent',
|
||||
].sort())
|
||||
})
|
||||
|
||||
it('leaves the gutter reservation to the scrolling region', () => {
|
||||
// Hiding the thumb must not move a row: the reservation lives on the list
|
||||
// (ui-workspace), so the column states colour only.
|
||||
expect(declarationText).not.toMatch(/scrollbar-gutter/)
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: a1ff7d840dae86f5da98de1208ecda3b8b62026b
|
||||
README.zh.md: 2e034f3173baabb3eea6b4ae670d5070c95480be
|
||||
README.md: 88e21fe214ec806b101050949690283d811be36d
|
||||
README.zh.md: 4ed45070234acb78a2e5edef52578b504ae53077
|
||||
|
||||
@@ -6,7 +6,7 @@ Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took.
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.
|
||||
|
||||
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
滚动条重新绑定契约:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。
|
||||
滚动条重新绑定契约:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量另一个合法的目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。
|
||||
|
||||
两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。推理过程与实测计算值见[滚动条 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ const DARK_ATTRIBUTE = '[data-ds-dark-theme]'
|
||||
const TOKEN_PREFIX = '--dsw-alias-scrollbar-'
|
||||
/** Prefix of the rebindable indirection scrollbar.css owns. */
|
||||
const INDIRECTION_PREFIX = '--dsh-scrollbar-'
|
||||
/** The one non-token rebind value: a surface that draws no thumb at all. */
|
||||
const HIDDEN_THUMB = 'transparent'
|
||||
/** The elevation rebind, spelled per property: value-wholeness, not token shape. */
|
||||
const ELEVATED_REBIND = new Map([
|
||||
['--dsh-scrollbar-thumb', '--dsw-alias-scrollbar-bg-l2'],
|
||||
['--dsh-scrollbar-thumb-hover', '--dsw-alias-scrollbar-hover-l2'],
|
||||
].map(([property, token]) => [property!, `var(${token!})`]))
|
||||
|
||||
/**
|
||||
* Flatten a stylesheet into rules. Whitespace, declaration order, and trailing
|
||||
@@ -179,8 +186,13 @@ interface SheetSurfaces {
|
||||
elevated: Set<string>
|
||||
/** True when some rule declares `overflow*: auto|scroll`. */
|
||||
scrolls: boolean
|
||||
/** True when some rule rebinds the indirection. */
|
||||
rebinds: boolean
|
||||
/**
|
||||
* True when some rule rebinds the indirection to an ELEVATION. A rule that
|
||||
* only hides the bar (`transparent`) does not count: it states no elevation,
|
||||
* so a sheet that hides its bars and also scrolls on an elevated surface
|
||||
* still owes the l2 pair for whatever draws a thumb there.
|
||||
*/
|
||||
rebindsElevation: boolean
|
||||
}
|
||||
const sheetSurfaces = new Map<string, SheetSurfaces>()
|
||||
|
||||
@@ -238,12 +250,16 @@ const elevatedSurfaces = elevatedRungs()
|
||||
|
||||
for (const file of packageStylesheets()) {
|
||||
const rules = parseRules(readFileSync(file, 'utf8'))
|
||||
const surfaces: SheetSurfaces = { elevated: new Set(), scrolls: false, rebinds: false }
|
||||
const surfaces: SheetSurfaces = { elevated: new Set(), scrolls: false, rebindsElevation: false }
|
||||
for (const rule of rules) {
|
||||
let rebinds = false
|
||||
let rebindsElevation = false
|
||||
const ruleSurfaces: string[] = []
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) rebinds = true
|
||||
if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) {
|
||||
rebinds = true
|
||||
if (value !== HIDDEN_THUMB) rebindsElevation = true
|
||||
}
|
||||
if (OVERFLOW_PROPERTIES.includes(property) && /\b(?:auto|scroll)\b/.test(value)) surfaces.scrolls = true
|
||||
if (SURFACE_PROPERTIES.includes(property)) ruleSurfaces.push(...varReferences(value))
|
||||
for (const token of varReferences(value)) {
|
||||
@@ -254,10 +270,8 @@ for (const file of packageStylesheets()) {
|
||||
for (const token of ruleSurfaces) {
|
||||
if (elevatedSurfaces.has(token)) surfaces.elevated.add(token)
|
||||
}
|
||||
if (rebinds) {
|
||||
rebindRules.push({ file, rule })
|
||||
surfaces.rebinds = true
|
||||
}
|
||||
if (rebinds) rebindRules.push({ file, rule })
|
||||
if (rebindsElevation) surfaces.rebindsElevation = true
|
||||
}
|
||||
sheetSurfaces.set(file, surfaces)
|
||||
}
|
||||
@@ -452,13 +466,25 @@ describe('elevated surface rebinds', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('every rebind targets the l2 elevation pair', () => {
|
||||
it('rebinds the pair to one target: the l2 elevation pair, or transparent', () => {
|
||||
// The rule as a whole, not each declaration on its own. Per-declaration
|
||||
// checking accepts a MIXED rule — `thumb: transparent` beside
|
||||
// `thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` — which repaints the
|
||||
// bar the moment the pointer reaches it while passing a gate that claims
|
||||
// the two targets are exclusive.
|
||||
//
|
||||
// The elevation half compares the whole value against the pair's canonical
|
||||
// spelling rather than checking that every token it mentions ends in `-l2`.
|
||||
// A shape check admits `color-mix(…, var(--dsw-alias-scrollbar-bg-l2) 85%,
|
||||
// white)` and a crossed pair (the hover token bound to the resting
|
||||
// property); neither is what the contract says.
|
||||
for (const { file, rule } of rebindRules) {
|
||||
for (const [property, value] of rule.declarations) {
|
||||
if (!property.startsWith(INDIRECTION_PREFIX)) continue
|
||||
for (const token of varReferences(value)) {
|
||||
expect(token, `${file}: ${property}`).toMatch(/-l2$/)
|
||||
}
|
||||
const rebinds = rule.declarations.filter(([property]) => property.startsWith(INDIRECTION_PREFIX))
|
||||
const where = `${file} ${rule.selectors.join(', ')}`
|
||||
if (rebinds.every(([, value]) => value === HIDDEN_THUMB)) continue
|
||||
expect(rebinds.some(([, value]) => value === HIDDEN_THUMB), `${where}: mixes ${HIDDEN_THUMB} with an elevation`).toBe(false)
|
||||
for (const [property, value] of rebinds) {
|
||||
expect(value, `${where}: ${property}`).toBe(ELEVATED_REBIND.get(property))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -499,7 +525,7 @@ describe('elevated surface rebinds', () => {
|
||||
// (ChatView's `.toBottom`, CodeBlock's banner). Geometry cannot make that
|
||||
// call — a floating button carries a radius, a shadow, and a fixed size.
|
||||
for (const [file, surfaces] of sheetSurfaces) {
|
||||
if (!surfaces.scrolls || surfaces.rebinds) continue
|
||||
if (!surfaces.scrolls || surfaces.rebindsElevation) continue
|
||||
expect([...surfaces.elevated], `${file} scrolls on an elevated surface without rebinding`).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user