diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 5fdf5a0487b1..40a8922d453f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -71,7 +71,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onCancel: () => Promise | void onAddContextRef: (refText: string, label?: string, detail?: string) => void onAddUrl: (url: string) => void - onBranchInNewChat: (messageId: string) => void + onBranchInNewChat?: (messageId: string) => void maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index dde25d3d2019..ae618e344e6c 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -170,7 +170,6 @@ function TileChat({ onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAttachDroppedItems={composer.attachDroppedItems} onAttachImageBlob={composer.attachImageBlob} - onBranchInNewChat={() => undefined} onCancel={actions.cancelRun} onDeleteSelectedSession={() => undefined} onDismissError={actions.dismissError} diff --git a/apps/desktop/src/app/contrib/latest-actions.ts b/apps/desktop/src/app/contrib/latest-actions.ts index 4407739398a9..7d191208a27c 100644 --- a/apps/desktop/src/app/contrib/latest-actions.ts +++ b/apps/desktop/src/app/contrib/latest-actions.ts @@ -34,7 +34,7 @@ export function latestChatActions(actions: ChatActions): ChatActions { onAddUrl: (...args) => actions.onAddUrl(...args), onAttachDroppedItems: (...args) => actions.onAttachDroppedItems(...args), onAttachImageBlob: (...args) => actions.onAttachImageBlob(...args), - onBranchInNewChat: (...args) => actions.onBranchInNewChat(...args), + onBranchInNewChat: latestOptional(() => actions.onBranchInNewChat), onCancel: (...args) => actions.onCancel(...args), onDeleteSelectedSession: (...args) => actions.onDeleteSelectedSession(...args), onDismissError: latestOptional(() => actions.onDismissError), diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx new file mode 100644 index 000000000000..8fbe4fc23b90 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx @@ -0,0 +1,90 @@ +// Bug #2: the Branch-in-new-chat button used to render unconditionally even +// when its handler was a no-op (session-tile.tsx passed `() => undefined` +// for branched/tiled chats, where nested branching isn't supported). That +// left a visibly clickable button that silently did nothing. The fix makes +// AssistantMessage's action bar hide the button entirely when no handler is +// supplied, matching how onDismissError/onRestoreToMessage already behave. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Thread } from '.' + +const createdAt = new Date('2026-05-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) +Element.prototype.scrollTo = function scrollTo() {} + +afterEach(() => { + cleanup() +}) + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'question one' }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function Harness({ onBranchInNewChat }: { onBranchInNewChat?: (messageId: string) => void }) { + const runtime = useExternalStoreRuntime({ + messages: [userMessage(), assistantMessage()], + isRunning: false, + onNew: async () => {} + }) + return ( + + + + ) +} + +describe('AssistantMessage branch button visibility (bug #2 fix)', () => { + it('shows the Branch in new chat button when a handler is provided (open chat)', async () => { + render( undefined} />) + + expect(await screen.findByRole('button', { name: 'Branch in new chat' })).toBeTruthy() + }) + + it('hides the Branch in new chat button when no handler is provided (session-tile / branched chat)', async () => { + render() + + // Wait for the assistant message to actually mount before asserting + // absence, so a missing button isn't just a false negative from an + // unrendered message. + await screen.findByText('done') + + expect(screen.queryByRole('button', { name: 'Branch in new chat' })).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 8593ce104de3..3b7d0c8310eb 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -151,15 +151,17 @@ const AssistantActionBar: FC = ({ messageId, getMessageText, data-slot="aui_msg-actions" > - { - triggerHaptic('selection') - onBranchInNewChat?.(messageId) - }} - tooltip={copy.branchNewChat} - > - - + {onBranchInNewChat && ( + { + triggerHaptic('selection') + onBranchInNewChat(messageId) + }} + tooltip={copy.branchNewChat} + > + + + )}