mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-03 17:01:15 +08:00
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:
parent
df60c77472
commit
8bd122c967
@ -1,13 +1,14 @@
|
|||||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||||
import { MessageActionBar } from './MessageActionBar'
|
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
|
||||||
import { InlineImageGallery } from './InlineImageGallery'
|
import { InlineImageGallery } from './InlineImageGallery'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
|
branchAction?: MessageBranchAction
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessage({ content, isStreaming }: Props) {
|
export function AssistantMessage({ content, isStreaming, branchAction }: Props) {
|
||||||
if (!content.trim()) return null
|
if (!content.trim()) return null
|
||||||
|
|
||||||
const documentLayout = shouldUseDocumentLayout(content)
|
const documentLayout = shouldUseDocumentLayout(content)
|
||||||
@ -36,6 +37,7 @@ export function AssistantMessage({ content, isStreaming }: Props) {
|
|||||||
<MessageActionBar
|
<MessageActionBar
|
||||||
copyText={isStreaming ? undefined : content}
|
copyText={isStreaming ? undefined : content}
|
||||||
copyLabel="Copy reply"
|
copyLabel="Copy reply"
|
||||||
|
branchAction={branchAction}
|
||||||
align="start"
|
align="start"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,19 +1,28 @@
|
|||||||
|
import { GitFork } from 'lucide-react'
|
||||||
import { CopyButton } from '../shared/CopyButton'
|
import { CopyButton } from '../shared/CopyButton'
|
||||||
|
|
||||||
|
export type MessageBranchAction = {
|
||||||
|
label: string
|
||||||
|
loading?: boolean
|
||||||
|
onBranch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
copyText?: string
|
copyText?: string
|
||||||
copyLabel: string
|
copyLabel: string
|
||||||
|
branchAction?: MessageBranchAction
|
||||||
align?: 'start' | 'end'
|
align?: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageActionBar({
|
export function MessageActionBar({
|
||||||
copyText,
|
copyText,
|
||||||
copyLabel,
|
copyLabel,
|
||||||
|
branchAction,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const hasCopy = Boolean(copyText?.trim())
|
const hasCopy = Boolean(copyText?.trim())
|
||||||
|
|
||||||
if (!hasCopy) return null
|
if (!hasCopy && !branchAction) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -24,6 +33,7 @@ export function MessageActionBar({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasCopy ? (
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={copyText!}
|
text={copyText!}
|
||||||
label={copyLabel}
|
label={copyLabel}
|
||||||
@ -31,6 +41,19 @@ export function MessageActionBar({
|
|||||||
displayCopiedLabel="Copied"
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2339,8 +2339,15 @@ describe('MessageList nested tool calls', () => {
|
|||||||
|
|
||||||
render(<MessageList />)
|
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).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]!)
|
fireEvent.click(branchButtons[1]!)
|
||||||
|
|
||||||
@ -2358,7 +2365,7 @@ describe('MessageList nested tool calls', () => {
|
|||||||
const toasts = useUIStore.getState().toasts
|
const toasts = useUIStore.getState().toasts
|
||||||
expect(toasts[toasts.length - 1]).toMatchObject({
|
expect(toasts[toasts.length - 1]).toMatchObject({
|
||||||
type: 'success',
|
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 />)
|
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 () => {
|
it('keeps historical sessions readable when turn checkpoint payloads are missing', async () => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
|
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 { ApiError } from '../../api/client'
|
||||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
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(
|
function appendChildToolCall(
|
||||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||||
parentToolUseId: string,
|
parentToolUseId: string,
|
||||||
@ -1263,20 +1232,11 @@ export const MessageBlock = memo(function MessageBlock({
|
|||||||
role="user"
|
role="user"
|
||||||
content={message.content}
|
content={message.content}
|
||||||
>
|
>
|
||||||
<div className="group">
|
|
||||||
<UserMessage
|
<UserMessage
|
||||||
content={message.content}
|
content={message.content}
|
||||||
attachments={message.attachments}
|
attachments={message.attachments}
|
||||||
|
branchAction={branchAction}
|
||||||
/>
|
/>
|
||||||
{branchAction ? (
|
|
||||||
<MessageBranchAction
|
|
||||||
align="user"
|
|
||||||
label={branchAction.label}
|
|
||||||
loading={branchAction.loading}
|
|
||||||
onClick={branchAction.onBranch}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</SelectableChatMessage>
|
</SelectableChatMessage>
|
||||||
)
|
)
|
||||||
case 'assistant_text':
|
case 'assistant_text':
|
||||||
@ -1287,17 +1247,7 @@ export const MessageBlock = memo(function MessageBlock({
|
|||||||
role="assistant"
|
role="assistant"
|
||||||
content={message.content}
|
content={message.content}
|
||||||
>
|
>
|
||||||
<div className="group">
|
<AssistantMessage content={message.content} branchAction={branchAction} />
|
||||||
<AssistantMessage content={message.content} />
|
|
||||||
{branchAction ? (
|
|
||||||
<MessageBranchAction
|
|
||||||
align="assistant"
|
|
||||||
label={branchAction.label}
|
|
||||||
loading={branchAction.loading}
|
|
||||||
onClick={branchAction.onBranch}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</SelectableChatMessage>
|
</SelectableChatMessage>
|
||||||
)
|
)
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import type { UIAttachment } from '../../types/chat'
|
import type { UIAttachment } from '../../types/chat'
|
||||||
import { AttachmentGallery } from './AttachmentGallery'
|
import { AttachmentGallery } from './AttachmentGallery'
|
||||||
import { MessageActionBar } from './MessageActionBar'
|
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
attachments?: UIAttachment[]
|
attachments?: UIAttachment[]
|
||||||
|
branchAction?: MessageBranchAction
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessage({ content, attachments }: Props) {
|
export function UserMessage({ content, attachments, branchAction }: Props) {
|
||||||
const hasText = content.trim().length > 0
|
const hasText = content.trim().length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -37,6 +38,7 @@ export function UserMessage({ content, attachments }: Props) {
|
|||||||
<MessageActionBar
|
<MessageActionBar
|
||||||
copyText={content}
|
copyText={content}
|
||||||
copyLabel="Copy prompt"
|
copyLabel="Copy prompt"
|
||||||
|
branchAction={branchAction}
|
||||||
align="end"
|
align="end"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -952,8 +952,8 @@ export const en = {
|
|||||||
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
||||||
'chat.contextReferencesOnly': 'Added {count} references',
|
'chat.contextReferencesOnly': 'Added {count} references',
|
||||||
'chat.addSelectionToChat': 'Add to chat',
|
'chat.addSelectionToChat': 'Add to chat',
|
||||||
'chat.branchFromHere': 'Branch from here',
|
'chat.branchFromHere': 'Fork a new conversation',
|
||||||
'chat.branchSuccess': 'Created branched session "{title}".',
|
'chat.branchSuccess': 'Created forked conversation "{title}".',
|
||||||
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
|
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
|
||||||
'chat.userMessageReference': 'User message',
|
'chat.userMessageReference': 'User message',
|
||||||
'chat.assistantMessageReference': 'Assistant message',
|
'chat.assistantMessageReference': 'Assistant message',
|
||||||
|
|||||||
@ -954,9 +954,9 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||||
'chat.addSelectionToChat': '添加到对话',
|
'chat.addSelectionToChat': '添加到对话',
|
||||||
'chat.branchFromHere': '从这里派生',
|
'chat.branchFromHere': 'Fork 一个新对话',
|
||||||
'chat.branchSuccess': '已创建派生会话“{title}”。',
|
'chat.branchSuccess': '已 Fork 新对话“{title}”。',
|
||||||
'chat.branchError': '从该消息派生失败。详情:{detail}',
|
'chat.branchError': '从该消息 Fork 新对话失败。详情:{detail}',
|
||||||
'chat.userMessageReference': '用户消息',
|
'chat.userMessageReference': '用户消息',
|
||||||
'chat.assistantMessageReference': 'AI 回复',
|
'chat.assistantMessageReference': 'AI 回复',
|
||||||
'chat.slashCommands': '斜杠命令',
|
'chat.slashCommands': '斜杠命令',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user