From 8bd122c9672dc745713591ae3312a7c4181f3340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 23:11:03 +0800 Subject: [PATCH] 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 --- .../src/components/chat/AssistantMessage.tsx | 6 +- .../src/components/chat/MessageActionBar.tsx | 39 ++++++++--- .../src/components/chat/MessageList.test.tsx | 13 +++- desktop/src/components/chat/MessageList.tsx | 64 ++----------------- desktop/src/components/chat/UserMessage.tsx | 6 +- desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/zh.ts | 6 +- 7 files changed, 61 insertions(+), 77 deletions(-) diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index 32647c45..06c2ac86 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -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) { diff --git a/desktop/src/components/chat/MessageActionBar.tsx b/desktop/src/components/chat/MessageActionBar.tsx index 3861bd40..993e0243 100644 --- a/desktop/src/components/chat/MessageActionBar.tsx +++ b/desktop/src/components/chat/MessageActionBar.tsx @@ -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 (
- + {hasCopy ? ( + + ) : null} + {branchAction ? ( + + ) : null}
) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index da2c1577..449d7629 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -2339,8 +2339,15 @@ describe('MessageList nested tool calls', () => { render() - 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() - 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 () => { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 35fda7ed..adb380f8 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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 ( -
- -
- ) -} - function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -1263,20 +1232,11 @@ export const MessageBlock = memo(function MessageBlock({ role="user" content={message.content} > -
- - {branchAction ? ( - - ) : null} -
+ ) case 'assistant_text': @@ -1287,17 +1247,7 @@ export const MessageBlock = memo(function MessageBlock({ role="assistant" content={message.content} > -
- - {branchAction ? ( - - ) : null} -
+ ) case 'thinking': diff --git a/desktop/src/components/chat/UserMessage.tsx b/desktop/src/components/chat/UserMessage.tsx index d66dadea..7773e560 100644 --- a/desktop/src/components/chat/UserMessage.tsx +++ b/desktop/src/components/chat/UserMessage.tsx @@ -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) { )} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 43466957..eb60e1fd 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index db472bb9..61cab2ad 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -954,9 +954,9 @@ export const zh: Record = { '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': '斜杠命令',