Merge remote-tracking branch 'origin/master' into feat/web-cards-toolrow
# Conflicts: # packages/client/connection/src/client/fixture.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx # packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx # packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx # packages/client/ui-conversation/src/client/toolviews/search-row.tsx # packages/client/ui-conversation/tests/chat-apply.spec.tsx # packages/client/ui-primitives/src/index.ts
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch, date-aware clock,
|
||||
// optional edit stub.
|
||||
// branch wired through onBranch, date-aware clock.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
|
||||
IconBranchOutline16, IconCopyOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
@@ -18,8 +17,6 @@ export interface MessageIconActionsProps {
|
||||
time: number
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Fork the session at this message. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
@@ -30,11 +27,11 @@ export interface MessageIconActionsProps {
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||
* @param props - Copy text, event time, clock side, branch callback, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, onBranch, className, t,
|
||||
text, time, clock, onBranch, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -58,13 +55,6 @@ export function MessageIconActions({
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{edit === true && (
|
||||
<Tooltip label={t('edit')} side="bottom">
|
||||
<button type="button" className={css.action} aria-label={t('edit')}>
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// clock + copy / branch IconActions), steering (badged bubble), context
|
||||
// injection, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
@@ -156,7 +156,6 @@ export const MessageItem = memo(function MessageItem({
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
|
||||
@@ -3,43 +3,35 @@
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
interface UsageTotals {
|
||||
interface WindowStats {
|
||||
turns: number
|
||||
steps: 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
|
||||
}
|
||||
|
||||
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold assistant and tool-result nodes into display totals.
|
||||
* Fold assistant and tool-result nodes into the window-scoped display totals.
|
||||
*
|
||||
* Counts and wall times describe the loaded window on purpose — they answer
|
||||
* "what is on screen". Token accounting deliberately does NOT come from here:
|
||||
* the window is paged and compaction rewrites it, so billing rides the durable
|
||||
* `tokenUsage` projection instead.
|
||||
* @param nodes - snapshot nodes.
|
||||
* @returns totals; cacheHitPct null until any cache accounting arrives.
|
||||
* @returns visible counts and summed wall times.
|
||||
*/
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
|
||||
const turns = new Set<number>()
|
||||
let steps = 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)
|
||||
@@ -51,22 +43,8 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
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
|
||||
}
|
||||
const denom = input + cacheRead
|
||||
return {
|
||||
turns: turns.size,
|
||||
steps,
|
||||
llmMs,
|
||||
toolMs,
|
||||
inputTokens: input + cacheRead,
|
||||
outputTokens: output,
|
||||
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
|
||||
}
|
||||
return { turns: turns.size, steps, llmMs, toolMs }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,21 +72,82 @@ export function formatDuration(ms: number): string {
|
||||
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> }
|
||||
/**
|
||||
* Cache-hit share of prompt-side input over the whole durable log.
|
||||
* @param usage - the session's token-usage projection value.
|
||||
* @returns rounded integer percent, or null when no input was billed.
|
||||
*/
|
||||
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
|
||||
const denominator = billedInputTokens(usage)
|
||||
return denominator === 0
|
||||
? null
|
||||
: Math.round(usage.cacheReadTokens / denominator * 100)
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
/** Sum the three disjoint prompt-side billing buckets. */
|
||||
function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
interface ContextOccupancy {
|
||||
percent: number
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate context occupancy, using the TUI's integer rounding and upper
|
||||
* clamp. The numerator and capacity are independent last-wins projection
|
||||
* fields, so this is a reference figure rather than an exact measurement of one
|
||||
* request (see the token-meter README).
|
||||
* @param pressure - the session's context-pressure projection value.
|
||||
* @returns occupancy and its denominator, or null until both values are known.
|
||||
*/
|
||||
export function contextOccupancy(
|
||||
pressure: ContextPressureProjection | undefined,
|
||||
): ContextOccupancy | null {
|
||||
if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null
|
||||
return {
|
||||
percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)),
|
||||
contextWindow: pressure.contextWindow,
|
||||
}
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector plus the projection read seat. */
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useProjection: UseProjection
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
const pressure = useProjection('contextPressure')
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
// 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`)
|
||||
const groups: string[] = []
|
||||
if (stats.steps > 0) {
|
||||
groups.push(`${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(' · '))
|
||||
}
|
||||
const context = contextOccupancy(pressure)
|
||||
if (context !== null) {
|
||||
groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`)
|
||||
}
|
||||
// Billing rides the durable projection, so these survive paging and
|
||||
// compaction. Suppress the empty projection on a brand-new session.
|
||||
if (usage !== undefined
|
||||
&& (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`)
|
||||
groups.push(
|
||||
`Input ${formatTokens(billedInputTokens(usage))} tok`
|
||||
+ ` · Output ${formatTokens(usage.outputTokens)} tok`,
|
||||
)
|
||||
}
|
||||
if (groups.length === 0) return null
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
|
||||
Reference in New Issue
Block a user