Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onCancel: () => Promise<void> | 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> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/app/chat/session-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/contrib/latest-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onBranchInNewChat={onBranchInNewChat} />
</AssistantRuntimeProvider>
)
}

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(<Harness onBranchInNewChat={() => 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(<Harness />)

// 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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,17 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
data-slot="aui_msg-actions"
>
<MessageAge />
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat?.(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
{onBranchInNewChat && (
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
)}
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ReadAloudButton getText={getMessageText} messageId={messageId} />
<ActionBarPrimitive.Reload asChild>
Expand Down
Loading