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: 2 additions & 0 deletions .playwright/scripts/__tests__/conversation-canvas.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expect, test } from '../fixtures/electron.mjs'
async function prepare(buddy, label) {
const instance = await buddy.createInstance(label)
const desktop = await instance.launch()
await expect.poll(async () => (await desktop.page.evaluate(() => window.lexoraDesktop.localChat.runtime.getStatus())).status).toBe('ready')
await desktop.app.evaluate(({ BrowserWindow, ipcMain }, databasePath) => {
BrowserWindow.getAllWindows()[0].setSize(2000, 1100)
const { DatabaseSync } = process.getBuiltinModule('node:sqlite')
Expand Down Expand Up @@ -72,6 +73,7 @@ test('canvas actions, zoom and node lifetimes stay scoped across split panes and
await first.getByTestId('conversation-canvas-toggle').click()
await expect(first.locator('.conversation-node')).toHaveCount(1)
await first.getByTestId('pane-layout-menu').click()
await page.locator('.n-dropdown-menu:visible').getByText('分屏', { exact: true }).hover()
await page.locator('.n-dropdown-menu:visible').getByText('向右分屏', { exact: true }).click()
await task(page, 'b').click()
const second = pane(page, 'Canvas B')
Expand Down
12 changes: 9 additions & 3 deletions apps/buddy/src/i18n/locales/en-US/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ export default {
'desktop.canvas.failed': 'Failed',
'desktop.canvas.cancelled': 'Stopped',
'desktop.canvas.completed': 'Completed',
'desktop.canvas.followup': 'Follow up on this answer',
'desktop.canvas.retry': 'Regenerate answer',
'desktop.canvas.writeInComposer': 'Continue in the composer',
'desktop.canvas.generating': 'Generating an answer…',
Expand All @@ -77,8 +76,6 @@ export default {
'desktop.canvas.resetZoom': 'Reset zoom to 100%',
'desktop.canvas.loading': 'Loading canvas…',
'desktop.canvas.reload': 'Reload',
'desktop.canvas.composerTarget': 'Following up on this answer',
'desktop.canvas.cancelFollowup': 'Back to current branch',
'activity.status.completed': 'Completed',
'activity.status.failed': 'Failed',
'activity.status.running': 'Running',
Expand Down Expand Up @@ -318,6 +315,15 @@ export default {
'desktop.chat.runtimeRestart': 'Restart',
'desktop.chat.runtimeStarting': 'Starting local runtime',
'desktop.chat.regenerate': 'Regenerate',
'desktop.chat.followup': 'Branch from here',
'desktop.chat.followupTarget': 'Branch from this answer',
'desktop.chat.followupHint': 'Use the conversation up to this answer only. Sending creates a new branch and keeps the original conversation.',
'desktop.chat.followupSource': 'Selected answer',
'desktop.chat.locateFollowup': 'View origin',
'desktop.chat.cancelFollowup': 'Cancel branch',
'desktop.chat.cancelFollowupHint': 'Return to the current branch and keep this follow-up draft.',
'desktop.chat.branchPosition': 'Branch {index} / {count}',
'desktop.chat.branchNavigationHint': 'Switch the conversation after this point. Other branches are kept.',
'desktop.chat.nextBranch': 'Next branch',
'desktop.chat.previousBranch': 'Previous branch',
'desktop.chat.editingHistoryMessage': 'Editing a historical message',
Expand Down
12 changes: 9 additions & 3 deletions apps/buddy/src/i18n/locales/zh-CN/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export default {
'desktop.canvas.failed': '失败',
'desktop.canvas.cancelled': '已停止',
'desktop.canvas.completed': '已完成',
'desktop.canvas.followup': '从这条回答追问',
'desktop.canvas.retry': '重新生成回答',
'desktop.canvas.writeInComposer': '在输入框继续提问',
'desktop.canvas.generating': '正在生成回答…',
Expand All @@ -75,8 +74,6 @@ export default {
'desktop.canvas.resetZoom': '恢复 100% 缩放',
'desktop.canvas.loading': '正在读取画布…',
'desktop.canvas.reload': '重新加载',
'desktop.canvas.composerTarget': '正在这条回答后追问',
'desktop.canvas.cancelFollowup': '返回当前分支',
'activity.status.completed': '完成',
'activity.status.failed': '失败',
'activity.status.running': '进行中',
Expand Down Expand Up @@ -316,6 +313,15 @@ export default {
'desktop.chat.runtimeRestart': '重新启动',
'desktop.chat.runtimeStarting': '正在启动本地运行时',
'desktop.chat.regenerate': '重新生成',
'desktop.chat.followup': '从这里分支',
'desktop.chat.followupTarget': '从这条回答创建分支',
'desktop.chat.followupHint': '只沿用到这条回答为止的对话。发送后创建新分支,原对话保留。',
'desktop.chat.followupSource': '所选回答',
'desktop.chat.locateFollowup': '查看起点',
'desktop.chat.cancelFollowup': '取消分支',
'desktop.chat.cancelFollowupHint': '返回当前分支,保留这份追问草稿。',
'desktop.chat.branchPosition': '分支 {index} / {count}',
'desktop.chat.branchNavigationHint': '切换此处之后的对话,其他分支仍会保留。',
'desktop.chat.nextBranch': '下一条分支',
'desktop.chat.previousBranch': '上一条分支',
'desktop.chat.editingHistoryMessage': '正在编辑历史消息',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { LocalConversationTreeNode } from '@buddy-shared/conversation/conversationTree'

export type ConversationCanvasDirection = 'horizontal' | 'vertical'
export type ConversationCanvasNode = Omit<LocalConversationTreeNode, 'kind'> & { kind: LocalConversationTreeNode['kind'] | 'draft' }
export type ConversationCanvasNode = Omit<LocalConversationTreeNode, 'kind'> & {
kind: LocalConversationTreeNode['kind'] | 'draft'
canFollowup?: boolean
}
export interface ConversationNodePosition { x: number, y: number, width: number, height: number }

export function layoutConversationCanvas(nodes: readonly ConversationCanvasNode[], direction: ConversationCanvasDirection): ReadonlyMap<string, ConversationNodePosition> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { ChatTranscriptMessageRow, ChatTranscriptTurnResultRow } from '../chatTranscriptTypes'
import { describe, expect, it } from 'vitest'
import { getChatMessageFollowupSourceId, projectChatAgentTurnActions, projectChatFollowupMessageIds, projectChatMessageActions } from '../chatMessageActions'

function reply(id: string, runId: string, patch: Partial<ChatTranscriptMessageRow> = {}): ChatTranscriptMessageRow {
return {
key: id,
kind: 'message',
message: {
id,
runId,
role: 'assistant',
branchId: 'branch',
conversationId: 'conversation',
attachments: [],
content: { text: id },
createdAt: '2026-09-08T00:00:00.000Z',
},
turnOutputs: null,
...patch,
}
}

function question(id: string): ChatTranscriptMessageRow {
const row = reply(id, 'unused')
return { ...row, message: { ...row.message, role: 'user', runId: null } }
}

function result(runId: string, finalMessageId: string): ChatTranscriptTurnResultRow {
return {
key: `result-${runId}`,
kind: 'turn-result',
turn: {
branchId: 'branch',
completedAt: '2026-09-08T00:00:01.000Z',
finalMessageId,
nodes: [],
processMessageIds: [],
progress: null,
reasoningLevel: null,
runId,
startedAt: '2026-09-08T00:00:00.000Z',
status: 'completed',
triggeringMessageId: 'question',
usage: null,
},
}
}

describe('chat turn branch origins', () => {
it('offers history branching only after another public message, not for the current turn or trailing result metadata', () => {
const outcome = result('root', 'final')
const source = reply('final', 'root', { turnResult: outcome.turn })
const completed = new Set(['root'])
expect([...projectChatFollowupMessageIds([source], completed)]).toEqual([])
expect([...projectChatFollowupMessageIds([reply('final', 'root'), outcome], completed)]).toEqual([])
expect([...projectChatFollowupMessageIds([source, question('next')], completed)]).toEqual(['final'])
expect(projectChatAgentTurnActions(outcome.turn, false, true).showFollowup).toBe(false)
expect(projectChatAgentTurnActions(outcome.turn, false, true, true).showFollowup).toBe(true)
})

it('keeps branching at the root result even when the toolbar is presented on a partner reply', () => {
const firstPartner = reply('first-partner-reply', 'first-partner')
const finalPartner = reply('last-partner-reply', 'last-partner', { turnResult: result('root', 'root-final').turn })
const completed = new Set(['root', 'first-partner', 'last-partner'])
expect([...projectChatFollowupMessageIds([firstPartner, finalPartner], completed)]).toEqual([])
expect([...projectChatFollowupMessageIds([firstPartner, finalPartner, question('next')], completed)]).toEqual(['root-final'])
expect(getChatMessageFollowupSourceId(firstPartner)).toBeNull()
expect(getChatMessageFollowupSourceId(finalPartner)).toBe('root-final')
expect(projectChatMessageActions(firstPartner.message, false, false, true)).toMatchObject({
showCopy: true,
showTime: true,
showRegenerate: false,
showFollowup: false,
})
expect(projectChatMessageActions(finalPartner.message, false, true, true)).toMatchObject({
showRegenerate: true,
showFollowup: true,
})
})

it('keeps independent root turns separate while ignoring completed partner replies', () => {
const rows = [
reply('root-final', 'root', { turnResult: result('root', 'root-final').turn }),
reply('partner-final', 'partner'),
question('second-question'),
reply('second-final', 'second', { turnResult: result('second', 'second-final').turn }),
question('next'),
]
expect([...projectChatFollowupMessageIds(rows, new Set(['root', 'second', 'partner']))]).toEqual(['root-final', 'second-final'])
})

it('preserves a historical completed result without a visible body, using its own final message as the source', () => {
const outcome = result('root', 'empty-root-final')
const partner = reply('partner-final', 'partner')
expect([...projectChatFollowupMessageIds([partner, outcome], new Set(['root', 'partner']))]).toEqual([])
expect([...projectChatFollowupMessageIds([partner, outcome, question('next')], new Set(['root', 'partner']))]).toEqual(['empty-root-final'])
})

it.each([
{ streaming: true as const },
{ isIntermediate: true as const },
{ showActions: false as const },
])('does not branch from an unfinished or hidden result: %j', (patch) => {
expect([...projectChatFollowupMessageIds([
reply('last', 'root', { turnResult: result('root', 'last').turn, ...patch }),
question('next'),
], new Set(['root']))]).toEqual([])
})

it('keeps turn controls disabled during mutation and excludes messages without an execution', () => {
const message = reply('final', 'root').message
expect(projectChatMessageActions(message, true, true, true)).toMatchObject({ disabled: true, showFollowup: true })
expect(projectChatMessageActions({ ...message, runId: null }, false, true, true).showFollowup).toBe(false)
expect(projectChatMessageActions({ ...message, role: 'user' }, false, true, true).showFollowup).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ describe('projectChatMessageBranchNavigators', () => {
nextBranchId: 'branch-retry',
previousBranchId: null,
})
expect(projectChatAgentTurnActions(turn, false, true)).toEqual({ disabled: false, showCopy: status !== 'completed', showEdit: false, showRegenerate: true, showTime: true })
expect(projectChatAgentTurnActions(turn, false, false)).toEqual({ disabled: false, showCopy: false, showEdit: false, showRegenerate: false, showTime: false })
expect(projectChatAgentTurnActions(turn, false, true, true)).toEqual({ disabled: false, showCopy: status !== 'completed', showEdit: false, showFollowup: status === 'completed', showRegenerate: true, showTime: true })
expect(projectChatAgentTurnActions(turn, false, false)).toEqual({ disabled: false, showCopy: false, showEdit: false, showFollowup: false, showRegenerate: false, showTime: false })
})

it('attaches edited user-input siblings to the edited user message', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,67 @@
import type { LocalMessage } from '@buddy-shared/conversation/conversationApi'

import type { ChatAgentTurn } from './chatAgentTurn'
import type { ChatTranscriptMessageRow, ChatTranscriptRow } from './chatTranscriptTypes'

export interface ChatMessageActions {
disabled: boolean
showCopy: boolean
showEdit: boolean
showFollowup: boolean
showRegenerate: boolean
showTime: boolean
}

export function getChatMessageFollowupSourceId(row: Pick<ChatTranscriptMessageRow, 'message' | 'turnResult'> & { ownsResultActions?: boolean }): string | null {
return row.turnResult?.finalMessageId ?? (row.ownsResultActions ? row.message.id : null)
}

export function projectChatFollowupMessageIds(
rows: readonly ChatTranscriptRow[],
completedRunIds: ReadonlySet<string>,
): ReadonlySet<string> {
const finalMessages = new Map<string, { messageId: string, index: number, available: boolean }>()
let lastMessageIndex = -1
for (const [index, row] of rows.entries()) {
if (row.kind === 'message' && (row.message.role === 'user' || row.message.role === 'assistant')) {
lastMessageIndex = index
const messageId = getChatMessageFollowupSourceId(row)
const runId = row.turnResult?.runId ?? row.message.runId
if (row.message.role === 'assistant' && messageId && runId) {
finalMessages.set(runId, {
messageId,
index,
available: !row.streaming && !row.isIntermediate && row.showActions !== false,
})
}
}
else if ((row.kind === 'turn-result' || (row.kind === 'agent-turn' && row.ownsResultActions)) && row.turn.finalMessageId) {
finalMessages.set(row.turn.runId, {
messageId: row.turn.finalMessageId,
index,
available: row.turn.status === 'completed',
})
}
}
return new Set([...finalMessages].flatMap(([runId, source]) =>
completedRunIds.has(runId) && source.available && source.index < lastMessageIndex
? [source.messageId]
: [],
))
}

export function projectChatMessageActions(
message: LocalMessage,
disabled: boolean,
ownsResultActions: boolean,
canFollowup = false,
): ChatMessageActions {
const lifecycleMessage = message.role === 'user' || message.role === 'assistant'
return {
disabled,
showCopy: lifecycleMessage,
showEdit: message.role === 'user',
showFollowup: ownsResultActions && canFollowup && message.role === 'assistant' && message.runId !== null,
showRegenerate: ownsResultActions && message.role === 'assistant' && message.runId !== null,
showTime: lifecycleMessage,
}
Expand All @@ -29,13 +71,15 @@ export function projectChatAgentTurnActions(
turn: ChatAgentTurn,
disabled: boolean,
ownsResultActions: boolean,
canFollowup = false,
): ChatMessageActions {
const showActions = ownsResultActions
&& (turn.status === 'completed' || turn.status === 'failed' || turn.status === 'cancelled')
return {
disabled,
showCopy: showActions && turn.status !== 'completed',
showEdit: false,
showFollowup: canFollowup && showActions && turn.status === 'completed' && turn.finalMessageId !== null,
showRegenerate: showActions,
showTime: showActions,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import type { Node } from '@antv/x6'
import type { ConversationCanvasData } from './conversationCanvasContext'
import { Add20Regular, ArrowSync20Regular, Document20Regular, Keyboard20Regular, Wand20Regular } from '@vicons/fluent'
import { Document20Regular, Keyboard20Regular, Wand20Regular } from '@vicons/fluent'
import { useIntervalFn } from '@vueuse/core'
import { NTooltip } from 'naive-ui'
import { computed, inject, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'
import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue'
Expand Down Expand Up @@ -74,22 +75,37 @@ function open(event: MouseEvent) {
>
<div class="conversation-node__card" :class="{ 'buddy-border-beam': message.status === 'running' }">
<header class="conversation-node__header">
<DesktopIcon class="conversation-node__role" :component="message.kind === 'answer' ? Wand20Regular : Keyboard20Regular" :title="label" />
<DesktopIcon class="conversation-node__role" :component="message.kind === 'answer' ? Wand20Regular : Keyboard20Regular" />
<span v-if="message.toolCount" class="conversation-node__tools">{{ t('desktop.canvas.tools', { count: message.toolCount }) }}</span>
<div class="conversation-node__trailing flex flex-none items-center gap-[6px] ml-auto">
<span v-if="message.kind === 'draft'" class="conversation-node__status">{{ label }}</span>
<span v-if="message.status && message.status !== 'completed'" class="conversation-node__status" :class="message.status">{{ t(`desktop.canvas.${message.status}`) }}</span>
<div v-if="!simplified && message.kind !== 'draft' && !busy" class="conversation-node__actions" role="toolbar" :aria-label="t('desktop.canvas.nodeActions')" @pointerdown.stop @mousedown.stop>
<button v-if="message.kind === 'question'" type="button" :disabled="!data.canMutate" :aria-label="t('desktop.chat.editMessage')" :title="t('desktop.chat.editMessage')" data-testid="canvas-node-edit" @click.stop="actions.edit(message.id)">
<DesktopIcon name="edit" />
</button>
<NTooltip v-if="message.kind === 'question'" placement="top">
<template #trigger>
<button type="button" :disabled="!data.canMutate" :aria-label="t('desktop.chat.editMessage')" data-testid="canvas-node-edit" @click.stop="actions.edit(message.id)">
<DesktopIcon name="edit" />
</button>
</template>
{{ t('desktop.chat.editMessage') }}
</NTooltip>
<template v-else>
<button type="button" :disabled="!data.canMutate" :aria-label="t('desktop.canvas.retry')" :title="t('desktop.canvas.retry')" data-testid="canvas-node-retry" @click.stop="actions.retry(message.id)">
<DesktopIcon :component="ArrowSync20Regular" />
</button>
<button v-if="message.status === 'completed'" type="button" :disabled="!data.canMutate" :aria-label="t('desktop.canvas.followup')" :title="t('desktop.canvas.followup')" data-testid="canvas-node-followup" @click.stop="actions.followup(message.id)">
<DesktopIcon :component="Add20Regular" />
</button>
<NTooltip placement="top">
<template #trigger>
<button type="button" :disabled="!data.canMutate" :aria-label="t('desktop.canvas.retry')" data-testid="canvas-node-retry" @click.stop="actions.retry(message.id)">
<DesktopIcon name="messageRetry" />
</button>
</template>
{{ t('desktop.canvas.retry') }}
</NTooltip>
<NTooltip v-if="message.status === 'completed' && message.messageId && message.canFollowup !== false" placement="top">
<template #trigger>
<button type="button" :disabled="!data.canMutate" :aria-label="t('desktop.chat.followup')" data-testid="canvas-node-followup" @click.stop="actions.followup(message.id)">
<DesktopIcon name="messageBranch" />
</button>
</template>
{{ t('desktop.chat.followup') }}
</NTooltip>
</template>
</div>
</div>
Expand Down
Loading