fix(web): drop the branch action from user and steering bubbles

The branch control on user and consumed-steering bubbles could enable
only when a completed turn ended with no node after the message at all,
so readers saw a permanently unavailable control whose tooltip promised
a state it could not reach. An enabled one would mislead anyway: a fork
at a message seq cuts at the containing turn/end and takes the answer
along, the opposite of the branch-to-re-ask reading a control on one's
own bubble suggests.

MessageItem loses its fork props, PendingSteeringBubble loses the
showBranch special case, and messageBranchSeqs narrows to
assistantBranchSeqs: only a completed turn's transcript tail that is
the turn's own content-text assistant may fork. A steered turn keeps
its fork point under the settled answer, because fork is a log-prefix
cut and the steer is model-visible history the child inherits.

Web aria goldens drop the user-bubble disabled-branch row and its
hidden explanation text; the nested-subagent golden also loses the one
enabled user-tail fork handle, a loss the decision note accepts.
This commit is contained in:
creatixchu
2026-08-06 17:46:18 +08:00
parent 6902b51fea
commit 84a6bae1c7
49 changed files with 154 additions and 199 deletions

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -362,7 +362,7 @@ export function ChatView({
// mid-turn text and every node of a running turn omit `time`, so
// AssistantMarkdown stays chrome-free until the answer settles.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
@@ -632,8 +632,6 @@ export function ChatView({
<MessageItem
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)

View File

@@ -27,8 +27,6 @@ export interface MessageIconActionsProps {
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. */
className?: string | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -41,7 +39,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -111,7 +109,7 @@ export function MessageIconActions({
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{showBranch && onBranch !== undefined && (
{onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
<button
@@ -127,7 +125,7 @@ export function MessageIconActions({
</button>
</Tooltip>
)}
{showBranch && onBranch !== undefined && branchUnavailable && (
{onBranch !== undefined && branchUnavailable && (
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
)}
{clock === 'end' ? clockEl : null}

View File

@@ -1,8 +1,8 @@
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy / branch IconActions; steering adds the
// interjection caption that names it), pending steering (caption + copy only),
// context injection, compaction marker, retry disclosure, and unknown-surface
// JSON rows.
// (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'
@@ -27,10 +27,6 @@ export interface MessageItemProps {
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** 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']
}
@@ -217,7 +213,6 @@ export function PendingSteeringBubble({ content, t }: {
<MessageIconActions
text={text}
clock="start"
showBranch={false}
className={css.actions}
t={t}
/>
@@ -227,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: {
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, forkUnavailable = false, t,
node, retryActive = false, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
@@ -243,8 +238,6 @@ export const MessageItem = memo(function MessageItem({
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -75,15 +75,18 @@ export function runningTurnStartTime(
}
/**
* 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 leaves the earlier message's branch action
* unavailable because the Host would include the whole turn.
* Seq set of assistant answers that may fork: the completed turn's transcript
* tail, when that tail is the turn's own content-text assistant. A later tool,
* reasoning, error, or other transcript node leaves the answer's branch action
* unavailable because the Host would include the whole turn. User and steering
* bubbles carry no branch action at all: a fork at their seq cuts at the same
* `turn/end` as the answer's, so the affordance lives only under the settled
* answer.
* @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.
* @returns Assistant seq values whose visible position matches the fork boundary.
*/
export function messageBranchSeqs(
export function assistantBranchSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
@@ -98,8 +101,7 @@ export function messageBranchSeqs(
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user' || tail?.kind === 'steering'
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) {
result.add(tail.seq)
}
}