)}
- {pageTab === 'plans' && (
+ {hasHeartbeatActivity && (
)}
- {pageTab === 'plans' &&
diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx
index deaa7d7d..a9bd550a 100644
--- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx
+++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx
@@ -154,6 +154,7 @@ function sidebarElement({
conversationTitles = new Map(),
supervisionEnabled,
supervisionLibraries,
+ onOpenSupervisionConversation,
conversationStats,
taskDurations,
selectedTaskId,
@@ -195,6 +196,7 @@ function sidebarElement({
conversationTitles?: ReadonlyMap
supervisionEnabled?: boolean
supervisionLibraries?: React.ComponentProps['supervisionLibraries']
+ onOpenSupervisionConversation?: React.ComponentProps['onOpenSupervisionConversation']
conversationStats?: React.ComponentProps['conversationStats']
taskDurations?: React.ComponentProps['taskDurations']
selectedTaskId?: string
@@ -216,6 +218,7 @@ function sidebarElement({
activeConversationId={activeConversationId}
supervisionEnabled={supervisionEnabled}
supervisionLibraries={supervisionLibraries}
+ onOpenSupervisionConversation={onOpenSupervisionConversation}
conversationStats={conversationStats}
taskDurations={taskDurations}
selectedTaskId={selectedTaskId}
@@ -321,33 +324,25 @@ it('shows the task name while following and pinned, without displaying its raw I
await waitFor(() => expect(overview).toHaveBeenLastCalledWith({ target: { type: 'task', taskId: 'task-uuid' } }))
})
-it.each(['cancel', 'failure'] as const)('retries supervision knowledge preview after %s and shows returned content', async (outcome) => {
- const knowledgePreview = vi.fn(async () => ({ previewId: 'preview', libraryId: 'library', operation: 'create-entity',
- entity: { label: 'Returned label', type: 'Returned type', description: 'Returned description', aliases: ['Returned alias'] },
- source: { title: 'Returned source', content: 'Returned evidence' } }))
- const knowledgeCommit = vi.fn(async () => ({}))
- if (outcome === 'failure') knowledgeCommit.mockRejectedValueOnce(new Error('Write failed'))
+it('previews supervision sources without navigating and omits legacy entity writeback controls', async () => {
+ const knowledgePreview = vi.fn()
+ const knowledgeCommit = vi.fn()
+ const onOpenSupervisionConversation = vi.fn()
Object.assign(window.goodbuddy, { supervision: {
overview: vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: 'source', summary: 'Summary', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } }]),
- sourceContext: vi.fn(async () => ({ id: 'source', title: 'Source', content: 'Content' })), knowledgePreview, knowledgeCommit
+ sourceContext: vi.fn(async () => ({ id: 'source', title: 'Source', content: 'Content', contextType: 'conversation', conversationId: 'source-conversation' })), knowledgePreview, knowledgeCommit
} })
- const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(outcome !== 'cancel').mockReturnValue(true)
- try {
- renderSidebar({ activeConversationId: 'conversation', supervisionEnabled: true, supervisionLibraries: [{ id: 'library', name: 'Target library' } as never] })
- fireEvent.click(await screen.findByRole('button', { name: '查看来源' }))
- const button = await screen.findByRole('button', { name: '预览并写入实体' })
- fireEvent.click(button)
- await waitFor(() => expect(confirm).toHaveBeenCalledOnce())
- for (const text of ['Target library', 'Returned label', 'Returned type', 'Returned description', 'Returned alias', 'Returned source', 'Returned evidence']) {
- expect(confirm.mock.calls[0]![0]).toContain(text)
- }
- await waitFor(() => expect(button).toBeEnabled())
- expect(knowledgeCommit).toHaveBeenCalledTimes(outcome === 'cancel' ? 0 : 1)
- fireEvent.click(button)
- expect(await screen.findByText('知识实体已写入')).toBeInTheDocument()
- expect(knowledgePreview).toHaveBeenCalledTimes(2)
- expect(button).toBeEnabled()
- } finally { confirm.mockRestore() }
+ renderSidebar({ activeConversationId: 'conversation', supervisionEnabled: true, supervisionLibraries: [{ id: 'library', name: 'Target library' } as never], onOpenSupervisionConversation })
+ fireEvent.click(await screen.findByRole('button', { name: '查看来源' }))
+ expect(await screen.findByText('Content')).toBeInTheDocument()
+ expect(onOpenSupervisionConversation).not.toHaveBeenCalled()
+ const card = within(screen.getByRole('region', { name: '监督反馈' }))
+ expect(card.queryByRole('combobox')).not.toBeInTheDocument()
+ expect(card.queryByRole('button', { name: '预览并写入实体' })).not.toBeInTheDocument()
+ fireEvent.click(card.getByRole('button', { name: '打开会话' }))
+ expect(onOpenSupervisionConversation).toHaveBeenCalledExactlyOnceWith('source-conversation')
+ expect(knowledgePreview).not.toHaveBeenCalled()
+ expect(knowledgeCommit).not.toHaveBeenCalled()
})
describe('RightAssistantSidebar tab titles', () => {
diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx
index 5324da37..e8fae5da 100644
--- a/src/renderer/src/RightAssistantSidebar.tsx
+++ b/src/renderer/src/RightAssistantSidebar.tsx
@@ -244,7 +244,6 @@ function SupervisionCardContent({
taskTitle,
pinned = false,
onTogglePinned,
- supervisionLibraries: libraries,
onContinueSupervision: onContinue,
onOpenSupervisionGraph,
onOpenSupervisionConversation: onOpenConversation
@@ -256,7 +255,9 @@ function SupervisionCardContent({
const [source, setSource] = useState>()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
- const [libraryId, setLibraryId] = useState(libraries?.[0]?.id ?? '')
+ const [contextPrompt, setContextPrompt] = useState()
+ const [question, setQuestion] = useState('')
+ const busy = useRef(false)
const targetKey = target ? JSON.stringify(target) : undefined
useEffect(() => {
@@ -272,56 +273,45 @@ function SupervisionCardContent({
}, [targetKey, refresh])
const openSource = async (sourceId: string): Promise => {
+ if (busy.current) return
+ busy.current = true
setLoading(true); setMessage('')
try {
if (!sourceId) throw new Error('监督结果没有可定位来源')
const context = await window.goodbuddy.supervision.sourceContext(sourceId)
if (!mounted.current) return
setSource(context)
- if (context.contextType === 'conversation' && context.conversationId) {
- onOpenConversation?.(String(context.conversationId))
- }
} catch (error) {
- setMessage(error instanceof Error ? error.message : '来源不可用')
- } finally { setLoading(false) }
+ if (mounted.current) setMessage(error instanceof Error ? error.message : '来源不可用')
+ } finally { busy.current = false; if (mounted.current) setLoading(false) }
}
const continueDiscussion = async (): Promise => {
- if (!result || !source || !activeConversationId || !onContinue) return
+ if (!result || !activeConversationId || !onContinue || busy.current) return
+ busy.current = true
setLoading(true); setMessage('')
try {
- const preview = await window.goodbuddy.supervision.continueContext({ sourceId: String(source.id), resultId: result.id })
+ const sourceId = String(result.sourceId ?? '')
+ if (!sourceId) throw new Error('监督结果没有可定位来源')
+ const preview = await window.goodbuddy.supervision.continueContext({ sourceId, resultId: result.id })
if (!mounted.current) return
- if (!window.confirm(`确认将以下监督上下文发送到会话「${conversationTitle ?? activeConversationId}」 (${activeConversationId})?\n\n` + String(preview.prompt ?? ''))) return
- await onContinue(String(preview.prompt ?? ''), activeConversationId)
- } catch (error) { setMessage(error instanceof Error ? error.message : '继续讨论失败') }
- finally { setLoading(false) }
+ if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty'))
+ setContextPrompt(preview.prompt)
+ setQuestion(t('supervisor.discussion.defaultQuestion'))
+ } catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
+ finally { busy.current = false; if (mounted.current) setLoading(false) }
}
- const commitEntity = async (): Promise => {
- if (!result || !source || !libraryId) return
+ const sendDiscussion = async (): Promise => {
+ if (!contextPrompt || !question.trim() || !activeConversationId || !onContinue || busy.current) return
+ busy.current = true
setLoading(true); setMessage('')
try {
- const preview = await window.goodbuddy.supervision.knowledgePreview({
- operation: 'create-entity', libraryId, label: '监督回顾', type: '概念',
- description: String(result.summary ?? ''), aliases: [], sourceId: String(source.id)
- })
- const nextPreviewId = String(preview.previewId ?? '')
+ await onContinue(`${contextPrompt}\n\n${question}`, activeConversationId)
if (!mounted.current) return
- const entity = preview.entity as Record
- const evidence = preview.source as Record
- if (window.confirm([
- '确认写入以下知识实体预览?',
- `知识库:${libraries?.find((library) => library.id === preview.libraryId)?.name ?? preview.libraryId}`,
- `名称:${entity.label}`, `类型:${entity.type}`,
- `描述:${entity.description}`, `别名:${(entity.aliases as string[]).join('、') || '无'}`,
- `来源:${evidence.title}`, String(evidence.content ?? '')
- ].join('\n\n'))) {
- await window.goodbuddy.supervision.knowledgeCommit({ previewId: nextPreviewId })
- setMessage('知识实体已写入')
- }
- } catch (error) { setMessage(error instanceof Error ? error.message : '知识库提交失败') }
- finally { setLoading(false) }
+ setContextPrompt(undefined); setQuestion('')
+ } catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
+ finally { busy.current = false; if (mounted.current) setLoading(false) }
}
return
@@ -329,7 +319,7 @@ function SupervisionCardContent({
监督反馈
{onTogglePinned &&
}
-
+
{target &&
{pinned ? '已固定 · ' : ''}{target.type === 'conversation' ? `会话:${conversationTitle?.trim() || '未命名会话'}` : `任务:${taskTitle?.trim() || '未命名任务'}`}
}
@@ -340,10 +330,21 @@ function SupervisionCardContent({
{onOpenSupervisionGraph && }
-
+
- {source &&
{String(source.title ?? '来源')}{String(source.content ?? source.error ?? '')}
{source.error !== undefined &&
{String(source.error)}}
}
- {libraries && libraries.length > 0 && source &&
}
+ {source &&
{String(source.title ?? '来源')}{String(source.content ?? source.error ?? '')}
{source.error !== undefined &&
{String(source.error)}}
+ {source.contextType === 'conversation' && Boolean(source.conversationId) && onOpenConversation &&
}
+
}
+ {contextPrompt !== undefined &&
+
{t('supervisor.discussion.target', { title: conversationTitle?.trim() || '未命名会话' })}
+
+
{t('supervisor.discussion.contextHint')}
+
+
+
+
+
}
+ {loading &&
{t('supervisor.discussion.loading')}
}
{message &&
{message}
}
>}
diff --git a/src/renderer/src/SupervisionCard.test.tsx b/src/renderer/src/SupervisionCard.test.tsx
index 8e54fad6..0c786f89 100644
--- a/src/renderer/src/SupervisionCard.test.tsx
+++ b/src/renderer/src/SupervisionCard.test.tsx
@@ -55,23 +55,96 @@ it('supervision ignores old target responses and never falls back to a global re
expect(screen.getByText('暂无监督回顾')).toBeInTheDocument()
})
-it('supervision previews and sends to the explicit conversation and discards stale previews', async () => {
+it('prepares context without viewing source and sends the edited question only on Send', async () => {
const send = vi.fn(async () => undefined)
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
+ const sourceContext = vi.fn()
+ const continueContext = vi.fn().mockResolvedValue({ prompt: 'Actual prompt\n Exact context' })
+ window.goodbuddy = { supervision: { overview: async () => [result('A')], sourceContext, continueContext } } as never
+ render(
)
+ fireEvent.click(await screen.findByRole('button', { name: '继续讨论' }))
+ const question = await screen.findByRole('textbox', { name: '继续讨论的问题' })
+ expect(continueContext).toHaveBeenCalledWith({ sourceId: 'source-A', resultId: 'A' })
+ expect(sourceContext).not.toHaveBeenCalled()
+ expect(send).not.toHaveBeenCalled()
+ expect(screen.getByText('发送到会话:Pinned A')).toHaveAttribute('title', 'fixed-A')
+ expect(screen.getByText('发送时会附带相关回顾与来源。')).toBeVisible()
+ expect(screen.queryByText(/Actual prompt/)).not.toBeInTheDocument()
+ fireEvent.change(question, { target: { value: 'What next?\nKeep this detail.' } })
+ expect(send).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: '发送' }))
+ await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
+ expect(send).toHaveBeenCalledExactlyOnceWith('Actual prompt\n Exact context\n\nWhat next?\nKeep this detail.', 'fixed-A')
+ expect(confirm).not.toHaveBeenCalled()
+})
+
+it('preserves the question on send failure, blocks duplicate requests and supports retry and cancel', async () => {
+ let rejectSend!: (error: Error) => void
+ const send = vi.fn().mockImplementationOnce(() => new Promise((_, reject) => { rejectSend = reject })).mockResolvedValue(undefined)
+ let resolveContext!: (value: unknown) => void
+ const continueContext = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveContext = resolve })).mockResolvedValue({ prompt: 'Context' })
+ window.goodbuddy = { supervision: { overview: async () => [result('A')], continueContext } } as never
+ render(
)
+ const start = await screen.findByRole('button', { name: '继续讨论' })
+ fireEvent.click(start)
+ fireEvent.click(start)
+ expect(start).toBeDisabled()
+ expect(continueContext).toHaveBeenCalledTimes(1)
+ await act(async () => resolveContext({ prompt: 'Context' }))
+ const question = screen.getByRole('textbox', { name: '继续讨论的问题' })
+ fireEvent.change(question, { target: { value: ' ' } })
+ expect(screen.getByRole('button', { name: '发送' })).toBeDisabled()
+ fireEvent.change(question, { target: { value: 'My question' } })
+ fireEvent.click(screen.getByRole('button', { name: '发送' }))
+ fireEvent.click(screen.getByRole('button', { name: '正在发送...' }))
+ expect(send).toHaveBeenCalledTimes(1)
+ expect(question).toBeDisabled()
+ expect(screen.getByRole('button', { name: '取消' })).toBeDisabled()
+ await act(async () => rejectSend(new Error('Send failed')))
+ expect(screen.getByRole('status')).toHaveTextContent('Send failed')
+ expect(question).toHaveValue('My question')
+ fireEvent.click(screen.getByRole('button', { name: '发送' }))
+ await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
+ expect(send).toHaveBeenNthCalledWith(2, 'Context\n\nMy question', 'A')
+ fireEvent.click(start)
+ await screen.findByRole('textbox')
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(send).toHaveBeenCalledTimes(2)
+})
+
+it('discards pending context when the target changes and clears previews on refresh', async () => {
+ const send = vi.fn()
let resolvePreview!: (value: unknown) => void
- const continueContext = vi.fn().mockResolvedValueOnce({ prompt: 'Actual prompt' }).mockImplementationOnce(() => new Promise((resolve) => { resolvePreview = resolve }))
- window.goodbuddy = { supervision: { overview: async () => [result('A')], sourceContext: async () => ({ id: 'source-A', title: 'Source', content: 'Content' }), continueContext } } as never
- const view = render(
)
- fireEvent.click(await screen.findByRole('button', { name: '查看来源' }))
+ const continueContext = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolvePreview = resolve })).mockResolvedValue({ prompt: 'Fresh context' })
+ const overview = vi.fn().mockResolvedValueOnce([result('A')]).mockResolvedValueOnce([result('B')]).mockResolvedValueOnce([result('C')])
+ window.goodbuddy = { supervision: { overview, sourceContext: async () => ({ title: 'Source', content: 'Content' }), continueContext } } as never
+ const view = render(
)
+ fireEvent.click(await screen.findByRole('button', { name: '继续讨论' }))
+ view.rerender(
)
+ await screen.findByText('B recap')
+ await act(async () => resolvePreview({ prompt: 'Stale prompt' }))
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '查看来源' }))
await screen.findByText('Content')
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
- await waitFor(() => expect(send).toHaveBeenCalledWith('Actual prompt', 'fixed-A'))
- expect(confirm.mock.calls[0]![0]).toContain('Pinned A')
- expect(confirm.mock.calls[0]![0]).toContain('fixed-A')
+ await screen.findByRole('textbox')
+ fireEvent.click(screen.getByRole('button', { name: '刷新监督回顾' }))
+ await screen.findByText('C recap')
+ expect(screen.queryByText('Content')).not.toBeInTheDocument()
+ expect(screen.queryByText('Fresh context')).not.toBeInTheDocument()
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(send).not.toHaveBeenCalled()
+})
+
+it('allows retry after context loading fails without sending', async () => {
+ const send = vi.fn()
+ const continueContext = vi.fn().mockRejectedValueOnce(new Error('Context failed')).mockResolvedValue({ prompt: 'Context' })
+ window.goodbuddy = { supervision: { overview: async () => [result('A')], continueContext } } as never
+ render(
)
+ fireEvent.click(await screen.findByRole('button', { name: '继续讨论' }))
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Context failed'))
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
- await waitFor(() => expect(continueContext).toHaveBeenCalledTimes(2))
- view.rerender(
)
- await act(async () => resolvePreview({ prompt: 'Stale prompt' }))
- expect(send).toHaveBeenCalledTimes(1)
- expect(confirm).toHaveBeenCalledTimes(1)
+ await screen.findByRole('textbox')
+ expect(send).not.toHaveBeenCalled()
})
diff --git a/src/renderer/src/SupervisionDiscussion.test.tsx b/src/renderer/src/SupervisionDiscussion.test.tsx
new file mode 100644
index 00000000..0eb95899
--- /dev/null
+++ b/src/renderer/src/SupervisionDiscussion.test.tsx
@@ -0,0 +1,39 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { SupervisionDiscussion } from './SupervisionDiscussion'
+
+afterEach(cleanup)
+
+it('previews before sending and preserves the question on failure', async () => {
+ const continueContext = vi.fn().mockResolvedValue({ prompt: 'Related evidence only' })
+ const send = vi.fn().mockRejectedValueOnce(new Error('Send failed')).mockResolvedValue(undefined)
+ const onOpenConversation = vi.fn()
+ window.goodbuddy = { supervision: { continueContext, continue: send } } as never
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
+ const question = await screen.findByRole('textbox', { name: '继续讨论的问题' })
+ expect(continueContext).toHaveBeenCalledWith({ resultId: 'result', sourceId: 'source' })
+ expect(send).not.toHaveBeenCalled()
+ expect(screen.getByText('发送到会话:Original conversation')).toBeVisible()
+ fireEvent.change(question, { target: { value: 'What changed?' } })
+ fireEvent.click(screen.getByRole('button', { name: '发送' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Send failed')
+ expect(question).toHaveValue('What changed?')
+ expect(onOpenConversation).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: '发送' }))
+ await waitFor(() => expect(onOpenConversation).toHaveBeenCalledWith('original'))
+ expect(send).toHaveBeenLastCalledWith({ conversationId: 'original', prompt: 'Related evidence only\n\nWhat changed?' })
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+})
+
+it('cancels a preview without sending or navigating', async () => {
+ const send = vi.fn()
+ const open = vi.fn()
+ window.goodbuddy = { supervision: { continueContext: async () => ({ prompt: 'Evidence' }), continue: send } } as never
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
+ fireEvent.click(await screen.findByRole('button', { name: '取消' }))
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
+ expect(send).not.toHaveBeenCalled()
+ expect(open).not.toHaveBeenCalled()
+})
diff --git a/src/renderer/src/SupervisionDiscussion.tsx b/src/renderer/src/SupervisionDiscussion.tsx
new file mode 100644
index 00000000..eb865f6b
--- /dev/null
+++ b/src/renderer/src/SupervisionDiscussion.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+export function SupervisionDiscussion({ resultId, sourceId, conversationId, title, onOpenConversation }: {
+ resultId: string
+ sourceId: string
+ conversationId: string
+ title: string
+ onOpenConversation: (id: string) => void
+}): React.JSX.Element {
+ const { t } = useTranslation('heartbeat')
+ const [context, setContext] = useState
()
+ const [question, setQuestion] = useState('')
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState('')
+ const busy = useRef(false)
+ const mounted = useRef(true)
+ useEffect(() => {
+ mounted.current = true
+ return () => { mounted.current = false }
+ }, [])
+ const perform = async (send: boolean) => {
+ if (busy.current) return
+ busy.current = true
+ setPending(true)
+ setError('')
+ try {
+ if (send) {
+ if (!context || !question.trim()) return
+ await window.goodbuddy.supervision.continue({ conversationId, prompt: `${context}\n\n${question}` })
+ if (!mounted.current) return
+ setContext(undefined)
+ setQuestion('')
+ onOpenConversation(conversationId)
+ } else {
+ const preview = await window.goodbuddy.supervision.continueContext({ resultId, sourceId })
+ if (!mounted.current) return
+ if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty'))
+ setContext(preview.prompt)
+ setQuestion(t('supervisor.discussion.defaultQuestion'))
+ }
+ } catch (reason) {
+ if (mounted.current) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
+ } finally {
+ busy.current = false
+ if (mounted.current) setPending(false)
+ }
+ }
+ return
+ {context === undefined ?
: <>
+
{t('supervisor.discussion.target', { title })}
+
+
{t('supervisor.discussion.contextHint')}
+
+
+
+
+ >}
+ {pending &&
{t('supervisor.discussion.loading')}
}
+ {error &&
{error}
}
+
+}
diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx
index 726068a6..b59298ef 100644
--- a/src/renderer/src/SupervisorWorkspace.test.tsx
+++ b/src/renderer/src/SupervisorWorkspace.test.tsx
@@ -10,6 +10,29 @@ const render = (ui: React.ReactNode) => {
}
describe('SupervisorWorkspace', () => {
+ it.each(['conversation', 'knowledge'])('opens only conversation sources without replacing the saved %s snapshot', async (sourceType) => {
+ const onOpenConversation = vi.fn()
+ const source = vi.fn(async () => ({ sourceType, sourceId: 'original-conversation', title: 'Original source', content: 'Saved snapshot', occurredAt: result.createdAt }))
+ window.goodbuddy = { supervision: {
+ overview: async () => [result], source,
+ graph: async () => ({ storyLine: null,
+ events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
+ entities: [], relations: [], eventEntities: [],
+ sources: [{ id: 'source', title: 'Original source', occurred_at: result.createdAt }],
+ eventSources: [{ event_id: 'event', source_id: 'source' }] })
+ } } as never
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: /Original source/ }))
+ expect(await screen.findByText('Saved snapshot')).toBeVisible()
+ expect(onOpenConversation).not.toHaveBeenCalled()
+ if (sourceType === 'conversation') {
+ fireEvent.click(screen.getByRole('button', { name: '打开会话' }))
+ expect(onOpenConversation).toHaveBeenCalledWith('original-conversation')
+ } else {
+ expect(screen.queryByRole('button', { name: '打开会话' })).not.toBeInTheDocument()
+ }
+ expect(source).toHaveBeenCalledOnce()
+ })
it('keeps dated historical content and exact graph selection when configuring and running a new review', async () => {
const old = { ...result, id: 'old', summary: 'Earlier conclusion.\n\nImportant final conclusion.', changeDigest: 'Confirmed change', openItems: ['Pending decision'], createdAt: '2026-08-22T00:00:00.000Z' }
let finish!: () => void
diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx
index f8d72d72..a64d0834 100644
--- a/src/renderer/src/SupervisorWorkspace.tsx
+++ b/src/renderer/src/SupervisorWorkspace.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
+import { SupervisionDiscussion } from './SupervisionDiscussion'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
import {
@@ -20,6 +21,7 @@ type Props = {
tab?: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
projects?: AssistantProject[]
onOpenActivity?: () => void
+ onOpenConversation?: (conversationId: string) => void
onTabChange?: (tab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings') => void
}
const emptyGraph: SupervisionGraphView = {
@@ -48,7 +50,8 @@ export function SupervisorWorkspace({
tab = 'overview',
projects = [],
onTabChange,
- onOpenActivity
+ onOpenActivity,
+ onOpenConversation
}: Props) {
const { t, i18n } = useTranslation('heartbeat')
const graphId = useId()
@@ -67,6 +70,7 @@ export function SupervisorWorkspace({
title: string
content: string
occurredAt: string
+ conversationId?: string
}>()
const [projectId, setProjectId] = useState('global')
const [days, setDays] = useState(7)
@@ -442,7 +446,9 @@ export function SupervisorWorkspace({
id,
title: String(item.title),
content: String(item.content),
- occurredAt: String(item.occurredAt)
+ occurredAt: String(item.occurredAt),
+ conversationId: item.sourceType === 'conversation' && typeof item.sourceId === 'string'
+ ? item.sourceId : undefined
})
} catch (reason) {
if (generation !== loadGeneration.current) return
@@ -1294,7 +1300,24 @@ export function SupervisorWorkspace({
{t('supervisor.sourceSnapshot')}
{source.title}
+ {source.conversationId && onOpenConversation && (
+
+ )}
{source.content}
+ {source.conversationId && resultId && onOpenConversation && }
)}
diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts
index 3e5a92a8..70afa742 100644
--- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts
+++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts
@@ -87,6 +87,11 @@ export const heartbeat = {
graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End',
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations' },
listEmpty: 'No records of this type in this review.',
+ openConversation: 'Open conversation',
+ discussion: {
+ start: 'Continue discussion', question: 'What would you like to discuss?', defaultQuestion: 'Using the review context above, help me work out the next steps.',
+ target: 'Send to conversation: {{title}}', contextHint: 'Relevant review context and sources will be included.', send: 'Send', sending: 'Sending...', loading: 'Working...', empty: 'The review context is empty. Please retry.'
+ },
events: 'Time events', entities: 'Knowledge entities', relations: 'Entity relations', eventSources: 'Event sources', sources: 'Related sources', noSources: 'No related sources are available.', selectHint: 'Select an event, entity, or relation to inspect it.', sourceSnapshot: 'Source details', sourceMissing: 'Source not found',
confirm: 'Confirm', revise: 'Revise', remove: 'Remove relation', label: 'Entity name', save: 'Save revision', cancel: 'Cancel', removeHint: 'This changes graph organization only; the original source remains available.',
relationTypes: { supports: 'Supports', 'depends-on': 'Depends on', contrasts: 'Contrasts', related: 'Related' },
diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts
index 490ea79f..81c4dc23 100644
--- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts
+++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts
@@ -84,6 +84,11 @@ export const heartbeat = {
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
listTabs: { event: '事件', entity: '实体', relation: '关系' },
listEmpty: '本次回顾暂无此类记录。',
+ openConversation: '打开会话',
+ discussion: {
+ start: '继续讨论', question: '继续讨论的问题', defaultQuestion: '请根据以上监督上下文,继续分析下一步行动。',
+ target: '发送到会话:{{title}}', contextHint: '发送时会附带相关回顾与来源。', send: '发送', sending: '正在发送...', loading: '正在处理...', empty: '监督上下文为空,请重试'
+ },
events: '时间事件', entities: '知识实体', relations: '实体关系', eventSources: '事件来源', sources: '关联来源', noSources: '没有可用的关联来源。', selectHint: '选择事件、实体或关系查看详情。', sourceSnapshot: '来源详情', sourceMissing: '来源不存在',
confirm: '确认', revise: '修订', remove: '移除关系', label: '实体名称', save: '保存修订', cancel: '取消', removeHint: '这只改变图谱中的关系组织,原始来源仍然保留。',
relationTypes: { supports: '支持', 'depends-on': '依赖', contrasts: '对比', related: '相关' },
diff --git a/tests/support/supervisor-layout-driver.mjs b/tests/support/supervisor-layout-driver.mjs
index ccd89152..4760927e 100644
--- a/tests/support/supervisor-layout-driver.mjs
+++ b/tests/support/supervisor-layout-driver.mjs
@@ -229,8 +229,8 @@ app
})()`)
assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Recap/automatic overflow')
assert.equal(report.refresh, 1, 'One refresh owner per panel')
- assert.equal(report.reports, tab === 'plans')
- assert.equal(report.suggestions, tab === 'plans')
+ assert.equal(report.reports, tab === 'plans' && scenario === 'populated')
+ assert.equal(report.suggestions, tab === 'plans' && scenario === 'populated')
assert.equal(report.recap, tab === 'overview' && scenario !== 'empty')
if (tab === 'overview') {
assert(!report.text.includes('暂无自动监督报告') && !report.text.includes('待确认记忆'))
@@ -240,13 +240,13 @@ app
assert(report.clientWidth - report.proseWidth <= 50, 'Prose fills the panel within card padding')
} else assert(report.text.includes('还没有成功回顾'))
} else {
- assert.equal(report.text.includes('暂无自动监督报告'), scenario !== 'populated')
- assert(report.text.includes('自动监督报告'))
+ assert(!report.text.includes('暂无自动监督报告'))
+ assert.equal(report.text.includes('自动监督报告'), scenario === 'populated')
}
reports.push({ scenario, tab, ...report })
const prefix = `recap-${scenario}-${tab}-${width}`
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
- if (tab === 'plans') {
+ if (tab === 'plans' && scenario === 'populated') {
for (const anchor of ['heartbeat-memory-title', 'heartbeat-reports-title', 'heartbeat-runs-title']) {
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
if (anchor === 'heartbeat-reports-title' && scenario === 'populated') await js(`document.querySelector('#heartbeat-panel-history button[aria-expanded=false]')?.click()`)
@@ -277,8 +277,9 @@ app
return
}
if (process.env.GOODBUDDY_SUPERVISOR_AUTOMATIC_OVERVIEW) {
- for (const empty of [false, true]) {
- await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (empty ? '' : '?menu=1'))
+ for (const scenario of ['populated', 'empty', 'plan-only']) {
+ const empty = scenario !== 'populated'
+ await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (scenario === 'populated' ? '?menu=1' : scenario === 'plan-only' ? '?plan-only=1' : ''))
await wait('!!document.querySelector("#supervisor-tab-plans")')
await js('document.fonts.ready')
await js('document.documentElement.dataset.theme = "light"')
@@ -295,22 +296,27 @@ app
overview: !!panel.querySelector('#heartbeat-panel-overview'),
metrics: !!panel.querySelector('.heartbeat-center__metrics'),
trend: !!panel.querySelector('#heartbeat-trend-title'),
- audit: !!panel.querySelector('#heartbeat-runs-title'),
+ audit: !!panel.querySelector('#heartbeat-runs-title'),
+ reports: !!panel.querySelector('#heartbeat-reports-title'),
+ suggestions: !!panel.querySelector('#heartbeat-panel-suggestions'),
+ emptyReports: panel.textContent.includes('暂无自动监督报告'),
plans: !!panel.querySelector('#heartbeat-panel-plans'),
create: [...panel.querySelectorAll('button')].filter(b => b.textContent === '创建自动监督计划').length,
emptyStates: panel.querySelectorAll('#heartbeat-panel-overview .empty-state').length,
activity: panel.querySelectorAll('.supervisor-activity__item').length };
})()`)
assert(report.pageWidth <= width && report.scrollWidth <= report.panelWidth, 'Automatic panel overflow')
- for (const key of ['overview', 'metrics', 'trend', 'audit', 'plans']) assert.equal(report[key], tab === 'plans', key)
+ for (const key of ['overview', 'plans']) assert.equal(report[key], tab === 'plans', key)
+ for (const key of ['metrics', 'trend', 'audit', 'reports', 'suggestions']) assert.equal(report[key], tab === 'plans' && !empty, key)
+ assert.equal(report.emptyReports, false)
assert.equal(report.create, tab === 'plans' ? 1 : 0)
assert.equal(report.emptyStates, 0)
if (tab === 'activity') assert.equal(report.activity, 3)
- reports.push({ empty, tab, ...report })
- const prefix = `automatic-${empty ? 'empty' : 'populated'}-${tab}-light-${width}`
+ reports.push({ scenario, empty, tab, ...report })
+ const prefix = `automatic-${scenario}-${tab}-light-${width}`
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
if (tab === 'plans') {
- for (const anchor of ['heartbeat-trend-title', 'heartbeat-runs-title']) {
+ for (const anchor of empty ? [] : ['heartbeat-trend-title', 'heartbeat-runs-title']) {
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
await settle()
await writeFile(join(artifacts, `${prefix}-${anchor}.png`), (await win.webContents.capturePage()).toPNG())
@@ -335,6 +341,45 @@ app
}
assert.deepEqual(errors, [])
await writeFile(join(artifacts, 'automatic-overview-measurements.json'), JSON.stringify(reports, null, 2))
+ await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?preview=1')
+ await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
+ await js('document.querySelector("#supervisor-tab-graph").click()')
+ await wait('!!document.querySelector(".supervisor-workspace__map")')
+ await js(`document.querySelector('.supervisor-workspace__map [aria-label="观察工作过程"]').dispatchEvent(new MouseEvent('click', {bubbles:true}))`)
+ await settle()
+ await js('document.querySelector(".supervisor-workspace__detail > .link-button").click()')
+ await wait('!!document.querySelector(".supervisor-discussion > button")')
+ await js('document.querySelector(".supervisor-discussion > button").click()')
+ await wait('!!document.querySelector(".supervisor-discussion textarea")')
+ await js('document.fonts.ready')
+ const previews = []
+ for (const theme of ['light', 'dark']) {
+ await js(`document.documentElement.dataset.theme = '${theme}'`)
+ for (const width of [1440, 390]) {
+ win.setContentSize(width, 1100)
+ await js(`document.querySelector('.supervisor-discussion').scrollIntoView({block:'center'})`)
+ await settle()
+ const report = await js(`(() => {
+ const preview = document.querySelector('.supervisor-discussion');
+ const box = e => { const r = e.getBoundingClientRect(); return {left:r.left, right:r.right, top:r.top, bottom:r.bottom}; };
+ return { width:innerWidth, pageWidth:document.documentElement.scrollWidth,
+ clientWidth:preview.clientWidth, scrollWidth:preview.scrollWidth,
+ question:preview.querySelector('textarea').value, text:preview.textContent,
+ controls:[...preview.querySelectorAll('textarea, button')].map(box) };
+ })()`)
+ assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Discussion preview overflow')
+ assert(report.question.trim() && report.text.includes('模拟会议记录'), 'Discussion question and destination visible')
+ assert(report.controls.every(box => box.left >= 0 && box.right <= width && box.top >= 0 && box.bottom <= 1100), 'Preview controls fit viewport')
+ assert(await js('!document.querySelector(".supervisor-discussion [role=alert]")'), 'Preview loads without error')
+ assert(await js('!document.querySelector(".supervisor-discussion details")'), 'No redundant context disclosure')
+ previews.push({ theme, ...report })
+ await writeFile(join(artifacts, `discussion-preview-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
+ }
+ }
+ await js('document.querySelector(".supervisor-discussion .secondary-button").click()')
+ await wait('!document.querySelector(".supervisor-discussion textarea")')
+ assert.deepEqual(errors, [])
+ await writeFile(join(artifacts, 'discussion-preview-measurements.json'), JSON.stringify(previews, null, 2))
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
win.destroy()
app.quit()
diff --git a/tests/support/supervisor-layout-regression.tsx b/tests/support/supervisor-layout-regression.tsx
index f7c3c704..3be5b4f7 100644
--- a/tests/support/supervisor-layout-regression.tsx
+++ b/tests/support/supervisor-layout-regression.tsx
@@ -146,6 +146,8 @@ Object.defineProperty(window, 'goodbuddy', {
? {}
: {
supervision: {
+ continueContext: async () => ({ prompt: '模拟讨论上下文:本周已核对交付清单,负责人已确认。\n\n原始依据:模拟会议记录。外部评审时间仍待确认,下一步需要核对验收条件。' }),
+ continue: async () => { throw new Error('Preview fixture must not send a message') },
execution: async () => ({ active: params.has('activity'), ...(params.has('activity') ? { runId: 'activity-1' } : {}) }),
batches: async ({ runId, offset, limit }: { runId: string; offset: number; limit: number }) => Array.from({ length: runId === 'activity-0' ? 6 : 12 }, (_, index) => ({
id: `leaf-${index}`, projectId: index < 3 ? 'Atlas' : 'Beacon', conversationId: `conversation-${Math.floor(index / 2)}`,
@@ -204,6 +206,7 @@ Object.defineProperty(window, 'goodbuddy', {
entityAction: async () => {},
relationAction: async () => {},
source: async () => ({
+ ...(params.has('preview') ? { sourceType: 'conversation', sourceId: 'simulated-conversation-uuid' } : {}),
title: '模拟会议记录',
content: '视觉 fixture,不是真实用户数据。',
occurredAt: '2026-09-21T08:00:00.000Z'
@@ -252,7 +255,7 @@ createRoot(document.getElementById('root')!).render(
true}
- configs={menu ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}
+ configs={menu || params.has('plan-only') ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}
runs={menu ? [{ id: 'run', configId: 'plan', trigger: 'scheduled', scheduledFor: createdAt, status: 'completed', attemptCount: 1, createdAt, updatedAt: createdAt }] : []}
entries={menu ? [{ id: 'report', configId: 'plan', runId: 'run', scheduledFor: createdAt, summary: '模拟自动监督报告:交付计划已更新,待核对负责人和验收日期。', highlights: ['保留原始依据,核对交付时间。'], proposedMemoryIds: ['memory'], followUpTaskIds: ['task'], createdAt }] : []}
memories={menu ? [{ id: 'memory', scope: 'global', type: 'preference', content: '模拟建议:周会总结保留负责人和下一次检查日期。', confidence: 0.9, salience: 0.8, status: 'proposed', createdAt, updatedAt: createdAt }] : []}
@@ -267,7 +270,8 @@ createRoot(document.getElementById('root')!).render(
onSetMemoryStatus={noop}
onSetTaskStatus={noop}
onUseFollowUpTask={() => {}}
- onRetryLoad={noop}
+ onRetryLoad={noop}
+ onOpenConversation={params.has('preview') ? () => { throw new Error('Preview fixture must not navigate') } : undefined}
/>
}