fix(web): keep ineligible message forks disabled

This commit is contained in:
kingwl
2026-08-03 16:19:49 +08:00
parent c43eaaec82
commit c5050f018e
41 changed files with 220 additions and 48 deletions

View File

@@ -5,7 +5,7 @@
// 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 content (text) nodes append IconActions once streaming ends
// (`time` is omitted for mid-turn narration); their branch action is present
// (`time` is omitted for mid-turn narration); their branch action is enabled
// only when the node is also the completed turn's transcript tail. Think /
// tool-head-only nodes stay chrome-free.
@@ -29,8 +29,10 @@ export interface AssistantMarkdownProps {
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through this finalized message's completed turn. */
/** Fork the session through this finalized message's completed turn when eligible. */
onFork?: ((seq: number) => void) | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -77,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, seq, onFork, t,
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -121,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -414,7 +414,8 @@ export function ChatView({
interrupted={node.interrupted}
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={branchSeqs.has(node.seq) ? forkAt : undefined}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)
@@ -429,7 +430,8 @@ export function ChatView({
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
{...branchSeqs.has(node.seq) ? { onFork: forkAt } : {}}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)

View File

@@ -43,3 +43,23 @@
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Unavailable stays focusable and hoverable so Tooltip can explain why. */
.action[data-unavailable] {
cursor: default;
opacity: 0.4;
}
.action[data-unavailable]:hover {
background: transparent;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,7 +1,7 @@
// Shared IconActions chrome for user, steering, and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback } from 'react'
import { useCallback, useId } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,6 +19,8 @@ export interface MessageIconActionsProps {
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
onBranch?: (() => void) | undefined
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
branchUnavailable?: boolean | undefined
/** Additional branch visibility gate for transient message chrome; defaults to true. */
showBranch?: boolean | undefined
/** Parent layout class composed onto the actions row. */
@@ -33,9 +35,10 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, onBranch, showBranch = true, className, t,
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
@@ -53,12 +56,24 @@ export function MessageIconActions({
</button>
</Tooltip>
{showBranch && onBranch !== undefined && (
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
<button
type="button"
className={css.action}
aria-label={t('message.branch')}
aria-disabled={branchUnavailable || undefined}
aria-describedby={branchUnavailable ? reasonId : undefined}
data-unavailable={branchUnavailable || undefined}
onClick={branchUnavailable ? undefined : onBranch}
>
<IconBranchOutline16 />
</button>
</Tooltip>
)}
{showBranch && onBranch !== undefined && branchUnavailable && (
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
)}
{clock === 'end' ? clockEl : null}
</div>
)

View File

@@ -26,8 +26,10 @@ export interface MessageItemProps {
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** Fork through this message's completed turn when it is the transcript tail. */
/** Fork through this message's completed turn when eligible. */
onFork?: (seq: number) => void
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: {
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
node, retryActive = false, onFork, forkUnavailable = false, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
@@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -50,8 +50,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
/**
* Seq set of message rows that may fork: the last transcript node of a
* completed turn, when that node owns message chrome. A later tool, reasoning,
* error, or other transcript node suppresses the earlier message's branch
* action even though the Host would include the whole turn.
* error, or other transcript node leaves the earlier message's branch action
* unavailable because the Host would include the whole turn.
* @param nodes - snapshot nodes in event order.
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Message seq values whose visible position matches the fork boundary.

View File

@@ -59,6 +59,7 @@ export const zh = {
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
'message.retry.active': '正在重试模型请求',
'message.retry.cancelled': '模型请求重试已取消',
'message.retry.started': '已重试模型请求',
@@ -166,6 +167,7 @@ export const en = {
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'message.branchUnavailable': 'Available only on the last message of a completed turn',
'message.retry.active': 'Retrying model request',
'message.retry.cancelled': 'Model request retry cancelled',
'message.retry.started': 'Retried model request',