The user-turn predicate (opensUserTurn) unifies the three parallel consumers a new node kind silently missed — produced-files turn reset, retry liveness, own-words force-scroll — so a skill invocation behaves as the turn opener it is. The menu marker resolves through ctx.locale.bind instead of a hand-rolled snapshot lookup; the dead legacy <skill> render arm goes with the removal cut; command-over-skill name precedence is now documented at the matchEnter seam; and the emptied replacement catalog keeps the no-reload sentence, with the never-published residual recorded in the Agent Note.
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
// MessageItem: simple chat nodes — user and consumed-steering bubbles
|
|
// (right-aligned, with clock + copy IconActions; steering adds the
|
|
// interjection caption that names it; branch lives only under assistant
|
|
// answers), pending steering (caption + copy only), context injection,
|
|
// compaction marker, retry disclosure, and unknown-surface JSON rows.
|
|
|
|
import { memo, useEffect, useMemo, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import type {
|
|
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SkillInvocationNode,
|
|
SteeringMessageNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
|
import { CompactionItem } from './CompactionItem.tsx'
|
|
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
|
import { MessageIconActions } from './MessageIconActions.tsx'
|
|
import css from './MessageItem.module.css'
|
|
|
|
export interface MessageItemProps {
|
|
node:
|
|
| UserMessageNode
|
|
| SteeringMessageNode
|
|
| ContextMessageNode
|
|
| SkillInvocationNode
|
|
| CompactionSummaryNode
|
|
| ModelRetryNode
|
|
| TurnErrorNode
|
|
| UnknownSurfaceNode
|
|
retryActive?: boolean
|
|
/** The owning view's locale seat, passed down as a plain prop. */
|
|
t: ChatViewSlotProps['t']
|
|
}
|
|
|
|
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
|
const texts: string[] = []
|
|
const rest: unknown[] = []
|
|
for (const block of content) {
|
|
const b = block as { type?: string; text?: string }
|
|
if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
|
|
else rest.push(block)
|
|
}
|
|
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>
|
|
)
|
|
}
|
|
|
|
/** Persistent, turn-positioned feedback for a terminal failure. */
|
|
function TurnErrorItem({ node, t }: {
|
|
node: TurnErrorNode
|
|
t: ChatViewSlotProps['t']
|
|
}) {
|
|
return (
|
|
<div className={css.turnErrorRow} role="status">
|
|
<StateDot state="error" className={css.turnErrorDot} />
|
|
<div className={css.turnErrorCopy}>
|
|
<span className={css.turnErrorTitle}>{t('message.turnError')}</span>
|
|
<span className={css.turnErrorMessage}>{node.message}</span>
|
|
</div>
|
|
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Display projection of reference forms in a user bubble (free geometry — no
|
|
* textarea alignment constraint here); everything else stays plain text. The
|
|
* logged model text remains the single truth; this is presentation only.
|
|
* Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21:
|
|
* the sent text IS the reference — the bubble uses the same plainest token
|
|
* scan as the composer, minus the lexicon: sent tokens were validated at
|
|
* compose time, so shape alone decorates).
|
|
*/
|
|
function projectUserText(text: string): ReactNode {
|
|
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g
|
|
const parts: ReactNode[] = []
|
|
let cursor = 0
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(text)) !== null) {
|
|
const tokenStart = m.index + (m[1]?.length ?? 0)
|
|
const label = m[2] ?? ''
|
|
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
|
|
parts.push(
|
|
<span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
|
|
{label}
|
|
</span>,
|
|
)
|
|
cursor = tokenStart + label.length
|
|
}
|
|
if (parts.length === 0) return <MessageText text={text} />
|
|
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)
|
|
return <>{parts}</>
|
|
}
|
|
|
|
/** Right-aligned bubble shared by user and steering rows. */
|
|
function UserStyleBubble({
|
|
content, actions, pending = false, steering = false, t,
|
|
}: {
|
|
content: readonly unknown[]
|
|
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
|
|
actions?: (text: string) => ReactNode
|
|
/** Whether this is the Host-authoritative pre-admission steering projection. */
|
|
pending?: boolean
|
|
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
|
|
steering?: boolean
|
|
t: ChatViewSlotProps['t']
|
|
}): ReactNode {
|
|
const { text, rest } = contentText(content)
|
|
const truncated = (total: number): string => t('json.truncated', { total })
|
|
return (
|
|
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
|
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
|
|
<div className={css.bubble}>
|
|
{projectUserText(text)}
|
|
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
|
</div>
|
|
{actions?.(text)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A user-explicit skill invocation: the right-aligned bubble presents the
|
|
* `/name args` gesture from source metadata (never re-parsed from the body),
|
|
* and the injected `<skill_content>` collapses behind a disclosure — the
|
|
* durable content is model-facing bulk, not conversation prose.
|
|
*/
|
|
function SkillInvocationRow({ node, t }: {
|
|
node: SkillInvocationNode
|
|
t: ChatViewSlotProps['t']
|
|
}): ReactNode {
|
|
const { text } = contentText(node.content)
|
|
return (
|
|
<div className={css.userRow} data-skill-invocation data-time-hover-root>
|
|
<div className={css.bubble}>
|
|
<span className={css.refChip} data-ref-chip="skill">{`/${node.name}`}</span>
|
|
{node.args !== undefined && <MessageText text={` ${node.args}`} />}
|
|
<details className={css.skillInvocationDetails}>
|
|
<summary className={css.skillInvocationSummary}>{t('message.skillInvocation.expand')}</summary>
|
|
<pre className={css.skillInvocationBody}>{text}</pre>
|
|
</details>
|
|
</div>
|
|
<MessageIconActions
|
|
text={text}
|
|
time={node.time}
|
|
clock="start"
|
|
className={css.actions}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Render one Host-authoritative pending steering item with the same visual
|
|
* language as its eventual durable transcript node.
|
|
* @param props - Pending message content and conversation translator.
|
|
* @returns the pending steering bubble.
|
|
*/
|
|
export function PendingSteeringBubble({ content, t }: {
|
|
content: readonly unknown[]
|
|
t: ChatViewSlotProps['t']
|
|
}): ReactNode {
|
|
return (
|
|
<UserStyleBubble
|
|
content={content}
|
|
pending
|
|
steering
|
|
t={t}
|
|
actions={text => (
|
|
<MessageIconActions
|
|
text={text}
|
|
clock="start"
|
|
className={css.actions}
|
|
t={t}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export const MessageItem = memo(function MessageItem({
|
|
node, retryActive = false, t,
|
|
}: MessageItemProps) {
|
|
const truncated = (total: number): string => t('json.truncated', { total })
|
|
switch (node.kind) {
|
|
case 'user':
|
|
case 'steering':
|
|
return (
|
|
<UserStyleBubble
|
|
content={node.content}
|
|
steering={node.kind === 'steering'}
|
|
t={t}
|
|
actions={text => (
|
|
<MessageIconActions
|
|
text={text}
|
|
time={node.time}
|
|
clock="start"
|
|
className={css.actions}
|
|
t={t}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
case 'context':
|
|
return (
|
|
<ContextInjectionRow
|
|
content={node.content}
|
|
source={node.source}
|
|
provenance={node.provenance}
|
|
form={node.form}
|
|
t={t}
|
|
/>
|
|
)
|
|
case 'skill-invocation':
|
|
return <SkillInvocationRow node={node} t={t} />
|
|
case 'compaction':
|
|
return <CompactionItem node={node} t={t} />
|
|
case 'model-retry':
|
|
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
|
case 'turn-error':
|
|
return <TurnErrorItem node={node} t={t} />
|
|
default:
|
|
return (
|
|
<div className={css.contextRow}>
|
|
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
|
|
</div>
|
|
)
|
|
}
|
|
})
|