From c92417e77f8799d606b2fde45ff27ef979ce8cba Mon Sep 17 00:00:00 2001 From: alelpoan <155192176+alelpoan@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:41:37 +0300 Subject: [PATCH] fix(desktop): Branch button silently does nothing inside a branched chat tile (#71969) * fix: Branch button is a dead no-op inside a branched chat tile session-tile.tsx wired onBranchInNewChat to () => undefined for tiled/branched sessions (nested branching isn't supported there), but the button in AssistantMessage's action bar rendered unconditionally regardless of whether a real handler was supplied. The button looked clickable but silently did nothing, with no visual feedback. - AssistantMessage now only renders the Branch button when onBranchInNewChat is actually provided, matching the existing pattern used for onDismissError/onRestoreToMessage. - session-tile.tsx no longer passes a no-op handler; the prop is simply omitted so the button doesn't render in tiles. - onBranchInNewChat is now optional on ChatViewProps, and the latestChatActions passthrough wrapper uses the existing latestOptional helper instead of an unconditional call. * test: assert Branch button visibility matches handler presence Adds coverage for the bug #2 fix: renders Thread with and without an onBranchInNewChat handler and asserts the Branch in new chat button is shown only when a real handler is supplied, hidden otherwise - covering both the normal open-chat case and the session-tile (branched chat) case that used to leave a dead, clickable button. --- apps/desktop/src/app/chat/index.tsx | 2 +- apps/desktop/src/app/chat/session-tile.tsx | 1 - .../desktop/src/app/contrib/latest-actions.ts | 2 +- .../thread/assistant-message.test.tsx | 90 +++++++++++++++++++ .../assistant-ui/thread/assistant-message.tsx | 20 +++-- 5 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx 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} + > + + + )}