feat(trajectory): implement trajectory step cell and layout with bilingual support

- Added TrajectoryCell, TrajectoryGroupHeader, and TrajectoryTurn components for rendering trajectory steps and groups.
- Introduced bilingual support with English and Chinese translations for trajectory notes.
- Updated conversation session models to include timestamps for various message types.
- Enhanced layout logic to handle expanded assistant blocks and tool results with duration metrics.
- Added CSS styles for new components to ensure proper display and alignment.
This commit is contained in:
07akioni
2026-07-24 13:30:19 +08:00
parent 65d29da8a1
commit 3babb2cd42
29 changed files with 1190 additions and 70 deletions

View File

@@ -0,0 +1,93 @@
/* Trajectory step cell — 38px row: index · kind tag · text · optional message
* metrics · elapsed time. */
.root {
display: flex;
align-items: center;
box-sizing: border-box;
height: 38px;
padding: 0 8px 0 20px;
gap: 24px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-3);
min-width: 0;
}
.selected {
border-color: transparent;
box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.index {
flex: none;
width: 24px;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}
.tagSlot {
flex: none;
width: 80px;
display: flex;
align-items: center;
min-width: 0;
}
.tag {
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: 22px;
max-width: 100%;
padding: 0 4px;
border-radius: 6px;
font: var(--dsw-font-xs-strong-13);
white-space: nowrap;
}
.tagUser {
color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-state-success-tertiary);
}
.tagMessage {
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
background: var(--dsw-specific-bubble);
}
.tagTool {
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-primary);
}
/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */
.trailing {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
width: 320px;
gap: 12px;
min-width: 0;
}
.metric,
.time {
flex: none;
width: 71px;
text-align: left;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}

View File

@@ -0,0 +1,99 @@
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
// ellipsis text, optional Message token metrics, and own-duration time.
import type { HTMLAttributes } from 'react'
import css from './TrajectoryCell.module.css'
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */
export type TrajectoryCellKind = 'user' | 'message' | 'tool'
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
user: 'User',
message: 'Message',
tool: 'Tool',
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
user: css.tagUser!,
message: css.tagMessage!,
tool: css.tagTool!,
}
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based step index shown as `#N`. */
index: number
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
/**
* Own duration in seconds. `null` means no duration to show (em dash) —
* used for in-flight tools and tools missing callTime.
*/
timeSeconds: number | null
/** Message-only: prompt token count. */
input?: number
/** Message-only: completion token count. */
output?: number
/** Message-only: reasoning token count (usage column, not a Think cell). */
think?: number
/** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
selected?: boolean
}
/**
* Format own-duration for the trailing time column: `—` when unknown, `+Ns`
* or `+N.1s` otherwise.
* @param seconds - duration seconds, or null when absent.
* @returns display string.
*/
export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—'
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `+${rounded}s`
return `+${rounded.toFixed(1)}s`
}
/**
* Render one trajectory step cell.
* @param props - index, kind, text, time, and optional Message metrics.
* @returns the cell element.
*/
export function TrajectoryCell({
index,
kind,
text,
timeSeconds,
input,
output,
think,
selected = false,
className,
...rest
}: TrajectoryCellProps) {
const rootClass = [
css.root,
selected ? css.selected : undefined,
className,
].filter((c): c is string => c !== undefined).join(' ')
const showMetrics = kind === 'message'
return (
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
<span className={css.index}>#{index}</span>
<span className={css.tagSlot}>
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
</span>
<span className={css.text}>{text}</span>
<span className={css.trailing}>
{showMetrics ? (
<>
<span className={css.metric}>{input ?? ''}</span>
<span className={css.metric}>{output ?? ''}</span>
<span className={css.metric}>{think ?? ''}</span>
</>
) : null}
<span className={css.time}>{formatElapsedSeconds(timeSeconds)}</span>
</span>
</div>
)
}

View File

@@ -0,0 +1,27 @@
/* Message / Step group title row inside a turn body. */
.root {
display: flex;
align-items: center;
box-sizing: border-box;
height: 36px;
padding: 0 20px;
gap: 24px;
min-width: 0;
}
.title {
flex: none;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-primary);
}
.description {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,26 @@
// TrajectoryGroupHeader: "Message" or "Step N" row with optional description.
import css from './TrajectoryGroupHeader.module.css'
export interface TrajectoryGroupHeaderProps {
/** Group title (`Message`, `Step 1`, …). */
title: string
/** Secondary summary (`49s`, `2.2s skill`, …). */
description?: string
}
/**
* Render a Message/Step group header inside a turn body.
* @param props - title and optional description.
* @returns the group header element.
*/
export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) {
return (
<div className={css.root}>
<span className={css.title}>{title}</span>
{description !== undefined && description !== ''
? <span className={css.description}>{description}</span>
: null}
</div>
)
}

View File

@@ -0,0 +1,16 @@
/* One turn block: sticky header + padded body with 10px item gap. */
.root {
width: 100%;
}
.body {
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
width: 100%;
max-width: 880px;
margin: 0 auto;
padding: 8px 16px 22px;
}

View File

@@ -0,0 +1,26 @@
// TrajectoryTurn: sticky Turn header plus the padded Message/Step body.
import type { ReactNode } from 'react'
import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx'
import css from './TrajectoryTurn.module.css'
export interface TrajectoryTurnProps {
/** 1-based turn index for the sticky header. */
turn: number
/** Message / Step headers and TrajectoryCell rows. */
children?: ReactNode
}
/**
* Render one turn section (sticky header + body).
* @param props - turn index and body children.
* @returns the turn section element.
*/
export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) {
return (
<section className={css.root} data-turn={turn}>
<TrajectoryTurnHeader turn={turn} />
<div className={css.body}>{children}</div>
</section>
)
}

View File

@@ -0,0 +1,48 @@
/* Sticky turn bar: full-bleed ghost-active fill across the panel; title +
* metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */
.root {
position: sticky;
top: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
height: 44px;
background: var(--dsw-alias-button-ghost-active-fill);
}
.inner {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
width: 100%;
max-width: 880px;
height: 100%;
margin: 0 auto;
padding: 0 16px;
}
.title {
flex: none;
font: var(--dsw-font-xs-strong-13);
color: var(--dsw-alias-label-primary);
}
.columns {
flex: none;
display: flex;
align-items: center;
width: 320px;
gap: 12px;
/* Match cell padding-right: 8 so Time lines up with the trailing lane. */
margin-right: 8px;
}
.column {
flex: none;
width: 71px;
text-align: left;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,30 @@
// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels.
import css from './TrajectoryTurnHeader.module.css'
const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const
export interface TrajectoryTurnHeaderProps {
/** 1-based turn index shown as `Turn N`. */
turn: number
}
/**
* Render the sticky turn header row.
* @param props.turn - turn index.
* @returns the sticky header element.
*/
export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
return (
<div className={css.root}>
<div className={css.inner}>
<span className={css.title}>Turn {turn}</span>
<div className={css.columns} aria-hidden="true">
{COLUMN_LABELS.map((label) => (
<span key={label} className={css.column}>{label}</span>
))}
</div>
</div>
</div>
)
}

View File

@@ -1,30 +1,40 @@
// TrajectoryView: P-I placeholder body for the trajectory tab — span stats
// header over a per-turn span list with node-count weights (no timing data
// exists yet; deviation ledger #3 defers real rendering to P-III).
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
import { useMemo } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans } from './spans.ts'
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
import { TrajectoryCell } from './TrajectoryCell.tsx'
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession((s) => s.nodes)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}></p></div>
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
[nodes, partial, runningCalls],
)
if (turns.length === 0) {
return <div className={css.root}><p className={css.empty}></p></div>
}
return (
<>
<TrajectoryStatsHeader useSession={useSession} />
<div className={css.root}>
{spans.map((span) => (
<div key={span.turn} className={css.row}>
<span className={css.turnTag}>turn {span.turn}</span>
<span className={css.meta}>
{span.steps} steps · {span.calls} calls · {span.nodes} nodes
</span>
</div>
))}
</div>
</>
<div className={css.root}>
{turns.map((turn) => (
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
{turn.groups.flatMap((group) => [
<TrajectoryGroupHeader
key={`${group.title}-h`}
title={group.title}
{...(group.description !== undefined ? { description: group.description } : {})}
/>,
...group.cells.map((cell) => (
<TrajectoryCell key={cell.index} {...cell} />
)),
])}
</TrajectoryTurn>
))}
</div>
)
}

View File

@@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation']
/**
* Client plugin body: register the trajectory and waterfall view tabs. The
* registrations ride the slot service's effect wrapper (plugin unload
* removes both tabs); the span stats header renders inside each view body
* (the chrome attachment mechanism retired with the view ring).
* removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps
* the span stats header inside its body (chrome attachment retired).
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {

View File

@@ -0,0 +1,374 @@
/**
* Trajectory list fold: expand assistant blocks, attach usage to Message,
* own-duration times, in-flight partial/runningCalls, and group descriptions.
*/
import type {
AssistantMessageNode,
ConversationSnapshot,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
title: string
description?: string
cells: readonly TrajectoryCellProps[]
}
/** One sticky-turn section. */
export interface TrajectoryTurnModel {
turn: number
groups: readonly TrajectoryGroupModel[]
}
/** Snapshot slice the trajectory view folds. */
export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
}
interface UsageLike {
inputTokens?: number
outputTokens?: number
reasoningTokens?: number
}
/** Cell plus absolute ms for group wall-span descriptions. */
interface LaidCell {
cell: TrajectoryCellProps
absTime: number | null
toolName?: string
callId?: string
}
/**
* Fold a snapshot into turn → Message/Step groups with expanded cells.
* @param input - nodes plus in-flight partial/runningCalls.
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { nodes, partial, runningCalls } = input
const resultByCall = indexResults(nodes)
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
let index = 0
let prevAbsTime: number | null = null
const bucket = (turn: number) => {
let entry = turns.get(turn)
if (entry === undefined) {
entry = { message: [], steps: new Map() }
turns.set(turn, entry)
}
return entry
}
const pushMessage = (turn: number, laid: LaidCell) => {
bucket(turn).message.push(laid)
}
const pushStep = (turn: number, step: number, laid: LaidCell) => {
const steps = bucket(turn).steps
const list = steps.get(step) ?? []
list.push(laid)
steps.set(step, list)
}
for (const node of nodes) {
if (node.kind === 'user' || node.kind === 'steering') {
const turn = node.kind === 'steering' ? node.turn : 0
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content),
timeSeconds: 0,
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'assistant') {
const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
for (const laid of laidList) {
if (node.step > 0) pushStep(node.turn, node.step, laid)
else pushMessage(node.turn, laid)
}
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) {
const toolName = node.call?.name
pushStep(0, 1, {
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
callId: node.callId,
cell: {
index: ++index,
kind: 'tool',
text: node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
timeSeconds: durationSeconds(node.time, node.callTime),
},
})
}
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
}
}
if (partial !== null) {
const fake: AssistantMessageNode = {
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks,
}
const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
for (const laid of laidList) {
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
else pushMessage(partial.turn, laid)
}
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
}
const seenCalls = collectCallIds(turns)
for (const call of runningCalls) {
if (seenCalls.has(call.callId)) continue
pushStep(call.turn, call.step > 0 ? call.step : 1, {
absTime: null,
toolName: call.name,
callId: call.callId,
cell: {
index: ++index,
kind: 'tool',
text: summarizeCall(call.name, call.argsRaw),
timeSeconds: null,
},
})
}
const prologue = turns.get(0)
if (prologue !== undefined) {
turns.delete(0)
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
message: [],
steps: new Map(),
})
const first = turns.get(1) ?? emptyTurn()
first.message = [...prologue.message, ...first.message]
for (const [step, cells] of prologue.steps) {
const existing = first.steps.get(step) ?? []
first.steps.set(step, [...cells, ...existing])
}
turns.set(1, first)
}
return [...turns.entries()]
.sort(([a], [b]) => a - b)
.map(([turn, entry]) => toTurnModel(turn, entry))
}
function toTurnModel(
turn: number,
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
): TrajectoryTurnModel {
const groups: TrajectoryGroupModel[] = []
if (entry.message.length > 0) {
const description = groupDescription(entry.message)
groups.push({
title: 'Message',
...(description !== undefined ? { description } : {}),
cells: entry.message.map(l => l.cell),
})
}
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
const laid = entry.steps.get(step) ?? []
const description = groupDescription(laid)
groups.push({
title: `Step ${step}`,
...(description !== undefined ? { description } : {}),
cells: laid.map(l => l.cell),
})
}
return { turn, groups }
}
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
function groupDescription(laid: readonly LaidCell[]): string | undefined {
const parts: string[] = []
// Tool rows contribute start (absTime) and end (start + own duration) so a
// single Tool cell still spans call→result for the group wall clock.
const times: number[] = []
for (const l of laid) {
if (l.absTime === null || !Number.isFinite(l.absTime)) continue
times.push(l.absTime)
if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) {
times.push(l.absTime + l.cell.timeSeconds * 1000)
}
}
if (times.length >= 2) {
const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000)
if (span !== undefined) parts.push(span)
} else if (times.length === 1) {
const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds
const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined
if (span !== undefined) parts.push(span)
}
const tools = new Map<string, number>()
for (const l of laid) {
if (l.toolName === undefined || l.cell.kind !== 'tool') continue
tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1)
}
for (const [name, count] of tools) {
parts.push(count > 1 ? `${name}×${count}` : name)
}
return parts.length === 0 ? undefined : parts.join(' ')
}
function formatGroupDuration(seconds: number): string | undefined {
if (!Number.isFinite(seconds)) return undefined
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded}s`
return `${rounded.toFixed(1)}s`
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
function durationSeconds(later: number, earlier: number | null): number | null {
if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null
return Math.max(0, (later - earlier) / 1000)
}
/** Epoch-ms usable as an absolute time, else null. */
function finiteTime(time: number): number | null {
return Number.isFinite(time) ? time : null
}
function expandAssistant(
node: AssistantMessageNode,
startIndex: number,
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
opts?: { streaming?: boolean },
): LaidCell[] {
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
const streaming = opts?.streaming === true
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
const nodeAbs = streaming ? null : finiteTime(node.time)
let usageAttached = false
for (const block of node.blocks) {
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
if (block.kind === 'reasoning') continue
if (block.kind === 'text') {
if (block.text === '' && streaming) continue
const cell: TrajectoryCellProps = {
index: ++index, kind: 'message', text: summarizeText(block.text),
timeSeconds: messageDuration,
}
if (!usageAttached && usage !== undefined) {
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
usageAttached = true
}
out.push({ absTime: nodeAbs, cell })
continue
}
if (block.kind === 'tool-call') {
const result = results.get(block.callId)
const toolDuration = streaming || result === undefined
? null
: durationSeconds(result.time, result.callTime)
const callAbs = streaming
? null
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
? result.callTime
: nodeAbs)
out.push({
absTime: callAbs,
toolName: block.name,
callId: block.callId,
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
timeSeconds: toolDuration,
},
})
}
}
if (out.length === 0 && !streaming) {
out.push({
absTime: nodeAbs,
cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration },
})
}
return out
}
function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
const map = new Map<string, ToolResultNode>()
for (const node of nodes) {
if (node.kind === 'tool-result') map.set(node.callId, node)
}
return map
}
function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
for (const node of nodes) {
if (node.kind !== 'assistant') continue
if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
}
return false
}
function collectCallIds(
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
): Set<string> {
const ids = new Set<string>()
for (const entry of turns.values()) {
for (const laid of entry.message) {
if (laid.callId !== undefined) ids.add(laid.callId)
}
for (const list of entry.steps.values()) {
for (const laid of list) {
if (laid.callId !== undefined) ids.add(laid.callId)
}
}
}
return ids
}
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
if (args === '') return name
const clipped = args.length > 72 ? `${args.slice(0, 71)}` : args
return `${name} · ${clipped}`
}
function summarizeResult(node: ToolResultNode): string {
if (node.isError) {
return node.error?.code ?? 'error'
}
for (const block of node.content) {
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
return summarizeText(block.text)
}
}
return node.call?.name ?? node.callId
}
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
}
return ''
}
function summarizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}

View File

@@ -1,25 +1,34 @@
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
* cell content width is capped on the turn body (max 880). */
.root {
padding: 16px;
overflow-y: auto;
height: 100%;
min-height: 0;
width: 100%;
box-sizing: border-box;
color: var(--dsw-alias-label-primary);
font-size: 13px;
background: var(--dsw-specific-sidebar-fill);
}
.empty {
padding: 16px;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
/* Waterfall placeholder rows (shared module). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
padding: 4px 16px;
}
.turnTag {
flex: none;
width: 64px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
.bar {
@@ -29,9 +38,10 @@
}
.barCalls {
background: var(--dsw-alias-brand-primary);
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.meta {
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}