Web composer stats detail row and input-zone polish
Stats line moves into the InputBar's new footer slot (sharing the card's width column) and expands to the design's grouped detail row: turns/steps, LLM and tool wall time, cache hit, and input/output token split, all derived client-side from the snapshot. The composer stack owns one 8px rhythm, the seat fades the transcript through a fixed 36px gradient band, back-to-bottom follows a live --dsh-composer-height, and goal/todo strips share one 752px tip-fill column.
This commit is contained in:
@@ -144,8 +144,11 @@
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .toBottomSlot {
|
||||
/* Clears the sticky composer stack (stats + docks + input card). */
|
||||
bottom: 168px;
|
||||
/* Clears the sticky composer stack (docks + input card + stats): the live
|
||||
height rides --dsh-composer-height (ConversationRoot's seat observer) so
|
||||
the control follows a growing textarea; the fallback covers the first
|
||||
paint before the observer fires. */
|
||||
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
|
||||
}
|
||||
|
||||
.toBottom {
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
736px message column axis. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
max-width: 736px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 24px 8px;
|
||||
padding: 4px 24px 0px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--dsw-alias-separator-primary);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './StatsLine.module.css'
|
||||
@@ -10,7 +10,13 @@ import css from './StatsLine.module.css'
|
||||
interface UsageTotals {
|
||||
turns: number
|
||||
steps: number
|
||||
tokens: number
|
||||
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
|
||||
llmMs: number
|
||||
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
|
||||
toolMs: number
|
||||
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheHitPct: number | null
|
||||
}
|
||||
|
||||
@@ -22,35 +28,72 @@ interface UsageLike {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold assistant nodes into display totals.
|
||||
* Fold assistant and tool-result nodes into display totals.
|
||||
* @param nodes - snapshot nodes.
|
||||
* @returns totals; cacheHitPct null until any cache accounting arrives.
|
||||
*/
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
const turns = new Set<number>()
|
||||
let steps = 0
|
||||
let tokens = 0
|
||||
let llmMs = 0
|
||||
let toolMs = 0
|
||||
let input = 0
|
||||
let output = 0
|
||||
let cacheRead = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') {
|
||||
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
|
||||
continue
|
||||
}
|
||||
if (node.kind !== 'assistant') continue
|
||||
turns.add(node.turn)
|
||||
steps += 1
|
||||
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
|
||||
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
|
||||
}
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
if (usage === undefined) continue
|
||||
input += usage.inputTokens ?? 0
|
||||
output += usage.outputTokens ?? 0
|
||||
cacheRead += usage.cacheReadTokens ?? 0
|
||||
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
|
||||
}
|
||||
const denom = input + cacheRead
|
||||
return {
|
||||
turns: turns.size,
|
||||
steps,
|
||||
tokens,
|
||||
llmMs,
|
||||
toolMs,
|
||||
inputTokens: input + cacheRead,
|
||||
outputTokens: output,
|
||||
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
|
||||
* @param n - token count.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatTokens(n: number): string {
|
||||
const scaled = (v: number): string =>
|
||||
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
||||
if (n < 1_000) return String(n)
|
||||
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
|
||||
return `${scaled(n / 1_000_000)}M`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
||||
* @param ms - duration in milliseconds.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const s = ms / 1_000
|
||||
if (s < 60) return `${Math.round(s * 10) / 10}s`
|
||||
const whole = Math.round(s)
|
||||
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
@@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
const parts: string[] = []
|
||||
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
|
||||
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
|
||||
parts.push(`${stats.turns} turns`)
|
||||
parts.push(`${stats.steps} steps`)
|
||||
return <div className={css.root}>{parts.join(' · ')}</div>
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
|
||||
const durations: string[] = []
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
|
||||
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
<Fragment key={group}>
|
||||
{i > 0 && <span className={css.sep} aria-hidden>|</span>}
|
||||
<span>{group}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* design §6 MIX evidence: entries coexist in fixed order).
|
||||
*/
|
||||
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** The composer top-edge band (stats line family). */
|
||||
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
|
||||
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
|
||||
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
@@ -253,6 +253,8 @@ export interface ComposerBarOwnerProps {
|
||||
leftItems?: ReactNode
|
||||
/** input.right slot entries (tool row, before the primary button). */
|
||||
rightItems?: ReactNode
|
||||
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
|
||||
footer?: ReactNode
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */
|
||||
|
||||
.dock {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dsw-alias-separator-primary);
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -127,10 +127,14 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order). */
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order).
|
||||
The stack owns the vertical rhythm: one gap here, entries carry no outer
|
||||
margins — an entry that renders null costs nothing, so spacing stays
|
||||
correct for any dock combination. */
|
||||
.composerStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
@@ -170,7 +174,17 @@
|
||||
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
|
||||
paints under a sticking code header while scrolling. */
|
||||
z-index: 7;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
|
||||
band at the seat's top (the figma 24% of the resting ~150px composer),
|
||||
solid below — px stops, not %, so a growing draft only widens the solid
|
||||
region and the fade band never stretches. The 0px stop is bg-base at
|
||||
zero alpha (not white, which the figma export hardcodes) so both themes
|
||||
fade from their own base. */
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
|
||||
var(--dsw-alias-bg-base) 36px
|
||||
);
|
||||
}
|
||||
|
||||
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
@@ -29,6 +29,23 @@ export function ConversationRoot({
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Publishes the seat's live height as --dsh-composer-height on the scroll
|
||||
// body so floating controls (ChatView back-to-bottom) clear the composer as
|
||||
// it grows. Callback ref, not an effect: the seat remounts when the tree
|
||||
// moves between the no-session and session paths. Stable identity so React
|
||||
// reattaches only on those remounts, not on every render.
|
||||
const seatObserver = useRef<ResizeObserver | null>(null)
|
||||
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
|
||||
seatObserver.current?.disconnect()
|
||||
seatObserver.current = null
|
||||
const scroller = seat?.parentElement ?? null
|
||||
if (seat === null || scroller === null) return
|
||||
seatObserver.current = new ResizeObserver(() => {
|
||||
scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
|
||||
})
|
||||
seatObserver.current.observe(seat)
|
||||
}, [])
|
||||
|
||||
const sessionWorkspace = sessionId === undefined
|
||||
? undefined
|
||||
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
|
||||
@@ -106,6 +123,9 @@ export function ConversationRoot({
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
// Stats band under the card, inside the bar's width column so both
|
||||
// share one constraint (composer.dock = stats-line family).
|
||||
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
@@ -113,9 +133,6 @@ export function ConversationRoot({
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{/* Stats band above the input-dock strips so the prior ChatView footer
|
||||
order (stats → todo/queue → card) is preserved under the sticky stack. */}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
@@ -133,7 +150,7 @@ export function ConversationRoot({
|
||||
// on the fallback alone would leave Question/Approval panels at the content
|
||||
// end off-screen when the user is not pinned to the floor.
|
||||
const composerSeat = (
|
||||
<div className={css.composerSeat} data-composer-seat="">
|
||||
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
|
||||
margin + 6px here); error/status strips still carry their own margin. */
|
||||
padding: 6px 32px 12px;
|
||||
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
|
||||
the chat scroller. No top pad: the composer stack's gap owns the space
|
||||
above; error/status strips still carry their own margin. */
|
||||
padding: 0 32px 8px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -209,12 +209,16 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
|
||||
min-height: 52px;
|
||||
max-height: 336px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
|
||||
line + 4pt); the docked composer collapses to the content height. */
|
||||
.hero .mirror {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
|
||||
(figma Input_Bottom chrome). */
|
||||
.row {
|
||||
|
||||
@@ -30,7 +30,7 @@ export type InputBarProps = ComposerBarProps
|
||||
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
@@ -417,6 +417,7 @@ export function InputBar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
|
||||
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
|
||||
stack owns the gap. */
|
||||
|
||||
.root {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
width: calc(100% - 88px);
|
||||
max-width: 776px;
|
||||
max-width: 752px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
@@ -20,11 +21,13 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Compact scale (GoalBar reference): collapsed header totals the goal
|
||||
strip's 38px (8+8 pad + 20 line + 2 border). */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -41,8 +44,8 @@
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user