Keep message fork controls inline

Desktop transcript messages already expose copy controls in the shared action row. The fork affordance was rendered as its own zero-height hover row, which left the icon below Copy and made the action feel detached from the message toolbar. Moving the fork button into the existing action bar keeps the controls aligned while preserving the original branch-session API path.

Constraint: Desktop message actions should stay visually grouped for both user and assistant messages

Rejected: Keep a separate fork-only hover row | it reproduces the two-line layout regression shown in the desktop UI

Confidence: high

Scope-risk: narrow

Directive: Keep future per-message actions inside MessageActionBar unless they need a different interaction surface

Tested: cd desktop && bun run test src/components/chat/MessageList.test.tsx --pool forks --poolOptions.forks.singleFork=true

Tested: cd desktop && bun run test src/i18n/index.test.tsx --pool forks --poolOptions.forks.singleFork=true

Tested: cd desktop && bun run lint

Tested: bun run check:desktop

Not-tested: Full live desktop backend fork-click E2E
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 23:11:03 +08:00
parent df60c77472
commit 8bd122c967
7 changed files with 61 additions and 77 deletions

View File

@ -1,13 +1,14 @@
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { MessageActionBar } from './MessageActionBar'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { InlineImageGallery } from './InlineImageGallery'
type Props = {
content: string
isStreaming?: boolean
branchAction?: MessageBranchAction
}
export function AssistantMessage({ content, isStreaming }: Props) {
export function AssistantMessage({ content, isStreaming, branchAction }: Props) {
if (!content.trim()) return null
const documentLayout = shouldUseDocumentLayout(content)
@ -36,6 +37,7 @@ export function AssistantMessage({ content, isStreaming }: Props) {
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
branchAction={branchAction}
align="start"
/>
</div>

View File

@ -1,19 +1,28 @@
import { GitFork } from 'lucide-react'
import { CopyButton } from '../shared/CopyButton'
export type MessageBranchAction = {
label: string
loading?: boolean
onBranch: () => void
}
type Props = {
copyText?: string
copyLabel: string
branchAction?: MessageBranchAction
align?: 'start' | 'end'
}
export function MessageActionBar({
copyText,
copyLabel,
branchAction,
align = 'start',
}: Props) {
const hasCopy = Boolean(copyText?.trim())
if (!hasCopy) return null
if (!hasCopy && !branchAction) return null
return (
<div
@ -24,13 +33,27 @@ export function MessageActionBar({
}`}
>
<div className="flex items-center gap-1.5">
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
{hasCopy ? (
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
) : null}
{branchAction ? (
<button
type="button"
onClick={branchAction.onBranch}
disabled={branchAction.loading}
aria-label={branchAction.label}
title={branchAction.label}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
>
<GitFork size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
) : null}
</div>
</div>
)

View File

@ -2339,8 +2339,15 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
const branchButtons = screen.getAllByRole('button', { name: 'Branch from here' })
const branchButtons = screen.getAllByRole('button', { name: 'Fork a new conversation' })
expect(branchButtons).toHaveLength(2)
expect(branchButtons[0]!.closest('[data-message-actions]')).toBe(
screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]')
)
expect(branchButtons[1]!.closest('[data-message-actions]')).toBe(
screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]')
)
expect(branchButtons[1]?.getAttribute('title')).toBe('Fork a new conversation')
fireEvent.click(branchButtons[1]!)
@ -2358,7 +2365,7 @@ describe('MessageList nested tool calls', () => {
const toasts = useUIStore.getState().toasts
expect(toasts[toasts.length - 1]).toMatchObject({
type: 'success',
message: 'Created branched session "Branched session".',
message: 'Created forked conversation "Branched session".',
})
})
@ -2390,7 +2397,7 @@ describe('MessageList nested tool calls', () => {
render(<MessageList />)
expect(screen.queryByRole('button', { name: 'Branch from here' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Fork a new conversation' })).toBeNull()
})
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {

View File

@ -1,5 +1,5 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, GitBranch, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
@ -359,37 +359,6 @@ function SelectableChatMessage({
)
}
function MessageBranchAction({
align,
label,
loading,
onClick,
}: {
align: ChatMessageRole
label: string
loading?: boolean
onClick: () => void
}) {
return (
<div
className={`pointer-events-none relative z-10 -mt-3 h-0 w-full opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100 ${
align === 'user' ? 'flex justify-end pr-1' : 'flex justify-start pl-1'
}`}
>
<button
type="button"
onClick={onClick}
disabled={loading}
aria-label={label}
title={label}
className="pointer-events-auto inline-flex h-7 w-7 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-wait disabled:opacity-60"
>
<GitBranch size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
)
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
parentToolUseId: string,
@ -1263,20 +1232,11 @@ export const MessageBlock = memo(function MessageBlock({
role="user"
content={message.content}
>
<div className="group">
<UserMessage
content={message.content}
attachments={message.attachments}
/>
{branchAction ? (
<MessageBranchAction
align="user"
label={branchAction.label}
loading={branchAction.loading}
onClick={branchAction.onBranch}
/>
) : null}
</div>
<UserMessage
content={message.content}
attachments={message.attachments}
branchAction={branchAction}
/>
</SelectableChatMessage>
)
case 'assistant_text':
@ -1287,17 +1247,7 @@ export const MessageBlock = memo(function MessageBlock({
role="assistant"
content={message.content}
>
<div className="group">
<AssistantMessage content={message.content} />
{branchAction ? (
<MessageBranchAction
align="assistant"
label={branchAction.label}
loading={branchAction.loading}
onClick={branchAction.onBranch}
/>
) : null}
</div>
<AssistantMessage content={message.content} branchAction={branchAction} />
</SelectableChatMessage>
)
case 'thinking':

View File

@ -1,13 +1,14 @@
import type { UIAttachment } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { MessageActionBar } from './MessageActionBar'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
type Props = {
content: string
attachments?: UIAttachment[]
branchAction?: MessageBranchAction
}
export function UserMessage({ content, attachments }: Props) {
export function UserMessage({ content, attachments, branchAction }: Props) {
const hasText = content.trim().length > 0
return (
@ -37,6 +38,7 @@ export function UserMessage({ content, attachments }: Props) {
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
branchAction={branchAction}
align="end"
/>
)}

View File

@ -952,8 +952,8 @@ export const en = {
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
'chat.contextReferencesOnly': 'Added {count} references',
'chat.addSelectionToChat': 'Add to chat',
'chat.branchFromHere': 'Branch from here',
'chat.branchSuccess': 'Created branched session "{title}".',
'chat.branchFromHere': 'Fork a new conversation',
'chat.branchSuccess': 'Created forked conversation "{title}".',
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
'chat.userMessageReference': 'User message',
'chat.assistantMessageReference': 'Assistant message',

View File

@ -954,9 +954,9 @@ export const zh: Record<TranslationKey, string> = {
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加到对话',
'chat.branchFromHere': '从这里派生',
'chat.branchSuccess': '已创建派生会话“{title}”。',
'chat.branchError': '从该消息派生失败。详情:{detail}',
'chat.branchFromHere': 'Fork 一个新对话',
'chat.branchSuccess': '已 Fork 新对话“{title}”。',
'chat.branchError': '从该消息 Fork 新对话失败。详情:{detail}',
'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复',
'chat.slashCommands': '斜杠命令',