Merge remote-tracking branch 'origin/master' into fix/web-sources-scroll

This commit is contained in:
Chinesezjc
2026-08-03 16:38:12 +08:00
90 changed files with 1426 additions and 242 deletions

View File

@@ -4,9 +4,10 @@
// 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 turn-tail content (text) nodes append IconActions once streaming
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
// nodes stay chrome-free.
// Finalized content (text) nodes append IconActions once streaming ends
// (`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.
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -28,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 the turn containing this finalized message. */
/** 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']
}
@@ -76,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.
@@ -120,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

@@ -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, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -236,6 +236,7 @@ export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnEnds = useSession(s => s.turnEnds)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -257,6 +258,7 @@ export function ChatView({
// 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])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -413,6 +415,7 @@ export function ChatView({
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)
@@ -428,6 +431,7 @@ export function ChatView({
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
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'
@@ -17,9 +17,11 @@ export interface MessageIconActionsProps {
time?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message. */
/** Fork the session at this message; omission hides the branch action. */
onBranch?: (() => void) | undefined
/** Whether to render the branch action; defaults to true. */
/** 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
@@ -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])
@@ -52,13 +55,25 @@ export function MessageIconActions({
<IconCopyOutline16 />
</button>
</Tooltip>
{showBranch && (
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
{showBranch && 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
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 the session through the turn containing this message (user-bubble branch action). */
/** 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

@@ -5,8 +5,8 @@
* 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
* flow share one gate.
* and completed-turn branch points are derived here too so ChatView and the
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ToolResultNode,
@@ -47,6 +47,39 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
return new Set(lastByTurn.values())
}
/**
* 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.
* @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.
*/
export function messageBranchSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
const result = new Set<number>()
const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1])
let nodeIndex = 0
for (const [turn, endSeq] of boundaries) {
let tail: ConversationNode | undefined
while (nodeIndex < nodes.length) {
const candidate = nodes[nodeIndex]
if (candidate === undefined || candidate.seq > endSeq) break
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user'
|| (tail?.kind === 'steering' && tail.turn === turn)
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}
}
return result
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes in human-transcript and durable-notice order.

View File

@@ -461,7 +461,7 @@ export interface ChatViewInjected {
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
}
/** Fork the session through the turn containing the message at `seq`, then open the child. */
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void
}

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',