Merge remote-tracking branch 'origin/master' into mergebot/pr998
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
@@ -55,6 +55,17 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
|
||||
if (!running) return null
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
const node = nodes[index]
|
||||
if (node === undefined) continue
|
||||
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
|
||||
if (node.kind === 'assistant' || node.kind === 'user') return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
@@ -262,6 +273,7 @@ export function ChatView({
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
@@ -424,7 +436,15 @@ export function ChatView({
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
|
||||
return (
|
||||
<MessageItem
|
||||
key={item.key}
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
onFork={forkAt}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,6 +34,106 @@
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.retrySummary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 2px 0;
|
||||
gap: 7px;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.retrySummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retrySummary::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 1.5px solid currentcolor;
|
||||
border-bottom: 1.5px solid currentcolor;
|
||||
content: '';
|
||||
opacity: 0.8;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.retrySummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.retrySummary:focus-visible {
|
||||
outline: 1.5px solid var(--dsw-alias-button-info-fill);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.retryText {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.retryRow[data-active] .retryText {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-alias-label-tertiary) 0%,
|
||||
var(--dsw-alias-label-tertiary) 40%,
|
||||
var(--dsw-alias-label-secondary) 50%,
|
||||
var(--dsw-alias-label-tertiary) 60%,
|
||||
var(--dsw-alias-label-tertiary) 100%
|
||||
);
|
||||
background-position: 100% 50%;
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: retry-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.retryRow[open] .retrySummary::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.retryDetails {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: 3px;
|
||||
padding-left: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.retryDetailLabel {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@keyframes retry-shimmer {
|
||||
from {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.retryRow[data-active] .retryText {
|
||||
background: none;
|
||||
color: inherit;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
|
||||
spans render as chips; free geometry — no textarea pairing here). */
|
||||
.refChip {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with
|
||||
// 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.
|
||||
// injection, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
@@ -16,7 +14,8 @@ import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
|
||||
retryActive?: boolean
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
onFork?: (seq: number) => void
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -34,6 +33,80 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
function retrySeconds(milliseconds: number): number {
|
||||
return Math.max(1, Math.ceil(milliseconds / 1_000))
|
||||
}
|
||||
|
||||
interface RetryCountdown {
|
||||
deadline: number
|
||||
seconds: number
|
||||
}
|
||||
|
||||
function ModelRetryItem({ node, active, t }: {
|
||||
node: ModelRetryNode
|
||||
active: boolean
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
// Anchor the host-scheduled delay to this browser's first render of the
|
||||
// retry node. Host event time and Date.now() may belong to different clocks.
|
||||
const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq])
|
||||
const scheduledSeconds = retrySeconds(node.delayMs)
|
||||
const maximum = node.mode === 'normal' ? node.maxRetries : '∞'
|
||||
const [countdown, setCountdown] = useState<RetryCountdown>(() => ({
|
||||
deadline,
|
||||
seconds: retrySeconds(deadline - Date.now()),
|
||||
}))
|
||||
const remainingSeconds = countdown.deadline === deadline
|
||||
? countdown.seconds
|
||||
: retrySeconds(deadline - Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const updateCountdown = (): number => {
|
||||
const next = retrySeconds(deadline - Date.now())
|
||||
setCountdown(current => (
|
||||
current.deadline === deadline && current.seconds === next
|
||||
? current
|
||||
: { deadline, seconds: next }
|
||||
))
|
||||
return next
|
||||
}
|
||||
if (updateCountdown() === 1) return
|
||||
const timer = window.setInterval(() => {
|
||||
if (updateCountdown() === 1) window.clearInterval(timer)
|
||||
}, 250)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [active, deadline])
|
||||
|
||||
const label = active
|
||||
? t('message.retry.active')
|
||||
: node.retryState === 'cancelled'
|
||||
? t('message.retry.cancelled')
|
||||
: node.retryState === 'started'
|
||||
? t('message.retry.started')
|
||||
: t('message.retry.scheduled')
|
||||
const seconds = active ? remainingSeconds : scheduledSeconds
|
||||
|
||||
return (
|
||||
<details className={css.retryRow} data-active={active || undefined}>
|
||||
<summary className={css.retrySummary}>
|
||||
<span className={css.retryText} role="status">
|
||||
{t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
|
||||
</span>
|
||||
</summary>
|
||||
<div className={css.retryDetails}>
|
||||
<div>
|
||||
<span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
|
||||
{Math.round(node.delayMs)}ms
|
||||
</div>
|
||||
<div>
|
||||
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
|
||||
{node.failure.message}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -66,7 +139,9 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
@@ -105,6 +180,8 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag
|
||||
return (
|
||||
<ContextInjectionRow content={node.content} source={node.source} t={t} />
|
||||
)
|
||||
case 'model-retry':
|
||||
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
|
||||
* results group into consecutive-run tool groups (figma step-summary flow,
|
||||
* VERTICAL gap10) alternating with narration; everything else passes through.
|
||||
* VERTICAL gap10) alternating with narration. Consecutive retry notices
|
||||
* reuse the first notice's row while projecting the latest retry turn.
|
||||
* Item identity keys are stable across snapshots so the list parent can
|
||||
* subscribe to keys only while rows subscribe to content. IconActions ownership
|
||||
* (last content assistant per turn) is derived here too so ChatView and the
|
||||
@@ -49,7 +50,7 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
|
||||
/**
|
||||
* Group finalized nodes into the step-summary flow.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
|
||||
* @returns flow items; consecutive tool results and retry notices reuse their first key.
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
@@ -63,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
} else {
|
||||
group.push(node)
|
||||
}
|
||||
} else if (node.kind === 'model-retry') {
|
||||
group = null
|
||||
const previous = items[items.length - 1]
|
||||
if (
|
||||
previous?.kind === 'node'
|
||||
&& previous.node.kind === 'model-retry'
|
||||
) {
|
||||
items[items.length - 1] = { ...previous, node }
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
} else {
|
||||
group = null
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
|
||||
@@ -53,6 +53,13 @@ export const zh = {
|
||||
'message.unknownBlock': '未知内容块',
|
||||
'message.stopped': '已停止',
|
||||
'message.branch': '在新对话中分支',
|
||||
'message.retry.active': '正在重试模型请求',
|
||||
'message.retry.cancelled': '模型请求重试已取消',
|
||||
'message.retry.started': '已重试模型请求',
|
||||
'message.retry.scheduled': '等待重试模型请求',
|
||||
'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
@@ -140,6 +147,13 @@ export const en = {
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
'message.stopped': 'Stopped',
|
||||
'message.branch': 'Branch into a new conversation',
|
||||
'message.retry.active': 'Retrying model request',
|
||||
'message.retry.cancelled': 'Model request retry cancelled',
|
||||
'message.retry.started': 'Retried model request',
|
||||
'message.retry.scheduled': 'Waiting to retry model request',
|
||||
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
|
||||
Reference in New Issue
Block a user