Merge remote-tracking branch 'origin/master' into feat/web-terminal-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
This commit is contained in:
Chinesezjc
2026-07-29 15:44:58 +08:00
79 changed files with 1259 additions and 673 deletions
@@ -1,14 +1,22 @@
/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
IconActions sit below the body with an explicit 16px top margin (figma
43:32997) — separate from the body's internal gap so the footer spacing
stays fixed when the body is a single block. */
.root {
display: flex;
flex-direction: column;
gap: 16px;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;
@@ -19,3 +27,18 @@
font-size: 11px;
line-height: 18px;
}
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
.actions {
margin-top: 16px;
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
margin-left: -6px;
}
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
@media (hover: hover) {
.root:hover .actions,
.root:focus-within .actions {
opacity: 1;
}
}
@@ -4,10 +4,14 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -16,6 +20,8 @@ export interface AssistantMarkdownProps {
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
interrupted?: boolean | undefined
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
time?: number | undefined
}
function firstLine(text: string): string {
@@ -23,6 +29,15 @@ function firstLine(text: string): string {
return nl === -1 ? text : text.slice(0, nl)
}
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
function copyText(blocks: readonly AssistantBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
if (block.kind === 'text') parts.push(block.text)
}
return parts.join('')
}
/** Reasoning block as the Think variant summary row (figma 39:28304). */
function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
@@ -38,7 +53,9 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
)
}
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time,
}: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -47,18 +64,30 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
// Footer only after the turn settles with a known event time; streaming omits it.
const showActions = !streaming && time !== undefined
return (
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{interrupted && <span className={css.stopped}></span>}
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{interrupted && <span className={css.stopped}></span>}
</div>
{showActions && (
<MessageIconActions
text={copyText(blocks)}
time={time}
clock="end"
className={css.actions}
/>
)}
</div>
)
})
@@ -330,7 +330,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
return (
<AssistantMarkdown
key={item.key}
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={node.time}
/>
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
@@ -0,0 +1,53 @@
/* Shared message IconActions row (user + assistant). Parent modules own
hover-reveal selectors and layout offsets via the composed className. */
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
.timeStart {
padding-right: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
.timeEnd {
padding-left: 12px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
@@ -0,0 +1,65 @@
// Shared IconActions chrome for user and assistant messages: copy / branch
// live (branch still a stub), date-aware clock, optional edit stub.
import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
export interface MessageIconActionsProps {
/** Plain text the copy action writes. */
text: string
/** Unix epoch ms for the clock label. */
time: number
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** When true, append the stub edit control (user bubble). */
edit?: boolean | undefined
/** Parent layout / hover-reveal class composed onto the actions row. */
className?: string | undefined
}
/**
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
* @param props - Copy text, event time, clock side, optional edit, className.
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, edit, className,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
const clockEl = (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{formatMessageClock(time, day)}
</span>
)
return (
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
{clock === 'start' ? clockEl : null}
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
{edit === true && (
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
)}
{clock === 'end' ? clockEl : null}
</div>
)
}
@@ -20,46 +20,14 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
@@ -1,18 +1,16 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// copy / branch / edit IconActions), steering (badged bubble), context
// clock + copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo, useCallback } from 'react'
import { memo } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -30,42 +28,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove()
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -98,32 +60,6 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
function UserActions({ text }: { text: string }) {
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user': {
@@ -134,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} />
<MessageIconActions
text={text}
time={node.time}
clock="start"
edit
className={css.actions}
/>
</div>
)
}
@@ -0,0 +1,91 @@
// Shared chrome helpers for user/assistant IconActions rows: clipboard write
// and the compact date+clock label from a session-event epoch.
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
*/
export async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove()
}
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
/**
* Local calendar-day epoch (ms at local midnight) for an instant.
* @param ms - Unix epoch ms.
* @returns Midnight of that local calendar day.
*/
export function startOfLocalDay(ms: number): number {
const d = new Date(ms)
d.setHours(0, 0, 0, 0)
return d.getTime()
}
/**
* Delay until the next local midnight after `ms` (at least 1ms).
* @param ms - Unix epoch ms.
* @returns Milliseconds until the following local midnight.
*/
export function msUntilNextLocalMidnight(ms: number): number {
const next = new Date(ms)
next.setHours(24, 0, 0, 0)
return Math.max(next.getTime() - ms, 1)
}
/**
* Compact local timestamp for message IconActions.
* Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
* other years → `YYYY年M月D日 HH:mm`.
* @param time - Unix epoch ms from the source session event.
* @param now - Reference instant for the day/year cut (defaults to wall clock).
* @returns Date-aware clock string (24-hour, zero-padded time).
*/
export function formatMessageClock(time: number, now: number = Date.now()): string {
const d = new Date(time)
const n = new Date(now)
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
if (
d.getFullYear() === n.getFullYear()
&& d.getMonth() === n.getMonth()
&& d.getDate() === n.getDate()
) {
return clock
}
const md = `${d.getMonth() + 1}${d.getDate()}`
if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
return `${d.getFullYear()}${md} ${clock}`
}
@@ -0,0 +1,25 @@
// Component-local calendar-day tick: memoized message rows keep stable props
// across midnight, so the IconActions clock needs a local day seat that
// re-fires at the next local midnight without reaching for framework hooks.
import { useEffect, useState } from 'react'
import { msUntilNextLocalMidnight, startOfLocalDay } from './message-chrome.ts'
/**
* Local calendar-day epoch that advances at each local midnight.
* @returns Midnight ms for the current local day; updates after the boundary.
*/
export function useCalendarDay(): number {
const [day, setDay] = useState(() => startOfLocalDay(Date.now()))
useEffect(() => {
let timer: ReturnType<typeof setTimeout>
const arm = (): void => {
const now = Date.now()
setDay(startOfLocalDay(now))
timer = setTimeout(arm, msUntilNextLocalMidnight(now))
}
timer = setTimeout(arm, msUntilNextLocalMidnight(Date.now()))
return () => { clearTimeout(timer) }
}, [])
return day
}