feat: connect conversations with Magic Notes

Let users retain useful assistant replies or complete conversations without
leaving their chat workflow. Preview and edit captured text, choose an existing
note or atomically create a note with its first entry, and return to the source
conversation or message from saved records.

Add an application-scoped Notes workbar tab with title/body search and quick
text appends. Preserve drafts across conversation switches and guard closing,
replacement and disabling. Schema 48 stores optional source metadata without
rewriting file-backed bodies; source deletion retains the saved note. Older
clients cannot reopen an upgraded database without a pre-upgrade backup.

Release note: 可将助手回答或完整会话预览、编辑后加入魔法笔记,并从笔记返回原会话或消息。右侧快速笔记支持搜索和追加文字,切换会话保留草稿;采集本身不调用模型,保存后自动评论仍遵循已有设置。

Validation: isolated HEAD-based candidate passed 22 files / 930 focused tests,
plus 20 gateway tests after including its create-result fixture adjustment.
Real Electron App/preload/IPC/SQLite acceptance passed for all 502 historical
messages, exact-entry/source navigation, native keyboard input in a 214px
panel, reopen and source-deletion retention, with zero capture model calls.
Repository lint and web TypeScript passed. Cached whitespace and secret-pattern
checks passed; native-client changes and unrelated IPC extraction are excluded.

Validation limits: the isolated source tree reuses installed node_modules,
where a parallel task upgraded DSH from the committed 0.1.2-rc.1 to 0.1.7-rc.2.
Full typecheck reports 14 DSH errors in three unchanged files; the full-suite
attempt with --bail=1 also fails in deepseek-harness-host.test.ts. These are not
all-green repository or release-package results. Earlier execution-time full
suite failures and fixture repairs remain recorded in the feature progress doc.
This commit is contained in:
mesalogo
2026-09-27 08:17:54 +08:00
parent 55efca3ee1
commit 1094433a7d
51 changed files with 1949 additions and 56 deletions
+144
View File
@@ -868,6 +868,7 @@ const api: DesktopApi = {
remove: vi.fn(async () => {}),
},
magicNotes: {
search: vi.fn(async () => []),
list: vi.fn(async () => ({ notes: [] })),
get: vi.fn(async () => {
throw new Error("not used");
@@ -1160,6 +1161,7 @@ describe("App", () => {
delete document.documentElement.dataset.theme;
document.documentElement.style.colorScheme = "";
vi.clearAllMocks();
vi.mocked(api.magicNotes.search).mockReset().mockResolvedValue([]);
vi.mocked(api.knowledge.getSnapshot).mockReset();
vi.mocked(api.knowledge.externalInstancesList).mockReset();
vi.mocked(api.agent.respondQuestion).mockReset().mockResolvedValue();
@@ -13874,6 +13876,148 @@ describe("App", () => {
})
})
it('captures all conversation history, freezes the source, and preserves the preview across hidden workbar and new conversations', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
const messages = Array.from({ length: 502 }, (_, index) => ({ id: crypto.randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const, content: `Capture history ${index}`, createdAt: 1_775_000_000_000 + index, state: 'complete' as const }))
const conversation = { id: crypto.randomUUID(), projectId, title: 'Captured discussion', updatedAt: 1_775_000_000_502, messages }
vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation])
render(<App />)
await screen.findByText('Capture history 501')
expect(screen.queryByText('Capture history 0')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '会话操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '保存当前对话到笔记' }))
const preview = await screen.findByLabelText('待加入内容')
expect(preview).toHaveValue(messages.map(message => `## ${message.role === 'user' ? '用户' : '助手'}\n\n${message.content}`).join('\n\n'))
expect(screen.getAllByRole('tab', { name: '笔记' })).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: '快速笔记' }))
expect(screen.getAllByRole('tab', { name: '笔记' })).toHaveLength(1)
fireEvent.change(preview, { target: { value: 'Frozen edited capture' } })
fireEvent.click(screen.getByRole('button', { name: /收起工作栏|助手工作栏/ }))
fireEvent.click(screen.getByRole('button', { name: '快速笔记' }))
expect(await screen.findByLabelText('待加入内容')).toHaveValue('Frozen edited capture')
fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
expect(await screen.findByLabelText('待加入内容')).toHaveValue('Frozen edited capture')
fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
expect(screen.getByLabelText('笔记标题')).toHaveValue('Captured discussion')
fireEvent.click(within(screen.getByRole('region', { name: '快速笔记' })).getByRole('button', { name: '加入笔记' }))
await waitFor(() => expect(api.magicNotes.create).toHaveBeenCalledWith(expect.objectContaining({ source: expect.objectContaining({ kind: 'conversation', conversationId: conversation.id, messageIds: messages.map(message => message.id) }), content: { version: 1, ops: [{ insert: 'Frozen edited capture\n' }] } })))
})
it('adds an assistant reply to an explicitly selected note and opens the returned entry in the full workspace', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true, magicNoteCommentMode: 'after-save-manual' })
const message = { id: crypto.randomUUID(), role: 'assistant' as const, content: 'Reusable **answer**', createdAt: 1, state: 'complete' as const }
const conversation = { id: crypto.randomUUID(), projectId, title: 'Source answer discussion', updatedAt: 2, messages: [message] }
vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation])
const noteId = crypto.randomUUID()
const oldEntry = { id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Keep existing entry\n' }] }, plainText: 'Keep existing entry', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' }
const note = { id: noteId, title: 'Collected answers', preview: oldEntry.plainText, entryCount: 1, pinned: false, revision: 1, createdAt: oldEntry.createdAt, updatedAt: oldEntry.updatedAt, entries: [oldEntry] }
const createdEntryId = crypto.randomUUID()
const saved = { ...note, entryCount: 2, entries: [oldEntry, { ...oldEntry, id: createdEntryId, content: { version: 1 as const, ops: [{ insert: `${message.content}\n` }] }, plainText: message.content }], createdEntryId }
vi.mocked(api.magicNotes.search).mockResolvedValue([note])
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
vi.mocked(api.magicNotes.createEntry).mockImplementationOnce(async () => {
vi.mocked(api.magicNotes.get).mockResolvedValue(saved)
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [saved] })
return saved
})
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '加入笔记' }))
const panel = within(await screen.findByRole('region', { name: '快速笔记' }))
expect(panel.getByLabelText('待加入内容')).toHaveValue(message.content)
expect(panel.getByRole('button', { name: '加入笔记' })).toBeDisabled()
fireEvent.click(await panel.findByRole('button', { name: /Collected answers/ }))
fireEvent.click(panel.getByRole('button', { name: '加入笔记' }))
await waitFor(() => expect(api.magicNotes.createEntry).toHaveBeenCalledWith({ noteId, content: { version: 1, ops: [{ insert: `${message.content}\n` }] }, source: expect.objectContaining({ kind: 'message', conversationId: conversation.id, messageIds: [message.id] }) }))
expect(await panel.findByText('Keep existing entry')).toBeVisible()
const added = await waitFor(() => {
const element = document.getElementById(`compact-note-entry-${createdEntryId}`)
expect(element).not.toBeNull()
return element!
})
const originalScroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView })
try {
fireEvent.click(within(added).getByRole('button', { name: '在完整工作区打开' }))
await waitFor(() => {
expect(document.getElementById(`magic-note-entry-${createdEntryId}`)).toBeVisible()
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' })
})
} finally {
if (originalScroll) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScroll)
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
}
expect(api.magicNotes.analyze).not.toHaveBeenCalled()
})
it('does not capture partial history when loading the full conversation fails', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
const id = crypto.randomUUID()
vi.mocked(api.conversations.listSummaries).mockResolvedValue([
{ id: crypto.randomUUID(), projectId, title: 'Opened discussion', updatedAt: 2, messages: [{ id: crypto.randomUUID(), role: 'assistant', state: 'complete', content: 'Opened discussion text', createdAt: 1 }] },
{ id, projectId, title: 'Unavailable history', updatedAt: 1, messages: [], messageSummary: { count: 10, firstRole: 'user' } }
])
vi.mocked(api.conversations.get).mockRejectedValue(new Error('History read failed'))
render(<App />)
await screen.findByText('Opened discussion text')
fireEvent.click(screen.getByText('Unavailable history'))
await screen.findByText('History read failed')
fireEvent.click(screen.getByRole('button', { name: '会话操作' }))
expect(screen.getByRole('menuitem', { name: '保存当前对话到笔记' })).toBeEnabled()
fireEvent.click(screen.getByRole('menuitem', { name: '保存当前对话到笔记' }))
await waitFor(() => expect(api.conversations.get).toHaveBeenCalledTimes(2))
expect(screen.queryByLabelText('待加入内容')).not.toBeInTheDocument()
expect(api.magicNotes.create).not.toHaveBeenCalled()
expect(api.magicNotes.createEntry).not.toHaveBeenCalled()
})
it('opens note sources in folded history and distinguishes a deleted message from a deleted conversation', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
const messages = Array.from({ length: 102 }, (_, index) => ({ id: crypto.randomUUID(), role: index % 2 ? 'assistant' as const : 'user' as const, content: `Source history ${index}`, createdAt: 1_775_000_000_000 + index, state: 'complete' as const }))
const conversation = { id: crypto.randomUUID(), projectId, title: 'Source discussion', updatedAt: 1_775_000_000_502, messages }
vi.mocked(api.conversations.list).mockResolvedValueOnce([conversation])
const noteId = crypto.randomUUID()
const note = { id: noteId, title: 'Source note', preview: '', entryCount: 1, pinned: false, revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', entries: [{ id: crypto.randomUUID(), noteId, content: { version: 1 as const, ops: [{ insert: 'Saved text\n' }] }, plainText: 'Saved text', comments: [], revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z', source: { kind: 'message' as const, conversationId: conversation.id, messageIds: [messages[1]!.id], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: conversation.title } }] }
vi.mocked(api.magicNotes.search).mockResolvedValue([note])
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
render(<App />)
await screen.findByText('Source history 101')
expect(screen.queryByText('Source history 1')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '快速笔记' }))
fireEvent.click(await screen.findByRole('button', { name: /Source note/ }))
fireEvent.click(await screen.findByRole('button', { name: '查看来源消息' }))
await waitFor(() => {
expect(screen.getByText('Source history 1')).toBeVisible()
expect(document.activeElement).toHaveTextContent('Source history 1')
})
vi.mocked(api.conversations.get).mockResolvedValue({ ...conversation, messages: [] })
fireEvent.click(screen.getByRole('button', { name: '查看来源消息' }))
expect(await screen.findByText('原消息已不存在')).toBeVisible()
vi.mocked(api.conversations.get).mockRejectedValue(new Error('对话不存在'))
fireEvent.click(screen.getByRole('button', { name: '返回原会话' }))
expect(await screen.findByRole('button', { name: '原会话已不存在' })).toBeDisabled()
expect(screen.getByText('Saved text')).toBeVisible()
})
it('guards disabling Magic Notes with an unsaved quick draft before saving application settings', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '快速笔记' }))
fireEvent.click(await screen.findByRole('button', { name: '新建笔记' }))
fireEvent.change(screen.getByLabelText('笔记标题'), { target: { value: 'Unsaved quick note' } })
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const center = await screen.findByRole('dialog', { name: '应用中心' })
fireEvent.click(within(center).getByRole('button', { name: /魔法笔记.*设置|设置.*魔法笔记/ }))
const enabled = await screen.findByRole('switch', { name: /启用/ })
const calls = vi.mocked(api.updates!.updateSettings).mock.calls.length
fireEvent.click(enabled)
const guard = await screen.findByRole('dialog', { name: /放弃/ })
expect(vi.mocked(api.updates!.updateSettings).mock.calls).toHaveLength(calls)
fireEvent.click(within(guard).getByRole('button', { name: '继续编辑' }))
expect(vi.mocked(api.updates!.updateSettings).mock.calls).toHaveLength(calls)
})
it('keeps Magic Notes free of a generic application settings shortcut', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
render(<App />)
+134 -4
View File
@@ -1,4 +1,8 @@
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
import { MagicNotesPanel } from './MagicNotesPanel';
import { AnchoredMenu } from './AnchoredMenu';
import { useMagicNoteDraft } from './use-magic-note-draft';
import type { MagicNoteSource } from '../../shared/magic-notes-contracts';
import LocalInferencePage from "./LocalInferencePage";
import { ApplicationMenu } from './ApplicationMenu';
import { AttachmentResultButton } from './AttachmentResultButton';
@@ -861,6 +865,8 @@ function ConversationHistoryLoader({ conversationId, active, load }: {
}
function ChatHistoryPane({
onAddToNote,
noteMessageNavigation,
onOpenImageModelSettings,
onReselectImageSources,
onEditImage,
@@ -885,6 +891,8 @@ function ChatHistoryPane({
taskStrip,
visibleMessageCount,
}: {
onAddToNote?: (conversationId: string, message: Message, trigger: HTMLElement) => void;
noteMessageNavigation?: { conversationId: string; messageId: string; requestId: number };
onOpenImageModelSettings: () => void;
onReselectImageSources: (operation: ImageOperation) => void;
onEditImage: (artifact: AssistantArtifact) => void;
@@ -951,6 +959,18 @@ function ChatHistoryPane({
);
const hiddenMessageCount = visibleMessageStartIndex;
useEffect(() => {
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
if (!element) return;
const frame = requestAnimationFrame(() => {
element.scrollIntoView?.({ block: 'center' });
element.tabIndex = -1;
element.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [active, conversation.id, noteMessageNavigation, visibleMessageCount]);
const handleArticleRef = useCallback(
(messageId: string, element: HTMLElement | null): void => {
if (element) {
@@ -1183,6 +1203,7 @@ function ChatHistoryPane({
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
@@ -2450,6 +2471,16 @@ function App(): React.JSX.Element {
undefined,
);
const notesLeaveRequesterRef = useRef<SettingsLeaveRequester | undefined>(undefined);
const noteDraft = useMagicNoteDraft();
const { guard: guardNoteDraft, setDraft: setNoteDraft } = noteDraft;
const [notesOpenRequest, setNotesOpenRequest] = useState(0);
const [notesNavigation, setNotesNavigation] = useState<{ noteId: string; entryId?: string; requestId: number }>();
const [noteMessageNavigation, setNoteMessageNavigation] = useState<{ conversationId: string; messageId: string; requestId: number }>();
const [noteCaptureLoading, setNoteCaptureLoading] = useState(false);
const noteCapturePending = useRef(false);
const noteCaptureTrigger = useRef<HTMLElement | null>(null);
const noteTitleMenuRef = useRef<HTMLButtonElement>(null);
const [noteTitleMenuOpen, setNoteTitleMenuOpen] = useState(false);
const registerNotesLeaveRequester = useCallback((requester: SettingsLeaveRequester | undefined): void => {
notesLeaveRequesterRef.current = requester;
}, []);
@@ -2631,11 +2662,23 @@ function App(): React.JSX.Element {
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
setApplicationSettings(settings);
setMagicNotesEnabled(settings.magicNotesEnabled);
if (settings.magicNotesEnabled) setMagicNotesEnabled(true);
else {
const revision = applicationSettingsRevisionRef.current;
void guardNoteDraft().then(async allowed => {
if (revision !== applicationSettingsRevisionRef.current) return;
if (allowed) { setNoteDraft(undefined); setMagicNotesEnabled(false); }
else if (window.goodbuddy.updates) {
// Continuing an externally disabled draft restores the same application setting.
try { await window.goodbuddy.updates.updateSettings({ magicNotesEnabled: true }); }
catch (reason) { setApplicationSettingsError(displayErrorMessage(reason, tRef.current('applications.saveFailed'))); }
}
});
}
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
}, []);
}, [guardNoteDraft, setNoteDraft]);
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
@@ -2657,6 +2700,10 @@ function App(): React.JSX.Element {
}, [applyApplicationSettings, t]);
const updateApplicationSettings = useCallback(async (patch: ApplicationSettingsUpdate): Promise<boolean> => {
if (applicationSettingsPendingRef.current || applicationSettingsUnconfirmed) return false;
if (patch.magicNotesEnabled === false) {
if (!await guardNoteDraft()) return false;
setNoteDraft(undefined);
}
const revision = ++applicationSettingsRevisionRef.current;
applicationSettingsPendingRef.current++;
setApplicationSettingsPending(true);
@@ -2683,7 +2730,7 @@ function App(): React.JSX.Element {
} finally {
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
}
}, [applicationSettingsUnconfirmed, applyApplicationSettings, t]);
}, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery);
@@ -8454,6 +8501,71 @@ function App(): React.JSX.Element {
}
};
const openQuickNotes = useCallback((): void => {
if (!magicNotesEnabled) return;
setAssistantSidebarOpen(true);
setNotesOpenRequest(value => value + 1);
}, [magicNotesEnabled]);
const captureToNote = useCallback(async (conversationId: string, message?: Message, trigger?: HTMLElement): Promise<void> => {
if (!magicNotesEnabled || noteCapturePending.current || noteDraft.saving) return;
const snapshot = conversationsRef.current.find(item => item.id === conversationId);
if (!snapshot || (message && (message.state === 'streaming' || !message.content.trim()))) return;
if (!message && snapshot.messages.some(item => item.state === 'streaming')) {
notify({ tone: 'info', message: t('magicNotes:capture.wait') });
return;
}
noteCapturePending.current = true;
setNoteCaptureLoading(true);
const capturedAt = new Date().toISOString();
try {
const conversation = message ? snapshot : await ensureConversationHistory(conversationId);
const messages = message ? [message] : conversation.messages.filter(item => (item.role === 'user' || item.role === 'assistant') && item.content.trim());
if (!message && messages.some(item => item.state === 'streaming')) throw new Error(t('magicNotes:capture.wait'));
const text = message ? message.content : messages.map(item => `## ${t(item.role === 'user' ? 'magicNotes:capture.user' : 'magicNotes:capture.assistant')}\n\n${item.content}`).join('\n\n');
if (!text.trim()) return;
// Main validates source IDs against persisted messages, including newly completed replies.
persistLocalConversationChanges();
await conversationPersistenceQueueRef.current;
if (!await guardNoteDraft()) return;
const title = conversation.title.slice(0, 100);
setNoteDraft({ text, initialText: text, title, initialTitle: title, targetId: '', newNote: false,
incomplete: message ? message.state !== 'complete' : undefined,
source: { kind: message ? 'message' : 'conversation', conversationId, messageIds: messages.map(item => item.id), capturedAt,
conversationTitle: conversation.title, projectId: conversation.projectId, projectName: conversation.projectId ? projectNames.get(conversation.projectId) : undefined } });
noteCaptureTrigger.current = trigger ?? noteTitleMenuRef.current;
openQuickNotes();
} catch (reason) { notify({ tone: 'error', message: displayErrorMessage(reason, t('magicNotes:errors.operationFailed')) }); }
finally { noteCapturePending.current = false; setNoteCaptureLoading(false); }
}, [magicNotesEnabled, noteDraft.saving, ensureConversationHistory, persistLocalConversationChanges, guardNoteDraft, setNoteDraft, projectNames, openQuickNotes, notify, t]);
const openNoteSource = async (source: MagicNoteSource, messageId?: string): Promise<'opened' | 'missing'> => {
let snapshot;
try { snapshot = await window.goodbuddy.conversations.get(source.conversationId); }
catch (reason) {
if (reason instanceof Error && /对话不存在(?:$|["'])/u.test(reason.message)) return 'missing';
throw reason;
}
requestWorkspaceLeave('chat', () => {
const next = mergePersistedConversations(conversationsRef.current, [snapshot], persistedLocalConversationsRef.current, new Set([source.conversationId, ...retainedConversationDetailIds()]));
conversationsRef.current = next;
setConversations(next);
setActiveProjectId(snapshot.projectId ?? '');
setActiveId(source.conversationId);
setSearchQuery('');
commitView('chat');
const targetId = messageId ?? (source.kind === 'message' ? source.messageIds[0] : undefined);
if (targetId) {
const conversation = next.find(item => item.id === source.conversationId)!;
const index = conversation.messages.findIndex(item => item.id === targetId);
if (index < 0 || !snapshot.messages.some(message => message.id === targetId)) notify({ tone: 'info', message: t('magicNotes:capture.missingMessage') });
else {
setVisibleMessageCounts(current => ({ ...current, [source.conversationId]: Math.max(current[source.conversationId] ?? messageRenderBatchSize, conversation.messages.length - index) }));
setNoteMessageNavigation({ conversationId: source.conversationId, messageId: targetId, requestId: Date.now() });
}
}
});
return 'opened';
};
const openActivityConversation = (conversationId: string): void => {
const open = async (): Promise<void> => {
let conversation = conversationsRef.current.find(
@@ -9597,6 +9709,7 @@ function App(): React.JSX.Element {
) : null}
</div>
{magicNotesEnabled && <button type="button" className="nav-item" title={t('magicNotes:capture.quick')} onClick={openQuickNotes}><FileText size={17} aria-hidden="true" /><span>{t('magicNotes:capture.quick')}</span></button>}
<div className="sidebar-footer sidebar-footer--applications">
<button
className="nav-item"
@@ -9735,6 +9848,12 @@ function App(): React.JSX.Element {
</b>
)}
</div>
{magicNotesEnabled && activeConversation && <>
<button type="button" className="icon-button" ref={noteTitleMenuRef} aria-label={t('magicNotes:capture.menu')} title={t('magicNotes:capture.menu')} aria-haspopup="menu" aria-expanded={noteTitleMenuOpen} onClick={() => setNoteTitleMenuOpen(value => !value)}><MoreHorizontal size={16} /></button>
{noteTitleMenuOpen && <AnchoredMenu anchorRef={noteTitleMenuRef} id="note-conversation-menu" label={t('magicNotes:capture.menu')} onClose={() => setNoteTitleMenuOpen(false)}>
<button role="menuitem" tabIndex={-1} type="button" disabled={isRunning || noteCaptureLoading || noteDraft.saving} title={isRunning ? t('magicNotes:capture.wait') : undefined} onClick={() => { setNoteTitleMenuOpen(false); void captureToNote(activeConversation.id); }}>{t(noteCaptureLoading ? 'magicNotes:capture.loading' : 'magicNotes:capture.conversation')}</button>
</AnchoredMenu>}
</>}
<ScopeBadge
scope={
activeProject
@@ -9752,6 +9871,7 @@ function App(): React.JSX.Element {
</>
)}
<div className="topbar__actions">
{magicNotesEnabled && !sidebarOpen && <button type="button" className="icon-button" aria-label={t('magicNotes:capture.quick')} title={t('magicNotes:capture.quick')} onClick={openQuickNotes}><FileText size={17} aria-hidden="true" /></button>}
<button
aria-controls="assistant-sidebar"
aria-expanded={assistantSidebarOpen}
@@ -9822,6 +9942,8 @@ function App(): React.JSX.Element {
key={conversation.id}
locale={locale}
onCopyMessage={copyMessage}
onAddToNote={magicNotesEnabled ? captureToNote : undefined}
noteMessageNavigation={noteMessageNavigation}
onDownloadImage={downloadImage}
onEditImage={editImage}
onOpenImageModelSettings={openImageModelSettings}
@@ -11061,7 +11183,7 @@ function App(): React.JSX.Element {
<RouteLoadingStatus label={t("route.loading")} />
}
>
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} onBeforeLeave={registerNotesLeaveRequester} />
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} onBeforeLeave={registerNotesLeaveRequester} navigation={notesNavigation} onOpenSource={openNoteSource} />
</Suspense>
</RouteErrorBoundary>
</PageShell>
@@ -11734,6 +11856,13 @@ function App(): React.JSX.Element {
/>
)}
<RightAssistantSidebar
notesEnabled={magicNotesEnabled}
notesSettingsReady={Boolean(applicationSettings)}
notesOpenRequest={notesOpenRequest}
onBeforeCloseNotes={async () => { if (!await guardNoteDraft()) return false; setNoteDraft(undefined); return true; }}
notesPanel={<MagicNotesPanel state={noteDraft} active={assistantSidebarOpen && magicNotesEnabled} commentMode={applicationSettings?.magicNoteCommentMode} commentFormat={applicationSettings?.magicNoteCommentFormat} onNotify={notify} onOpenSource={openNoteSource}
onCancelCapture={() => requestAnimationFrame(() => { const trigger = noteCaptureTrigger.current; (trigger?.isConnected && !trigger.closest('[hidden], [inert]') ? trigger : noteTitleMenuRef.current)?.focus(); })}
onOpenWorkspace={(noteId, entryId) => requestWorkspaceLeave('magic-notes', () => { setNotesNavigation({ noteId, entryId, requestId: Date.now() }); commitView('magic-notes'); })} />}
activeConversationId={activeId}
conversationStats={statsConversation && executionStats.conversation ? {
conversationId: statsConversation.id,
@@ -11835,6 +11964,7 @@ function App(): React.JSX.Element {
/>
</div>
</div>
{noteDraft.confirmation}
</DocumentConversationContext>
</div>
);
+18
View File
@@ -80,6 +80,24 @@ function createMessages(): Message[] {
}
describe('ChatTimeline', () => {
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
const onAddToNote = vi.fn()
const messages: Message[] = [
{ id: 'complete', role: 'assistant', content: 'Complete text', createdAt: 1, state: 'complete' },
{ id: 'partial', role: 'assistant', content: 'Partial text', createdAt: 2, state: 'error' },
{ id: 'streaming', role: 'assistant', content: 'Streaming text', createdAt: 3, state: 'streaming' },
{ id: 'empty', role: 'assistant', content: '', createdAt: 4, state: 'complete' },
{ id: 'user', role: 'user', content: 'User text', createdAt: 5, state: 'complete' }
]
const props = { ...callbacks, artifactById: new Map(), conversationId: 'capture-conversation', hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: messages.length, messages }
const view = render(<ChatTimeline {...props} onAddToNote={onAddToNote} />)
const buttons = screen.getAllByRole('button', { name: '加入笔记' })
expect(buttons).toHaveLength(2)
fireEvent.click(buttons[1]!)
expect(onAddToNote).toHaveBeenCalledWith('capture-conversation', messages[1], buttons[1])
view.rerender(<ChatTimeline {...props} />)
expect(screen.queryByRole('button', { name: '加入笔记' })).not.toBeInTheDocument()
})
it('keeps a question before later tools and text when its answer arrives and history reopens', () => {
const props = {
artifactById: new Map(), conversationId: 'question-order',
+6
View File
@@ -526,6 +526,7 @@ function SubagentStatusList({
}
type ChatMessageRowProps = {
onAddToNote?: (conversationId: string, message: Message, trigger: HTMLElement) => void
onOpenImageModelSettings?: () => void
onReselectImageSources?: (operation: ImageOperation) => void
onEditImage?: (artifact: AssistantArtifact) => void
@@ -563,6 +564,7 @@ type ChatMessageRowProps = {
}
function ChatMessageRowView({
onAddToNote,
onOpenImageModelSettings,
onReselectImageSources,
onEditImage,
@@ -1274,6 +1276,7 @@ function ChatMessageRowView({
message.state !== 'streaming' &&
Boolean(message.content.trim()) && (
<div className="message__actions">
{!greeting && onAddToNote && <button type="button" className="icon-button" aria-label={t('magicNotes:capture.add')} title={t('magicNotes:capture.add')} onClick={event => onAddToNote(conversationId, message, event.currentTarget)}><FileText aria-hidden="true" size={15} /></button>}
<button
aria-label={t(copied ? 'notices.messageCopied' : 'chat.copyMessage')}
className="icon-button"
@@ -1321,6 +1324,7 @@ function ChatMessageRowView({
export const ChatMessageRow = memo(ChatMessageRowView)
type ChatTimelineProps = {
onAddToNote?: ChatMessageRowProps['onAddToNote']
onOpenImageModelSettings?: () => void
onReselectImageSources?: (operation: ImageOperation) => void
onEditImage?: (artifact: AssistantArtifact) => void
@@ -1361,6 +1365,7 @@ type ChatTimelineProps = {
}
export const ChatTimeline = memo(function ChatTimeline({
onAddToNote,
onOpenImageModelSettings,
onReselectImageSources,
onEditImage,
@@ -1404,6 +1409,7 @@ export const ChatTimeline = memo(function ChatTimeline({
const messageIndex = messageStartIndex + visibleMessageIndex
return (
<ChatMessageRow
onAddToNote={onAddToNote}
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
+29
View File
@@ -0,0 +1,29 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { MagicNoteSource as Source } from '../../shared/magic-notes-contracts'
export type OpenMagicNoteSource = (source: Source, messageId?: string) => Promise<'opened' | 'missing'>
export function MagicNoteSource({ source, onOpen }: { source: Source; onOpen?: OpenMagicNoteSource }): React.JSX.Element {
const { t, i18n } = useTranslation('magicNotes')
const [missing, setMissing] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const open = async (messageId?: string): Promise<void> => {
if (!onOpen || busy) return
setBusy(true)
setError('')
try { setMissing(await onOpen(source, messageId) === 'missing') }
catch (reason) { setError(reason instanceof Error ? reason.message : t('errors.operationFailed')) }
finally { setBusy(false) }
}
return <aside className="magic-note-source" aria-label={t('capture.source')}>
<span>{t('capture.source')}: {source.conversationTitle}</span>
<span>{source.projectName ?? t('capture.global')} · <time dateTime={source.capturedAt}>{new Date(source.capturedAt).toLocaleString(i18n.language)}</time></span>
{onOpen && <div className="magic-note-panel__actions">
<button className="secondary-button" type="button" disabled={missing || busy} onClick={() => void open()}>{t(missing ? 'capture.missingConversation' : 'capture.openSource')}</button>
{source.kind === 'message' && <button className="secondary-button" type="button" disabled={missing || busy} onClick={() => void open(source.messageIds[0])}>{t('capture.openMessage')}</button>}
</div>}
{error && <p role="alert">{error} <button className="secondary-button" type="button" onClick={() => void open(source.kind === 'message' ? source.messageIds[0] : undefined)}>{t('actions.retry')}</button></p>}
</aside>
}
+210
View File
@@ -0,0 +1,210 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopApi } from '../../shared/contracts'
import type { MagicNoteDetail, MagicNoteSource } from '../../shared/magic-notes-contracts'
import { magicNoteRichContentSchema } from '../../shared/magic-notes-contracts'
import { capturedNoteContent, MagicNotesPanel } from './MagicNotesPanel'
import { useMagicNoteDraft } from './use-magic-note-draft'
import { MagicNoteSource as SourceBar } from './MagicNoteSource'
import i18n from './i18n'
const note: MagicNoteDetail = { id: '00000000-0000-4000-8000-000000000601', title: 'Release decisions', preview: 'Existing text', entryCount: 0, entries: [], pinned: true, revision: 1, createdAt: '2026-09-26T00:00:00Z', updatedAt: '2026-09-26T00:00:00Z' }
const source: MagicNoteSource = { kind: 'message', conversationId: 'conversation-a', messageIds: ['message-a'], capturedAt: '2026-09-26T00:00:00Z', conversationTitle: 'Discussion A', projectName: 'Project A' }
const entry = { id: '00000000-0000-4000-8000-000000000602', noteId: note.id, content: capturedNoteContent('Captured text'), plainText: 'Captured text', comments: [], revision: 1, createdAt: note.createdAt, updatedAt: note.updatedAt, source }
const search = vi.fn<DesktopApi['magicNotes']['search']>()
const get = vi.fn<DesktopApi['magicNotes']['get']>()
const create = vi.fn<DesktopApi['magicNotes']['create']>()
const createEntry = vi.fn<DesktopApi['magicNotes']['createEntry']>()
const analyze = vi.fn<DesktopApi['magicNotes']['analyze']>()
const onNotify = vi.fn()
const onOpenWorkspace = vi.fn()
let onChanged: () => void
function Harness({ mode = 'immediate' }: { mode?: 'immediate' | 'after-save-auto' | 'after-save-manual' }): React.JSX.Element {
const state = useMagicNoteDraft()
const [visible, setVisible] = useState(true)
return <>
<button onClick={() => setVisible(value => !value)}>Hide panel</button>
<button onClick={() => { void state.guard().then(allowed => { if (allowed) state.setDraft({ text: 'Captured text', initialText: 'Captured text', title: 'Discussion A', initialTitle: 'Discussion A', targetId: '', newNote: false, source }) }) }}>Capture</button>
<button onClick={() => { void state.guard().then(allowed => { if (allowed) { state.setDraft(undefined); setVisible(false) } }) }}>Close tab</button>
<div hidden={!visible}><MagicNotesPanel state={state} active={visible} commentMode={mode} onNotify={onNotify} onOpenWorkspace={onOpenWorkspace} onOpenSource={async () => 'opened'} onCancelCapture={vi.fn()} /></div>
{state.confirmation}
</>
}
beforeEach(async () => {
vi.resetAllMocks()
await i18n.changeLanguage('en-US')
search.mockResolvedValue([note])
get.mockResolvedValue(note)
const saved = { ...note, entries: [entry], entryCount: 1, createdEntryId: entry.id }
create.mockImplementation(async () => { get.mockResolvedValue(saved); return saved })
createEntry.mockImplementation(async () => { get.mockResolvedValue(saved); return saved })
analyze.mockResolvedValue(note)
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { magicNotes: { search, get, create, createEntry, analyze, onChanged: (listener: () => void) => { onChanged = listener; return vi.fn() } } } })
})
afterEach(cleanup)
describe('compact notes', () => {
it('creates the note and captured entry in one explicit submit and focuses the returned entry', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
await screen.findByDisplayValue('Captured text')
expect(create).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'New note' }))
expect(screen.getByLabelText('Note title')).toHaveValue('Discussion A')
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
await waitFor(() => {
expect(create).toHaveBeenCalledWith({ title: 'Discussion A', content: capturedNoteContent('Captured text'), source })
expect(createEntry).not.toHaveBeenCalled()
expect(document.activeElement).toHaveAttribute('id', `compact-note-entry-${entry.id}`)
})
expect(analyze).not.toHaveBeenCalled()
expect(onNotify).toHaveBeenCalledWith({ tone: 'success', message: 'Entry saved' })
})
it('preserves edited content and source through hiding, refresh, failure, and retry', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
const text = await screen.findByLabelText('Text to add')
fireEvent.change(text, { target: { value: 'Edited **Markdown**\n```ts\nconst a = 1\n```' } })
fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ }))
await waitFor(() => expect(get).toHaveBeenCalledWith(note.id))
fireEvent.click(screen.getByText('Hide panel'))
act(() => onChanged())
fireEvent.click(screen.getByText('Hide panel'))
expect(await screen.findByLabelText('Text to add')).toHaveValue('Edited **Markdown**\n```ts\nconst a = 1\n```')
createEntry.mockRejectedValueOnce(new Error('Disk full'))
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Disk full')
expect(screen.getByLabelText('Text to add')).toHaveValue('Edited **Markdown**\n```ts\nconst a = 1\n```')
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledTimes(2))
expect(createEntry).toHaveBeenLastCalledWith({ noteId: note.id, content: capturedNoteContent('Edited **Markdown**\n```ts\nconst a = 1\n```'), source })
})
it('guards close, replacement, modified cancellation, and workspace navigation', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
await screen.findByDisplayValue('Captured text')
for (const action of ['Close tab', 'Capture']) {
fireEvent.click(screen.getByText(action))
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Continue editing' }))
expect(screen.getByLabelText('Text to add')).toHaveValue('Captured text')
}
fireEvent.change(screen.getByLabelText('Text to add'), { target: { value: 'Changed preview' } })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Discard and switch' }))
fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ }))
fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'Quick draft' } })
fireEvent.click(screen.getByRole('button', { name: 'Open in full workspace' }))
expect(onOpenWorkspace).not.toHaveBeenCalled()
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Discard and switch' }))
await waitFor(() => expect(onOpenWorkspace).toHaveBeenCalledWith(note.id, undefined))
})
it.each(['immediate', 'after-save-manual', 'after-save-auto'] as const)('respects %s without analyzing the capture draft', async mode => {
render(<Harness mode={mode} />)
fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ }))
fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'Quick text' } })
expect(analyze).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
await waitFor(() => expect(createEntry).toHaveBeenCalledOnce())
expect(createEntry).toHaveBeenCalledWith({ noteId: note.id, content: capturedNoteContent('Quick text'), source: undefined })
await waitFor(() => expect(onNotify).toHaveBeenCalled())
expect(analyze).toHaveBeenCalledTimes(mode === 'after-save-auto' ? 1 : 0)
})
it('keeps a successful save when automatic comments fail', async () => {
analyze.mockRejectedValue(new Error('Model offline'))
render(<Harness mode="after-save-auto" />)
fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ }))
fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'Quick text' } })
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: expect.stringContaining('Model offline') })))
expect(createEntry).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Text to add')).toHaveValue('')
})
it('locks duplicate submission, cancellation, and closing until the save completes', async () => {
let finish!: (result: Awaited<ReturnType<DesktopApi['magicNotes']['createEntry']>>) => void
createEntry.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
render(<Harness />)
fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ }))
fireEvent.change(await screen.findByLabelText('Text to add'), { target: { value: 'In-flight text' } })
fireEvent.click(screen.getByRole('button', { name: 'Add to note' }))
expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled()
expect(screen.getByLabelText('Text to add')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
fireEvent.click(screen.getByText('Close tab'))
await act(async () => {})
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByLabelText('Text to add')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Saving...' }))
expect(createEntry).toHaveBeenCalledOnce()
await act(async () => finish({ ...note, entries: [entry], entryCount: 1, createdEntryId: entry.id }))
expect(screen.getByLabelText('Text to add')).toHaveValue('')
})
it('shows an oversized preview unchanged and allows correction before saving', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
const preview = await screen.findByLabelText('Text to add')
const oversized = 'x'.repeat(512_001)
fireEvent.change(preview, { target: { value: oversized } })
fireEvent.click(screen.getByRole('button', { name: 'New note' }))
expect(preview).toHaveValue(oversized)
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
expect(screen.getByText(/Each entry supports up to 500 KB/)).toBeVisible()
fireEvent.change(preview, { target: { value: 'Within capacity' } })
expect(screen.getByRole('button', { name: 'Add to note' })).toBeEnabled()
expect(create).not.toHaveBeenCalled()
})
it('preserves text when the target disappears and requires a new selection', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
fireEvent.click(await screen.findByRole('button', { name: /Release decisions/ }))
await waitFor(() => expect(get).toHaveBeenCalled())
get.mockRejectedValue(new Error('笔记不存在'))
search.mockResolvedValue([])
act(() => onChanged())
await waitFor(() => expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled())
expect(screen.getByLabelText('Text to add')).toHaveValue('Captured text')
expect(createEntry).not.toHaveBeenCalled()
})
it('searches entry text, cancels stale requests, and does not replace the draft', async () => {
let finishOld!: (notes: MagicNoteDetail[]) => void
search.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve }))
render(<Harness />)
await waitFor(() => expect(search).toHaveBeenCalled())
fireEvent.change(screen.getByLabelText('Search notes'), { target: { value: 'deep entry text' } })
await waitFor(() => expect(search).toHaveBeenLastCalledWith({ query: 'deep entry text', limit: 100 }))
await screen.findByRole('button', { name: /Release decisions/ })
await act(async () => finishOld([{ ...note, title: 'Stale result' }]))
expect(screen.queryByText('Stale result')).not.toBeInTheDocument()
})
it('retains large previews and validates without truncating or splitting entries', () => {
const text = 'a'.repeat(199_999) + '😀' + 'b'.repeat(200_000)
const content = capturedNoteContent(text)
expect(magicNoteRichContentSchema.safeParse(content).success).toBe(true)
expect(content.ops.map(op => op.insert).join('')).toBe(text + '\n')
expect(magicNoteRichContentSchema.safeParse(capturedNoteContent('中'.repeat(180_000))).success).toBe(false)
})
})
it('distinguishes deleted source from transient failure and preserves source labels', async () => {
const open = vi.fn().mockRejectedValueOnce(new Error('Read failed')).mockResolvedValue('missing')
render(<SourceBar source={source} onOpen={open} />)
fireEvent.click(screen.getByRole('button', { name: 'View source message' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Read failed')
expect(screen.getByRole('button', { name: 'Return to conversation' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('button', { name: 'The original conversation no longer exists' })).toBeDisabled()
expect(screen.getByText('From conversation: Discussion A')).toBeVisible()
expect(open).toHaveBeenLastCalledWith(source, 'message-a')
})
+282
View File
@@ -0,0 +1,282 @@
import { ArrowLeft, ExternalLink, Pin, Plus } from 'lucide-react'
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { magicNoteRichContentSchema, type MagicNoteCommentFormat, type MagicNoteDetail, type MagicNoteRichContent, type MagicNoteSummary } from '../../shared/magic-notes-contracts'
import type { MagicNoteCommentMode } from '../../shared/application-settings-contracts'
import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
import type { useMagicNoteDraft } from './use-magic-note-draft'
import type { AppNotificationInput } from './notifications'
import { ScopeBadge, SegmentedControl } from './WorkspacePrimitives'
import './magic-notes-panel.css'
const MagicCanvasThumbnail = lazy(async () => ({ default: (await import('./MagicCanvasThumbnail')).MagicCanvasThumbnail }))
// Split operations at the existing rich-text operation limit, without truncating text.
export function capturedNoteContent(text: string): MagicNoteRichContent {
const value = text.endsWith('\n') ? text : `${text}\n`
const ops: MagicNoteRichContent['ops'] = []
for (let start = 0; start < value.length;) {
let end = Math.min(start + 200_000, value.length)
if (end < value.length && /[\uD800-\uDBFF]/u.test(value[end - 1]!)) end--
ops.push({ insert: value.slice(start, end) })
start = end
}
return { version: 1, ops }
}
export type MagicNotesPanelProps = {
state: ReturnType<typeof useMagicNoteDraft>
active: boolean
commentMode?: MagicNoteCommentMode
commentFormat?: MagicNoteCommentFormat
onNotify: (notification: AppNotificationInput) => void
onOpenWorkspace: (noteId: string, entryId?: string) => void
onOpenSource: OpenMagicNoteSource
onCancelCapture: () => void
}
export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'combined', onNotify, onOpenWorkspace, onOpenSource, onCancelCapture }: MagicNotesPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('magicNotes')
const { draft, setDraft, saving, setSaving, selectedNoteId, setSelectedNoteId, guard } = state
const [query, setQuery] = useState('')
const [notes, setNotes] = useState<MagicNoteSummary[]>([])
const [detail, setDetail] = useState<MagicNoteDetail>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saveError, setSaveError] = useState('')
const [previousSource, setPreviousSource] = useState(draft?.source)
if (previousSource !== draft?.source) {
setPreviousSource(draft?.source)
setSaveError('')
}
const [refresh, setRefresh] = useState(0)
const [highlight, setHighlight] = useState<string>()
const submitting = useRef(false)
const preview = useRef<HTMLTextAreaElement>(null)
const title = useRef<HTMLInputElement>(null)
const currentDetail = detail?.id === selectedNoteId ? detail : undefined
const quickDraft = Boolean(draft && !draft.source && !draft.newNote && draft.targetId === selectedNoteId)
const content = useMemo(() => draft ? capturedNoteContent(draft.text) : undefined, [draft])
const tooLong = useMemo(() => Boolean(content && !magicNoteRichContentSchema.safeParse(content).success), [content])
const valid = Boolean(draft && !tooLong && (draft.newNote ? draft.title.trim().length > 0 && draft.title.trim().length <= 100 : draft.targetId) && (draft.text.trim() || (draft.newNote && !draft.source)))
useEffect(() => window.goodbuddy.magicNotes.onChanged(() => setRefresh(value => value + 1)), [])
useEffect(() => {
if (!active) return
let current = true
const timer = setTimeout(() => {
setLoading(true)
setError('')
void Promise.all([
window.goodbuddy.magicNotes.search({ query, limit: 100 }),
selectedNoteId ? window.goodbuddy.magicNotes.get(selectedNoteId).catch(reason => {
if (reason instanceof Error && reason.message.includes('笔记不存在')) {
if (current) {
setSelectedNoteId(''); setDetail(undefined); setSaveError(t('capture.deletedTarget'))
setDraft(value => value?.targetId === selectedNoteId ? { ...value, targetId: '' } : value)
}
return undefined
}
throw reason
}) : undefined
]).then(([items, note]) => { if (current) { setNotes(items); setDetail(note) } })
.catch(reason => { if (current) setError(reason instanceof Error ? reason.message : t('errors.operationFailed')) })
.finally(() => { if (current) setLoading(false) })
}, query ? 180 : 0)
return () => { current = false; clearTimeout(timer) }
// Drafts are independent of external refreshes and search responses.
}, [active, query, selectedNoteId, refresh, setSelectedNoteId, setDraft, t])
const captureSource = draft?.source
const newNote = draft?.newNote
useEffect(() => { if (active && captureSource) preview.current?.focus() }, [active, captureSource])
useEffect(() => { if (active && newNote) title.current?.focus() }, [active, newNote])
useEffect(() => {
if (!highlight || !active) return
const article = document.getElementById(`compact-note-entry-${highlight}`)
article?.scrollIntoView?.({ block: 'nearest' })
article?.focus({ preventScroll: true })
const timer = setTimeout(() => setHighlight(undefined), 2400)
return () => clearTimeout(timer)
}, [highlight, active])
const startNew = (): void => {
if (draft) { setDraft({ ...draft, newNote: true }); return }
setDraft({ text: '', initialText: '', title: '', initialTitle: '', targetId: '', newNote: true })
}
const select = async (noteId: string): Promise<void> => {
if (draft) { setDraft({ ...draft, targetId: noteId, newNote: false }); setSelectedNoteId(noteId); setSaveError(''); return }
setSelectedNoteId(noteId)
}
const cancel = async (): Promise<void> => {
if (!await guard(true)) return
setDraft(undefined)
setSaveError('')
if (draft?.source) onCancelCapture()
else requestAnimationFrame(() => (document.getElementById('compact-note-append') ?? document.getElementById('compact-note-search'))?.focus())
}
const save = async (): Promise<void> => {
if (!draft || !content || !valid || submitting.current) return
submitting.current = true
setSaving(true)
setSaveError('')
try {
const saved = draft.newNote
? await window.goodbuddy.magicNotes.create({ title: draft.title.trim(), ...(draft.text.trim() ? { content } : {}), source: draft.source })
: await window.goodbuddy.magicNotes.createEntry({ noteId: draft.targetId, content, source: draft.source })
setSelectedNoteId(saved.id)
setDetail(saved)
setDraft(undefined)
setHighlight(saved.createdEntryId)
setRefresh(value => value + 1)
onNotify({ tone: 'success', message: t(saved.createdEntryId ? 'notifications.entrySaved' : 'notifications.noteCreated') })
if (commentMode === 'after-save-auto' && saved.createdEntryId) {
void window.goodbuddy.magicNotes.analyze(saved.createdEntryId, { requestId: crypto.randomUUID(), direction: 'general', format: commentFormat })
.catch(reason => onNotify({ tone: 'error', message: t('canvas.savedAnalysisFailed', { error: reason instanceof Error ? reason.message : t('errors.operationFailed') }) }))
}
} catch (reason) {
setSaveError(reason instanceof Error && reason.message.includes('笔记不存在') ? t('capture.deletedTarget') : reason instanceof Error ? reason.message : t('errors.operationFailed'))
} finally { submitting.current = false; setSaving(false) }
}
const openWorkspace = async (entryId?: string): Promise<void> => {
if (!currentDetail || !await guard()) return
setDraft(undefined)
onOpenWorkspace(currentDetail.id, entryId)
}
const backToList = async (): Promise<void> => {
if (!await guard()) return
setDraft(undefined)
setSelectedNoteId('')
requestAnimationFrame(() => document.getElementById('compact-note-search')?.focus())
}
const validationId = `compact-note-validation${saveError ? ' compact-note-save-error' : ''}`
const list = (
<>
<label htmlFor="compact-note-search">{t('capture.search')}</label>
<input id="compact-note-search" type="search" value={query} maxLength={1000}
onChange={event => setQuery(event.target.value)} disabled={saving} />
{loading ? <p role="status">{t('status.loadingNotes')}</p> : notes.length === 0 ? (
!error && <p>{t(query ? 'capture.noResults' : 'capture.empty')}</p>
) : (
<div className="magic-note-panel__list">
{notes.map(note => (
<button type="button" className="magic-note-panel__note" key={note.id}
aria-pressed={draft?.targetId === note.id && !draft.newNote}
disabled={saving} onClick={() => void select(note.id)}>
<strong>{note.pinned && <Pin size={13} aria-label={t('status.pinned')} />}{note.title}</strong>
<span>{note.preview}</span>
<time dateTime={note.updatedAt}>{new Date(note.updatedAt).toLocaleString(i18n.language)}</time>
</button>
))}
</div>
)}
</>
)
return (
<section className="magic-note-panel" aria-label={t('capture.quick')} aria-busy={saving}
onKeyDown={event => {
if (event.key !== 'Escape') return
event.stopPropagation()
if (draft) void cancel()
else if (currentDetail) void backToList()
}}>
<header className="magic-note-panel__header">
<strong>{draft?.source ? t('capture.add') : currentDetail?.title ?? t('page.title')}</strong>
<ScopeBadge scope={{ kind: 'global' }} />
{(!draft || quickDraft) && currentDetail && (
<div className="magic-note-panel__actions">
<button className="icon-button" type="button" title={t('capture.back')}
aria-label={t('capture.back')} disabled={saving} onClick={() => void backToList()}>
<ArrowLeft size={16} />
</button>
<button className="icon-button" type="button" title={t('capture.openWorkspace')}
aria-label={t('capture.openWorkspace')} disabled={saving} onClick={() => void openWorkspace()}>
<ExternalLink size={16} />
</button>
</div>
)}
</header>
{error && <p role="alert">{error} <button className="secondary-button" type="button"
onClick={() => setRefresh(value => value + 1)}>{t('actions.retry')}</button></p>}
<div className="magic-note-panel__scroll">
{draft && !quickDraft ? (
<>
{draft.source && <div className="magic-note-source">
<span>{draft.source.projectName ?? t('capture.global')} / {draft.source.conversationTitle}</span>
<p>{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}</p>
{draft.incomplete && <p>{t('capture.incomplete')}</p>}
</div>}
<label htmlFor="compact-note-text">{t('capture.content')}</label>
<textarea ref={preview} id="compact-note-text" value={draft.text} disabled={saving}
aria-describedby={validationId} aria-invalid={tooLong || undefined}
onChange={event => setDraft({ ...draft, text: event.target.value })} />
<h3>{t('capture.target')}</h3>
<SegmentedControl ariaLabel={t('capture.target')} disabled={saving}
value={draft.newNote ? 'new' : 'existing'}
options={[{ value: 'existing', label: t('capture.choose') }, { value: 'new', label: t('actions.newNote') }]}
onChange={value => setDraft({ ...draft, newNote: value === 'new' })} />
{draft.newNote ? (
<>
<label htmlFor="compact-note-title">{t('capture.title')}</label>
<input ref={title} id="compact-note-title" value={draft.title} disabled={saving}
aria-describedby={validationId} aria-invalid={draft.title.trim().length > 100 || undefined}
onChange={event => setDraft({ ...draft, title: event.target.value })} />
</>
) : (
<>
{draft.targetId && <p>{notes.find(note => note.id === draft.targetId)?.title ??
(currentDetail?.id === draft.targetId ? currentDetail.title : t('capture.choose'))}</p>}
{list}
</>
)}
</>
) : currentDetail ? (
<>
{currentDetail.entries.length === 0 && <p>{t('notes.emptyEntries')}</p>}
{currentDetail.entries.map(entry => (
<article key={entry.id} id={`compact-note-entry-${entry.id}`} tabIndex={-1}
className={`magic-note-panel__entry${highlight === entry.id ? ' magic-note-panel__entry--highlight' : ''}`}>
<time dateTime={entry.createdAt}>{new Date(entry.createdAt).toLocaleString(i18n.language)}</time>
{entry.source && <MagicNoteSource source={entry.source} onOpen={onOpenSource} />}
{entry.content.version === 2 && <>
<Suspense fallback={<span>{t('status.loading')}</span>}>
<MagicCanvasThumbnail content={entry.content} />
</Suspense>
<span>{t('records.pages', { count: entry.content.pages.length })}</span>
</>}
<p className="magic-note-panel__preview">{entry.plainText.slice(0, 600)}{entry.plainText.length > 600 ? '…' : ''}</p>
<button className="secondary-button" type="button" disabled={saving}
onClick={() => void openWorkspace(entry.id)}>{t('capture.openWorkspace')}</button>
</article>
))}
</>
) : (
<>{list}<button className="secondary-button" type="button" onClick={startNew}>
<Plus size={14} />{t('actions.newNote')}
</button></>
)}
</div>
{(draft || currentDetail) && (
<footer className="magic-note-panel__footer">
{(!draft || quickDraft) && currentDetail && <>
<label htmlFor="compact-note-append">{t('capture.content')}</label>
<textarea id="compact-note-append" value={draft?.text ?? ''} disabled={saving}
aria-describedby={validationId} aria-invalid={tooLong || undefined}
onChange={event => setDraft(draft ? { ...draft, text: event.target.value } : {
text: event.target.value, initialText: '', title: '', initialTitle: '', targetId: currentDetail.id, newNote: false
})} />
</>}
<p id="compact-note-validation">{tooLong ? t('capture.tooLong') : draft && !valid ? t('capture.required') : t('capture.global')}</p>
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
<div className="magic-note-panel__actions">
{draft && <button className="secondary-button" type="button" disabled={saving}
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
</button>
</div>
</footer>
)}
</section>
)
}
@@ -200,6 +200,16 @@ async function openTodo(): Promise<void> {
}
describe('MagicNotesWorkspace overview navigation', () => {
it('opens an externally requested note and entry after initial loading and renders its source', async () => {
const source = { kind: 'message' as const, conversationId: 'conversation-a', messageIds: ['message-a'], capturedAt: detail.createdAt, conversationTitle: 'Original discussion' }
get.mockResolvedValue({ ...detail, entries: [{ ...detail.entries[0]!, source }] })
const onOpenSource = vi.fn(async () => 'opened' as const)
render(<MagicNotesWorkspace onNotify={onNotify} navigation={{ noteId, entryId, requestId: 1 }} onOpenSource={onOpenSource} />)
expect(await screen.findByDisplayValue(detail.title)).toBeVisible()
fireEvent.click(await screen.findByRole('button', { name: '查看来源消息' }))
expect(onOpenSource).toHaveBeenCalledWith(source, 'message-a')
expect(document.getElementById(`magic-note-entry-${entryId}`)).toBeVisible()
})
it('flushes pending canvas input before an App leave and retains it when continuing', async () => {
let requestLeave: ((leave: () => void) => void) | undefined
const leave = vi.fn()
+14
View File
@@ -49,6 +49,8 @@ import type {
import type { ApplicationSettings, MagicNoteCommentMode } from '../../shared/application-settings-contracts'
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
import { MagicNoteContent } from './MagicNoteContent'
import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
import './magic-notes-panel.css'
import { MagicTodoDirectory } from './MagicTodoDirectory'
import { MagicNoteEditor } from './MagicNoteEditor'
import { MagicCanvasEditor, canvasHasContent, type MagicCanvasEditorHandle } from './MagicCanvasEditor'
@@ -65,6 +67,8 @@ import {
} from './WorkspacePrimitives'
export type MagicNotesWorkspaceProps = {
navigation?: { noteId: string; entryId?: string; requestId: number }
onOpenSource?: OpenMagicNoteSource
applicationSettings?: ApplicationSettings
onNotify: (notification: AppNotificationInput) => void
onBeforeLeave?: (requester: ((leave: () => void) => void) | undefined) => void
@@ -348,6 +352,8 @@ function TodoListItem({
}
export function MagicNotesWorkspace({
navigation,
onOpenSource,
onNotify,
onBeforeLeave,
applicationSettings
@@ -1156,6 +1162,13 @@ export function MagicNotesWorkspace({
return () => onBeforeLeave?.(undefined)
}, [onBeforeLeave, requestDraftSwitch])
const handledNavigation = useRef<typeof navigation>(undefined)
useEffect(() => {
if (!navigation || handledNavigation.current === navigation || loadStatus !== 'ready' || busy) return
handledNavigation.current = navigation
void requestDraftSwitch({ kind: 'note', noteId: navigation.noteId, entryId: navigation.entryId })
}, [navigation, requestDraftSwitch, loadStatus, busy])
const continueEditing = useCallback((): void => {
setPendingDraftSwitch(undefined)
requestAnimationFrame(() => {
@@ -2710,6 +2723,7 @@ export function MagicNotesWorkspace({
tabIndex={-1}
className={`magic-note-entry${editingEntry?.id === entry.id && entry.content.version === 2 ? ' magic-note-entry--canvas-editing' : ''}`}
>
{entry.source && <MagicNoteSource source={entry.source} onOpen={onOpenSource} />}
<header>
<time dateTime={entry.createdAt}>
{dateFormatter.format(new Date(entry.createdAt))}
+41 -4
View File
@@ -83,6 +83,7 @@ export type AssistantSidebarTab =
| 'workspace'
| 'browser'
| 'results'
| 'notes'
export type SidebarArtifact = {
id: string
@@ -119,6 +120,11 @@ export type SidebarTaskDuration = {
}
export type RightAssistantSidebarProps = {
notesEnabled?: boolean
notesSettingsReady?: boolean
notesOpenRequest?: number
notesPanel?: React.ReactNode
onBeforeCloseNotes?: () => Promise<boolean>
open: boolean
tab: AssistantSidebarTab
approvals: PendingSidebarApproval[]
@@ -179,7 +185,8 @@ const tabIds: AssistantSidebarTab[] = [
'tasks',
'workspace',
'browser',
'results'
'results',
'notes'
]
const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarRatio = 0.3
@@ -803,6 +810,11 @@ function BrowserViewport({
}
export function RightAssistantSidebar({
notesEnabled = false,
notesSettingsReady = true,
notesOpenRequest = 0,
notesPanel,
onBeforeCloseNotes,
open,
tab,
approvals,
@@ -876,7 +888,7 @@ export function RightAssistantSidebar({
)
const localizedAppDefinitions = useMemo(
() =>
WORKBAR_APP_DEFINITIONS.map((definition) => {
WORKBAR_APP_DEFINITIONS.filter(definition => definition.id !== 'notes' || notesEnabled).map((definition) => {
const tabDefinition = tabs.find(
(item) => item.id === definition.id
)
@@ -890,7 +902,7 @@ export function RightAssistantSidebar({
t('sidebar.tabs.terminal.description')
} satisfies WorkbarAppDefinition
}),
[t, tabs]
[t, tabs, notesEnabled]
)
const [workbarInstances, setWorkbarInstances] = useState<
WorkbarTabInstance[]
@@ -1353,6 +1365,28 @@ export function RightAssistantSidebar({
[activeConversationId, conversationTitles, localizedAppDefinitions, workbarInstances]
)
const [handledNotesRequest, setHandledNotesRequest] = useState(0)
if (handledNotesRequest !== notesOpenRequest) {
setHandledNotesRequest(notesOpenRequest)
if (notesEnabled && notesOpenRequest) {
const existing = workbarInstances.find(instance => instance.appId === 'notes')
if (existing) setActiveWorkbarInstanceId(existing.id)
else if (workbarInstances.length < WORKBAR_LIMITS.maximumOpenInstances) {
const instance: WorkbarTabInstance = { id: crypto.randomUUID(), appId: 'notes', title: t('sidebar.tabs.notes.label') }
setWorkbarInstances([...workbarInstances, instance])
setActiveWorkbarInstanceId(instance.id)
} else {
setActionErrorState({ scope: { instanceId: activeWorkbarInstanceId, open }, message: t('sidebar.workbar.limit', { count: WORKBAR_LIMITS.maximumOpenInstances }) })
}
setBrowserFullscreenInstanceId(undefined)
}
}
if (notesSettingsReady && !notesEnabled && workbarInstances.some(instance => instance.appId === 'notes')) {
const next = workbarInstances.filter(instance => instance.appId !== 'notes')
setWorkbarInstances(next)
if (activeWorkbarApp === 'notes') setActiveWorkbarInstanceId(next[0]?.id ?? null)
}
const ensureBrowserTab = useCallback(
(
instance: WorkbarTabInstance
@@ -1527,6 +1561,7 @@ export function RightAssistantSidebar({
if (definition?.closable !== true) {
return false
}
if (instance.appId === 'notes' && onBeforeCloseNotes && !await onBeforeCloseNotes()) return false
if (instance.appId === 'browser') {
let tabId = browserTabIdsRef.current[instance.id]
let conversationId =
@@ -1596,6 +1631,7 @@ export function RightAssistantSidebar({
return true
},
[
onBeforeCloseNotes,
activeWorkbarInstanceId,
setActionError,
removeWorkbarInstance,
@@ -2022,7 +2058,7 @@ export function RightAssistantSidebar({
}
renderPanel={(instance) => (
<div
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : ''}`}
className={`assistant-sidebar__body${instance.appId === 'browser' ? ' assistant-sidebar__body--browser' : instance.appId === 'notes' ? ' assistant-sidebar__body--notes' : ''}`}
>
{hasUnboundRequestBrowser &&
workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances ? (
@@ -2035,6 +2071,7 @@ export function RightAssistantSidebar({
{actionError}
</p>
) : null}
{instance.appId === 'notes' && notesPanel}
{instance.appId === 'terminal' &&
instance.targetRef &&
(instance.targetRef.type === 'local' || instance.targetRef.type === 'project') ? (
+2
View File
@@ -5,6 +5,7 @@ import {
FileOutput,
FolderTree,
Globe2,
NotebookPen,
SquareTerminal,
X,
type LucideIcon
@@ -35,6 +36,7 @@ const WORKBAR_APP_ICONS: Record<WorkbarAppId, LucideIcon> = {
workspace: FolderTree,
browser: Globe2,
results: FileOutput,
notes: NotebookPen,
terminal: SquareTerminal
}
@@ -2,6 +2,21 @@ import type { TranslationShape } from '../../resource-types'
import type { magicNotes as chineseMagicNotes } from '../zh-CN/magicNotes'
export const magicNotes = {
capture: {
add: 'Add to note', quick: 'Quick note', conversation: 'Save conversation to note', menu: 'Conversation actions',
search: 'Search notes', target: 'Save to', title: 'Note title', content: 'Text to add', global: 'Global',
openWorkspace: 'Open in full workspace', back: 'Back to notes', choose: 'Choose an existing note',
empty: 'No notes yet. Create one to start writing.', noResults: 'No matching notes. Change your search or create a note.',
source: 'From conversation', openSource: 'Return to conversation', openMessage: 'View source message',
missingConversation: 'The original conversation no longer exists', missingMessage: 'The original message no longer exists',
messageScope: 'Saves this reply as text, excluding reasoning, tool logs, images, and attachment files.',
conversationScope: 'Saves all user and assistant text at capture time, including earlier history, excluding reasoning, tool logs, images, and attachment files.',
incomplete: 'This reply is incomplete. The preview includes its available text.', wait: 'Wait for the reply to finish or stop generation before saving.',
tooLong: 'Each entry supports up to 500 KB of text. Shorten the content before saving.',
required: 'Enter text and choose a note, or enter a new note title (up to 100 characters).',
saving: 'Saving...', deletedTarget: 'The target note was deleted. Choose another note; your draft is preserved.',
user: 'User', assistant: 'Assistant', loading: 'Loading the full conversation...'
},
records: { title: 'Records', pane: 'Record index', hide: 'Hide record index', show: 'Show record index', pages: '{{count}} pages', firstPage: 'Canvas first page', previewFailed: 'First page preview unavailable' },
canvas: {
savedAnalysisFailed: 'Entry saved, but automatic analysis failed: {{error}}. You can analyze it again later.',
@@ -207,6 +207,7 @@ export const workspace = {
resizeValue: '{{width}} pixels',
categoriesAriaLabel: 'Workspace categories',
tabs: {
notes: { label: 'Notes', description: 'Search notes, capture conversations, and append text.' },
tasks: {
label: 'Task center',
description: 'Review approvals and manage automations'
@@ -1,4 +1,19 @@
export const magicNotes = {
capture: {
add: '加入笔记', quick: '快速笔记', conversation: '保存当前对话到笔记', menu: '会话操作',
search: '搜索笔记', target: '保存到', title: '笔记标题', content: '待加入内容', global: '全局',
openWorkspace: '在完整工作区打开', back: '返回笔记列表', choose: '选择已有笔记',
empty: '还没有笔记,创建一篇开始记录。', noResults: '未找到相关笔记,请修改搜索词或新建笔记。',
source: '来自会话', openSource: '返回原会话', openMessage: '查看来源消息',
missingConversation: '原会话已不存在', missingMessage: '原消息已不存在',
messageScope: '保存范围:该回答的文字正文,不含推理、工具日志、图片和附件文件。',
conversationScope: '保存范围:触发时完整对话的用户与助手文字正文,不含推理、工具日志、图片和附件文件。',
incomplete: '这条回答尚未完成,预览保留已有正文。', wait: '请等待回答完成或停止生成后再保存。',
tooLong: '每条记录的文字不能超过 500 KB,请缩减内容后保存。',
required: '请输入正文并选择目标笔记,或填写新笔记标题(最多 100 字)。',
saving: '正在保存…', deletedTarget: '目标笔记已删除,请重新选择;草稿已保留。',
user: '用户', assistant: '助手', loading: '正在读取完整对话…'
},
records: { title: '记录', pane: '记录索引', hide: '收起记录索引', show: '展开记录索引', pages: '{{count}} 页', firstPage: '画布首页', previewFailed: '首页预览加载失败' },
canvas: {
savedAnalysisFailed: '记录已保存,但自动分析失败:{{error}}。可稍后重新分析。',
@@ -201,6 +201,7 @@ export const workspace = {
resizeValue: '{{width}} 像素',
categoriesAriaLabel: '工作栏分类',
tabs: {
notes: { label: '笔记', description: '搜索笔记、采集会话和快速追加文字。' },
tasks: {
label: '任务中心',
description: '处理待审批操作并管理自动化'
+27
View File
@@ -0,0 +1,27 @@
.magic-note-panel { display: flex; flex-direction: column; min-height: 0; height: 100%; min-width: 0; gap: var(--space-3); color: var(--text-primary); }
.magic-note-panel__header, .magic-note-panel__footer { flex: 0 0 auto; padding: var(--space-3); background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle); }
.magic-note-panel__header { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.magic-note-panel__header strong { flex: 1; overflow-wrap: anywhere; }
.magic-note-panel__scroll { flex: 1; min-height: 0; overflow: auto; padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3); }
.magic-note-panel__scroll > * { flex-shrink: 0; }
.magic-note-panel h3 { margin: 0; font-size: var(--font-section-title); }
.magic-note-panel__scroll > .segmented-control { flex: 0 0 auto; }
.magic-note-panel__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.magic-note-panel input, .magic-note-panel textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: var(--space-2); color: var(--text-primary); background: var(--surface-raised); border: 1px solid var(--border-control); border-radius: var(--radius-control); font: inherit; }
.magic-note-panel textarea { min-height: 180px; resize: vertical; }
.magic-note-panel__list { display: flex; flex-direction: column; gap: var(--space-2); }
.magic-note-panel__note { display: flex; flex-direction: column; gap: var(--space-2); text-align: left; padding: var(--space-3); background: var(--surface-raised); color: var(--text-primary); border: 1px solid var(--border-subtle); border-radius: var(--radius-control); }
.magic-note-panel__note[aria-pressed="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); }
.magic-note-panel__note:hover { background: var(--surface-subtle); }
.magic-note-panel__note strong { display: flex; gap: var(--space-2); overflow-wrap: anywhere; }
.magic-note-panel__note > span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.magic-note-panel time, .magic-note-panel__header > span, .magic-note-source { color: var(--text-secondary); font-size: var(--font-caption); }
.magic-note-source { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2); border-left: 2px solid var(--border-default); overflow-wrap: anywhere; }
.magic-note-panel__entry { padding: var(--space-3); border: 1px solid var(--border-subtle); border-radius: var(--radius-control); }
.magic-note-panel__entry--highlight { background: var(--accent-subtle); border-color: var(--accent-selected); }
.magic-note-panel__preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
.magic-note-panel__footer { border-top: 1px solid var(--border-subtle); border-bottom: 0; }
.magic-note-panel__footer p { margin: 0 0 var(--space-2); overflow-wrap: anywhere; }
.assistant-sidebar__body.assistant-sidebar__body--notes { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
.assistant-sidebar__body--notes > .magic-note-panel { flex: 1; }
.magic-note-panel .magic-note-panel__footer textarea { min-height: 80px; max-height: 180px; }
+60
View File
@@ -0,0 +1,60 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react'
import type { MagicNoteSource } from '../../shared/magic-notes-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
export type MagicNoteDraft = {
text: string
title: string
targetId: string
newNote: boolean
source?: MagicNoteSource
incomplete?: boolean
initialText: string
initialTitle: string
}
export function useMagicNoteDraft() {
const [draft, setDraft] = useState<MagicNoteDraft>()
const [saving, setSaving] = useState(false)
const [selectedNoteId, setSelectedNoteId] = useState('')
const [confirmation, setConfirmation] = useState<{ resolve: (discard: boolean) => void }>()
const pendingGuard = useRef<Promise<boolean> | undefined>(undefined)
const current = useRef({ draft, saving })
useLayoutEffect(() => { current.current = { draft, saving } }, [draft, saving])
const guard = useCallback(async (onlyModified = false): Promise<boolean> => {
const { draft: value, saving: busy } = current.current
if (busy) return false
if (!value || (onlyModified && value.text === value.initialText && value.title === value.initialTitle)) return true
if (!value.source && !value.text && !value.title) return true
if (!pendingGuard.current) pendingGuard.current = new Promise<boolean>(resolve => setConfirmation({ resolve }))
return pendingGuard.current
}, [])
const finishGuard = (discard: boolean): void => {
if (discard) { current.current = { ...current.current, draft: undefined }; setDraft(undefined) }
confirmation?.resolve(discard)
setConfirmation(undefined)
pendingGuard.current = undefined
}
return { draft, setDraft, saving, setSaving, selectedNoteId, setSelectedNoteId, guard,
confirmation: confirmation ? <MagicNoteDraftConfirmation onResolve={finishGuard} /> : null }
}
function MagicNoteDraftConfirmation({ onResolve }: { onResolve: (discard: boolean) => void }): React.JSX.Element {
const { t } = useTranslation('magicNotes')
const dialog = useRef<HTMLElement>(null)
const cancel = useRef<HTMLButtonElement>(null)
useLayoutEffect(() => activateModalFocus(() => cancel.current), [])
return createPortal(<div className="custom-task-dialog" onMouseDown={event => { if (event.target === event.currentTarget) onResolve(false) }}>
<section className="custom-task-dialog__surface" role="dialog" aria-modal="true" aria-labelledby="note-draft-title" aria-describedby="note-draft-description" ref={dialog} onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onResolve(false) }
else trapTabFocus(event, dialog.current)
}}>
<header className="custom-task-dialog__header"><h2 id="note-draft-title">{t('confirmations.discardDraftTitle')}</h2><button type="button" className="icon-button" aria-label={t('actions.cancel')} onClick={() => onResolve(false)}><X size={16} /></button></header>
<div className="custom-task-dialog__content" id="note-draft-description">{t('confirmations.discardDraftDescription')}</div>
<footer className="custom-task-dialog__actions"><button className="secondary-button" type="button" ref={cancel} onClick={() => onResolve(false)}>{t('actions.continueEditing')}</button><button className="danger-solid" type="button" onClick={() => onResolve(true)}>{t('actions.discardAndSwitch')}</button></footer>
</section>
</div>, document.body)
}