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:
Chinesezjc
2026-07-31 17:38:17 +08:00
210 changed files with 5925 additions and 3254 deletions

View File

@@ -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>
)

View File

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

View File

@@ -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) => (