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
+2 -2
View File
@@ -889,10 +889,10 @@ describe('KnowledgeMcpGateway', () => {
gateway.getMagicNote(readToken, { noteId: created.id })
).toThrow('笔记不存在')
const canvas = database.createMagicNote({ title: 'Canvas', content: {
const canvas = database.getMagicNote(database.createMagicNote({ title: 'Canvas', content: {
version: 2, kind: 'paged-canvas', assets: [],
pages: [{ id: 'page', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [{ type: 'IText', text: 'Canvas text' }] }]
} })
} }).id)
const canvasEntry = gateway.getMagicNote(readToken, { noteId: canvas.id }).entries[0]!
expect(canvasEntry).toMatchObject({ content: 'Canvas text', contentKind: 'paged-canvas', contentVersion: 2, plainTextEditable: false })
expect(() => gateway.updateMagicNoteEntry(writeToken, { entryId: canvasEntry.id, expectedRevision: canvasEntry.revision, content: 'overwrite' })).toThrow('画布记录不能')
@@ -31,6 +31,7 @@ it('migrates activity history and writes only changed records during streaming',
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version=43`)
const legacySave = raw.prepare('UPDATE activity_history SET records_json = ?, legacy_history_may_be_incomplete = 1')
legacySave.run(JSON.stringify(records))
@@ -178,6 +178,7 @@ it('migrates schema 35 conversations to unpinned while retaining existing data a
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 35;`)
legacy.close()
try {
@@ -332,6 +333,7 @@ it('preserves existing supervision data when upgrading schema 41 and reopening',
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 41;`)
legacy.close()
try {
@@ -1098,6 +1100,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 3;
`)
oldDatabase.close()
@@ -1264,6 +1267,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 26;
`)
legacy.close()
@@ -1383,6 +1387,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 30;
`)
legacy.close()
@@ -1464,6 +1469,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 30;
`)
legacy.close()
@@ -1564,6 +1570,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1614,6 +1621,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1665,6 +1673,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1712,6 +1721,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1748,6 +1758,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1781,6 +1792,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 5;
`)
versionFive.close()
@@ -1911,6 +1923,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 21;
COMMIT;
`)
@@ -2010,6 +2023,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 9;
`)
legacy.close()
@@ -2071,6 +2085,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 16`)
legacy.close()
@@ -3026,6 +3041,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 23`)
legacy.close()
@@ -3193,6 +3209,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 18;
`)
legacy.close()
@@ -3709,6 +3726,7 @@ describe('AssistantDatabase', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 31;
`)
legacy.close()
+47 -11
View File
@@ -86,6 +86,9 @@ import {
} from '../../shared/runtime-selection-contracts'
import {
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES,
magicNoteSourceSchema,
type MagicNoteSource,
type MagicNoteCreateResult,
type MagicNoteComment,
type MagicNoteDetail,
type MagicNoteEntryCreateResult,
@@ -119,7 +122,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 47
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 48
export type RemoteTaskEventInput = {
taskId: string
@@ -256,6 +259,7 @@ type MagicNoteEntryRow = {
id: string
note_id: string
content_json: string
source_json: string | null
plain_text: string
comments_json: string
analyzed_at: string | null
@@ -666,6 +670,7 @@ function toMagicNoteEntry(row: MagicNoteEntryRow, content: MagicNoteContent): Ma
id: row.id,
noteId: row.note_id,
content,
...(row.source_json ? { source: magicNoteSourceSchema.parse(JSON.parse(row.source_json)) } : {}),
plainText: row.plain_text,
comments: JSON.parse(row.comments_json) as MagicNoteComment[],
analyzedAt: row.analyzed_at ?? undefined,
@@ -3920,8 +3925,12 @@ export class AssistantDatabase {
createMagicNote(input: {
title: string
content?: MagicNoteContent
}): MagicNoteDetail {
source?: MagicNoteSource
}): MagicNoteCreateResult {
const id = randomUUID()
const entryId = input.content ? randomUUID() : undefined
const source = input.source ? magicNoteSourceSchema.parse(input.source) : undefined
if (source && !input.content) throw new Error('Source requires initial content')
const now = new Date().toISOString()
const database = this.requireDatabase()
if (input.content) {
@@ -3945,16 +3954,15 @@ export class AssistantDatabase {
VALUES (?, ?, ?, 0, ?, ?, ?)`
)
.run(id, null, input.title, input.content ? 1 : 0, now, now)
if (input.content) {
const entryId = randomUUID()
if (input.content && entryId) {
const pointer = this.writeMagicNoteBody(id, entryId, input.content, 0, now)
database
.prepare(
`INSERT INTO magic_note_entries
(id, note_id, content_json, plain_text, comments_json,
actions_json, analyzed_at, revision, created_at, updated_at,
image_bytes)
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?)`
image_bytes, source_json)
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?, ?)`
)
.run(
entryId,
@@ -3963,7 +3971,8 @@ export class AssistantDatabase {
magicNotePlainText(input.content),
now,
now,
embeddedBytes
embeddedBytes,
source ? JSON.stringify(source) : null
)
this.syncMagicNoteTodos(
database,
@@ -3984,7 +3993,7 @@ export class AssistantDatabase {
if (input.content) {
this.notifyMagicTodosChanged()
}
return detail
return { ...detail, ...(entryId ? { createdEntryId: entryId } : {}) }
}
updateMagicNote(input: {
@@ -4034,7 +4043,9 @@ export class AssistantDatabase {
noteId: string
content: MagicNoteContent
plainText: string
source?: MagicNoteSource
}): MagicNoteEntryCreateResult {
const source = input.source ? magicNoteSourceSchema.parse(input.source) : undefined
const database = this.requireDatabase()
const entryId = randomUUID()
const content = validateMagicNoteContent(input.content)
@@ -4059,8 +4070,8 @@ export class AssistantDatabase {
`INSERT INTO magic_note_entries
(id, note_id, content_json, plain_text, comments_json,
actions_json, analyzed_at, revision, created_at, updated_at,
image_bytes)
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?)`
image_bytes, source_json)
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?, ?)`
)
.run(
entryId,
@@ -4069,7 +4080,8 @@ export class AssistantDatabase {
input.plainText,
now,
now,
magicNoteEmbeddedBytes(input.content)
magicNoteEmbeddedBytes(input.content),
source ? JSON.stringify(source) : null
)
this.syncMagicNoteTodos(
database,
@@ -4287,6 +4299,23 @@ export class AssistantDatabase {
return { incompleteCount: row.incomplete_count }
}
searchMagicNoteSummaries(query: string, limit = 200): MagicNoteSummary[] {
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
const rows = this.requireDatabase().prepare(
`SELECT n.*,
(SELECT COUNT(*) FROM magic_note_entries e WHERE e.note_id = n.id) AS entry_count,
(SELECT plain_text FROM magic_note_entries e WHERE e.note_id = n.id
ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1) AS latest_plain_text
FROM magic_notes n
WHERE n.title LIKE ? ESCAPE '\\'
OR EXISTS (SELECT 1 FROM magic_note_entries e
WHERE e.note_id = n.id AND e.plain_text LIKE ? ESCAPE '\\')
ORDER BY n.pinned DESC, n.updated_at DESC, n.rowid DESC
LIMIT ?`
).all(pattern, pattern, limit) as MagicNoteRow[]
return rows.map(toMagicNoteSummary)
}
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] {
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
return (
@@ -11426,6 +11455,13 @@ export class AssistantDatabase {
database.exec('PRAGMA user_version = 47; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 48) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec('ALTER TABLE magic_note_entries ADD COLUMN source_json TEXT;')
database.exec('PRAGMA user_version = 48; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionReviewStore(): SupervisionReviewStore {
@@ -55,12 +55,12 @@ function subagent(requestId: string): SubagentEvent {
}
describe('subagent progress storage', () => {
it.each([45, 46])('upgrades schema %i without reconverting history or vacuuming ordinary free pages', async (sourceVersion) => {
it.each([45, 46, 47])('upgrades schema %i without reconverting history or vacuuming ordinary free pages', async (sourceVersion) => {
const { path, database, taskId } = await fixture()
const event = subagent(randomUUID())
event.progress = [{ id: randomUUID(), type: 'text', content: 'Keep current-format progress' }]
database.appendTaskEvent(taskId, 'subagent', event)
const note = database.createMagicNote({ title: 'Already migrated', content: { version: 1, ops: [{ insert: 'Keep note\n' }] } })
const note = database.getMagicNote(database.createMagicNote({ title: 'Already migrated', content: { version: 1, ops: [{ insert: 'Keep note\n' }] } }).id)
const header = { id: randomUUID(), title: 'Keep chat', updatedAt: 1000 }
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
content: 'Normal chat update'.repeat(100_000), createdAt: 1000 }
@@ -70,14 +70,15 @@ describe('subagent progress storage', () => {
database.close()
const sql = new DatabaseSync(path)
try {
sql.exec(`DROP VIEW supervision_review_current;
if (sourceVersion < 47) sql.exec(`DROP VIEW supervision_review_current;
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;`)
if (sourceVersion === 45) sql.exec(`
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;`)
sql.exec(`PRAGMA user_version = ${sourceVersion}; PRAGMA wal_checkpoint(TRUNCATE)`)
sql.exec(`ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = ${sourceVersion}; PRAGMA wal_checkpoint(TRUNCATE)`)
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
const events = sql.prepare('SELECT * FROM task_events WHERE task_id = ? ORDER BY id').all(taskId)
expect(events.length).toBeGreaterThan(0)
@@ -143,7 +144,7 @@ describe('subagent progress storage', () => {
it('does not re-show migration after a note is already migrated and later chats free pages', async () => {
const { path, database } = await fixture()
const note = database.createMagicNote({ title: 'Legacy note', content: { version: 1, ops: [{ insert: 'note body\n' }] } })
const note = database.getMagicNote(database.createMagicNote({ title: 'Legacy note', content: { version: 1, ops: [{ insert: 'note body\n' }] } }).id)
database.close()
const legacy = new DatabaseSync(path)
try {
@@ -158,6 +159,7 @@ describe('subagent progress storage', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 37`)
} finally { legacy.close() }
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
@@ -479,6 +481,7 @@ describe('subagent progress storage', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = ${sourceVersion}; BEGIN`)
const insert = legacy.prepare(
`INSERT INTO task_events(
@@ -86,6 +86,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 2`)
raw.close()
@@ -148,6 +149,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 20`)
raw.close()
@@ -193,7 +193,7 @@ it('preserves schema-45 plans, reports, IDs and foreign keys while extending sta
sql.exec(`INSERT INTO ${table}_old SELECT * FROM ${table}; DROP TABLE ${table}; ALTER TABLE ${table}_old RENAME TO ${table};`)
for (const index of indexes) sql.exec(String(index.sql))
}
sql.exec('PRAGMA user_version = 45; COMMIT; PRAGMA foreign_keys = ON;')
sql.exec('ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 45; COMMIT; PRAGMA foreign_keys = ON;')
db.initialize(directory)
expect(db.getHeartbeatConfig(config.id)).toEqual(savedConfig)
expect(db.listHeartbeatEntries(config.id)).toEqual(savedEntries)
@@ -134,6 +134,7 @@ it('upgrades schema 44 without inventing historical links and preserves runs on
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 44;`)
legacy.close()
db.initialize(process.cwd())
@@ -46,6 +46,7 @@ it('supervision preserves result history and protected identities within scope t
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 42;`)
legacy.close()
db.initialize(process.cwd())
+32 -3
View File
@@ -198,6 +198,8 @@ import {
import {
magicNoteAnalyzeSchema,
magicNoteCreateSchema,
magicNoteSearchSchema,
type MagicNoteSource,
magicNoteDeleteSchema,
magicNoteDraftAnalyzeSchema,
magicNoteEntryCreateSchema,
@@ -8430,6 +8432,33 @@ export function registerIpcHandlers(
contextManager.remove(requestIdSchema.parse(input))
})
const resolveMagicNoteSource = (source?: MagicNoteSource): MagicNoteSource | undefined => {
if (!source) return undefined
const conversation = assistantDatabase.getConversation(source.conversationId)
const messages = new Map(conversation.messages.map((message) => [message.id, message]))
for (const id of source.messageIds) {
const message = messages.get(id)
if (!message || (source.kind === 'message' && message.role !== 'assistant')) {
throw new Error('Invalid conversation source message')
}
}
const project = conversation.projectId ? assistantDatabase.getProject(conversation.projectId) : undefined
return {
kind: source.kind,
conversationId: conversation.id,
messageIds: source.messageIds,
capturedAt: source.capturedAt,
conversationTitle: conversation.title,
...(project ? { projectId: project.id, projectName: project.name } : {})
}
}
registerHandler(ipcChannels.magicNotesSearch, (event, input: unknown) => {
assertTrustedSender(event, window)
const { query, limit } = magicNoteSearchSchema.parse(input)
return assistantDatabase.searchMagicNoteSummaries(query, limit)
})
registerHandler(ipcChannels.magicNotesList, (event) => {
assertTrustedSender(event, window)
return { notes: assistantDatabase.listMagicNotes() }
@@ -8443,9 +8472,8 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.magicNotesCreate, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.createMagicNote(
magicNoteCreateSchema.parse(input)
)
const parsed = magicNoteCreateSchema.parse(input)
return assistantDatabase.createMagicNote({ ...parsed, source: resolveMagicNoteSource(parsed.source) })
})
registerHandler(ipcChannels.magicNotesUpdate, (event, input: unknown) => {
@@ -8470,6 +8498,7 @@ export function registerIpcHandlers(
return assistantDatabase.createMagicNoteEntry({
noteId: parsed.noteId,
content,
source: resolveMagicNoteSource(parsed.source),
plainText: magicNotePlainText(content)
})
}
@@ -1,5 +1,8 @@
// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -91,12 +94,104 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
open()
})
afterEach(async () => {
vi.restoreAllMocks()
await dispose?.()
database?.close()
bridge.handlers.clear()
await rm(directory, { recursive: true, force: true })
})
it('captures verified source labels and preserves them after source deletion, edits and reopen', async () => {
const project = database.createProject({ name: 'Actual project', rootPath: directory,
description: '', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } })
const header = { id: randomUUID(), title: 'Actual conversation', projectId: project.id, updatedAt: 1 }
const messages = Array.from({ length: 120 }, (_, index) => ({ id: randomUUID(),
role: index % 2 ? 'assistant' as const : 'user' as const,
content: `History ${index}`, state: 'complete' as const, createdAt: index }))
database.saveLocalConversations([{ header, messages }])
const source = { kind: 'conversation' as const, conversationId: header.id,
messageIds: messages.map(message => message.id), capturedAt: '2026-09-26T01:02:03.000Z',
conversationTitle: 'Forged title', projectId: randomUUID(), projectName: 'Forged project' }
const content = { version: 1 as const, ops: [{ insert: messages.map(message => `${message.role}: ${message.content}\n`).join('') }] }
const created = await api.create({ title: 'Captured history', content, source })
expect(created.createdEntryId).toBe(created.entries[0]!.id)
const expectedSource = { ...source, conversationTitle: header.title, projectId: project.id, projectName: project.name }
expect(created.entries[0]!.source).toEqual(expectedSource)
expect(created.entries[0]!.content).toEqual(content)
const appended = await api.createEntry({ noteId: created.id, content,
source: { ...source, kind: 'message', messageIds: [messages[1]!.id] } })
expect(appended.entries.find(entry => entry.id === appended.createdEntryId)?.source)
.toEqual({ ...expectedSource, kind: 'message', messageIds: [messages[1]!.id] })
database.deleteLocalConversation(header.id)
const edited = await api.updateEntry({ entryId: created.createdEntryId!, expectedRevision: 0,
content: { version: 1, ops: [{ insert: 'Edited copy\n' }] } })
expect(edited.entries[0]!.source).toEqual(expectedSource)
await reopen()
expect(await api.get(created.id)).toEqual(edited)
const sql = new DatabaseSync(join(directory, 'assistant.sqlite'))
try {
const row = sql.prepare('SELECT source_json FROM magic_note_entries WHERE id = ?').get(created.createdEntryId!) as { source_json: string }
expect(JSON.parse(row.source_json)).toEqual(expectedSource)
} finally { sql.close() }
await api.remove(created.id)
await expect(api.get(created.id)).rejects.toThrow()
})
it('rejects invalid source identities and source-only creates without leaving empty notes', async () => {
const header = { id: randomUUID(), title: 'Global conversation', updatedAt: 1 }
const message = { id: randomUUID(), role: 'assistant' as const, content: 'Answer', state: 'error' as const, createdAt: 1 }
database.saveLocalConversations([{ header, messages: [message] }])
const content = { version: 1 as const, ops: [{ insert: 'Edited preview\n' }] }
const source = { kind: 'message' as const, conversationId: header.id, messageIds: [message.id],
capturedAt: new Date().toISOString(), conversationTitle: 'Untrusted', projectId: randomUUID(), projectName: 'Untrusted' }
await expect(api.create({ title: 'Invalid', source })).rejects.toThrow()
await expect(api.create({ title: 'Invalid', content, source: { ...source, messageIds: [randomUUID()] } })).rejects.toThrow()
await expect(api.create({ title: 'Invalid', content, source: { ...source, messageIds: [message.id, message.id] } })).rejects.toThrow()
await expect(api.create({ title: 'Invalid', content, source: { ...source, conversationId: randomUUID() } })).rejects.toThrow()
expect((await api.list()).notes).toEqual([])
const note = await api.create({ title: 'Partial answer', content, source })
expect(note.entries[0]!.source).toEqual({ kind: 'message', conversationId: header.id,
messageIds: [message.id], capturedAt: source.capturedAt, conversationTitle: header.title })
await api.remove(note.id)
expect(database.getConversation(header.id).messages).toEqual([message])
})
it('rolls back a new note when writing its initial body fails and allows retry', async () => {
const rename = fs.renameSync
const failure = vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => {
if (String(to).includes('entries')) throw new Error('Initial body write failed')
rename(from, to)
})
const input = { title: 'Atomic capture', content: { version: 1 as const, ops: [{ insert: 'Saved once\n' }] } }
await expect(api.create(input)).rejects.toThrow('Initial body write failed')
expect((await api.list()).notes).toEqual([])
failure.mockRestore()
const note = await api.create(input)
expect(note.entries).toHaveLength(1)
expect(note.createdEntryId).toBe(note.entries[0]!.id)
expect((await api.list()).notes).toHaveLength(1)
})
it('searches titles and all entry bodies with note-level limits while preserving Agent entry results', async () => {
const titleOnly = await api.create({ title: 'Needle title' })
expect(titleOnly.createdEntryId).toBeUndefined()
const bodyOnly = await api.create({ title: 'Body only', content: { version: 1, ops: [{ insert: 'Needle body\n' }] } })
for (let index = 0; index < 4; index++) {
await api.createEntry({ noteId: bodyOnly.id, content: { version: 1, ops: [{ insert: `Needle ${index}\n` }] } })
}
await api.createEntry({ noteId: bodyOnly.id, content: { version: 1, ops: [{ insert: 'Latest unrelated preview\n' }] } })
const results = await api.search({ query: ' Needle ', limit: 2 })
expect(new Set(results.map(note => note.id))).toEqual(new Set([bodyOnly.id, titleOnly.id]))
expect(results.find(note => note.id === bodyOnly.id)).toMatchObject({ entryCount: 6, preview: 'Latest unrelated preview' })
expect(database.searchMagicNotes('Needle', 20).filter(result => result.noteId === bodyOnly.id)).toHaveLength(5)
await api.update({ noteId: titleOnly.id, pinned: true, expectedRevision: titleOnly.revision })
expect((await api.search({ query: 'Needle', limit: 1 })).map(note => note.id)).toEqual([titleOnly.id])
expect(await api.search({ query: '%' })).toEqual([])
expect(await api.search({ query: '' })).toHaveLength(2)
await expect(api.search({ query: 'Needle', limit: 0 })).rejects.toThrow()
await expect(api.search({ query: 'Needle', limit: 201 })).rejects.toThrow()
})
it.each([true, false])('limits saved, draft and todo input using persisted page order and count (vision=%s)', async (supportsImageInput) => {
getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput })
const requests: AgentExecutionRequest[] = []
@@ -53,6 +53,30 @@ function canvas(size = 40): MagicNoteCanvasContent {
}
describe('Magic note SQLite and filesystem storage', () => {
it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => {
const { database, sql, path, directory } = setup()
const created = database.createMagicNote({ title: 'Existing note', content: rich })
const before = database.getMagicNote(created.id)
const bodyPath = join(directory, 'notes', created.id, 'entries', `${created.createdEntryId}.json`)
const body = readFileSync(bodyPath)
const modifiedAt = statSync(bodyPath).mtimeMs
database.close()
// Reconstruct the previous released schema; production never downgrades databases.
sql.exec('ALTER TABLE magic_note_entries DROP COLUMN source_json; PRAGMA user_version = 47;')
upgradeAssistantStorage(path, () => undefined)
expect(sql.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION })
expect(sql.prepare('SELECT source_json FROM magic_note_entries').get()).toEqual({ source_json: null })
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
database.initialize(directory)
expect(database.getMagicNote(created.id)).toEqual(before)
expect(database.getMagicNote(created.id).entries[0]).not.toHaveProperty('source')
expect(readFileSync(bodyPath)).toEqual(body)
expect(statSync(bodyPath).mtimeMs).toBe(modifiedAt)
database.close()
database.initialize(directory)
expect(database.getMagicNote(created.id)).toEqual(before)
})
it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => {
const { database, sql, path, directory } = setup()
const text = 'data:text/plain;base64,aGVsbG8'
@@ -61,7 +85,7 @@ describe('Magic note SQLite and filesystem storage', () => {
const drawing = canvas()
drawing.pages[0]!.objects.push({ type: 'IText', ...custom }, { type: 'Group', objects: [{ type: 'Image', src: png, name: text, custom }] })
drawing.flow!.ops.push({ insert: text }, { insert: { image: png } }, { insert: { custom } })
const notes = [richText, drawing].map((content) => database.createMagicNote({ title: 'Literal URLs', content }))
const notes = [richText, drawing].map((content) => database.getMagicNote(database.createMagicNote({ title: 'Literal URLs', content }).id))
for (const [index, note] of notes.entries()) expect(note.entries[0]!.content).toEqual([richText, drawing][index])
const disk = JSON.parse(readFileSync(join(directory, 'notes', notes[1]!.id, 'entries', `${notes[1]!.entries[0]!.id}.json`), 'utf8'))
expect(disk.content.pages[0].objects[2]).toEqual({ type: 'IText', ...custom })
@@ -125,7 +149,7 @@ describe('Magic note SQLite and filesystem storage', () => {
expect(existsSync(root)).toBe(true)
expect(logged).toHaveBeenCalled()
// A delayed reset cleanup must not remove notes created afterwards.
const retained = database.createMagicNote({ title: 'New note', content: canvas() })
const retained = database.getMagicNote(database.createMagicNote({ title: 'New note', content: canvas() }).id)
failing = false
database.listMagicNotes()
expect(existsSync(root)).toBe(false)
@@ -134,7 +158,7 @@ describe('Magic note SQLite and filesystem storage', () => {
it('still rejects authoritative body rename failures before committing SQL', () => {
const { database } = setup()
const note = database.createMagicNote({ title: 'Body error', content: rich })
const note = database.getMagicNote(database.createMagicNote({ title: 'Body error', content: rich }).id)
const entry = note.entries[0]!
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
if (String(target).endsWith(`${entry.id}.json`)) throw new Error('body rename blocked')
@@ -229,6 +253,7 @@ describe('Magic note SQLite and filesystem storage', () => {
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
PRAGMA user_version = 37`)
rmSync(join(directory, 'notes'), { recursive: true })
const progress: number[] = []
@@ -270,7 +295,7 @@ describe('Magic note SQLite and filesystem storage', () => {
it('reclaims migrated SQLite payload space and restores a coordinated database/files backup', async () => {
const { database, sql, directory, path } = setup()
const note = database.createMagicNote({ title: 'Large legacy PDF', content: canvas(2 * 1024 * 1024) })
const note = database.getMagicNote(database.createMagicNote({ title: 'Large legacy PDF', content: canvas(2 * 1024 * 1024) }).id)
const entry = note.entries[0]!
database.close()
sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(entry.content), entry.id)
@@ -340,7 +365,7 @@ describe('Magic note SQLite and filesystem storage', () => {
it('rolls back failed creates and cleans their unaccepted files without affecting existing notes', () => {
const { database, sql, directory } = setup()
const retained = database.createMagicNote({ title: 'Keep', content: rich })
const retained = database.getMagicNote(database.createMagicNote({ title: 'Keep', content: rich }).id)
sql.exec(`CREATE TRIGGER fail_note_insert BEFORE INSERT ON magic_note_entries
BEGIN SELECT RAISE(ABORT, 'insert unavailable'); END`)
expect(() => database.createMagicNote({ title: 'Failed', content: canvas() })).toThrow('insert unavailable')
+5 -1
View File
@@ -156,6 +156,8 @@ import type {
MagicNoteAnalysisStreamEvent,
MagicNoteDraftAnalysis,
MagicNoteDetail,
MagicNoteCreateResult,
MagicNoteSummary,
MagicNoteEntryCreateResult,
MagicNotesSnapshot,
MagicTodoItem,
@@ -1625,6 +1627,8 @@ const desktopApi: DesktopApi = {
}
},
magicNotes: {
search: (input) =>
ipcRenderer.invoke(ipcChannels.magicNotesSearch, input) as Promise<MagicNoteSummary[]>,
list: () =>
ipcRenderer.invoke(
ipcChannels.magicNotesList
@@ -1637,7 +1641,7 @@ const desktopApi: DesktopApi = {
ipcRenderer.invoke(
ipcChannels.magicNotesCreate,
input
) as Promise<MagicNoteDetail>,
) as Promise<MagicNoteCreateResult>,
update: (input) =>
ipcRenderer.invoke(
ipcChannels.magicNotesUpdate,
+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)
}
+5 -1
View File
@@ -73,6 +73,9 @@ import type {
MagicNoteDetail,
MagicNoteEntryCreateResult,
MagicNoteCreateInput,
MagicNoteCreateResult,
MagicNoteSearchInput,
MagicNoteSummary,
MagicNoteEntryCreateInput,
MagicNoteEntryUpdateInput,
MagicNoteContent,
@@ -2325,8 +2328,9 @@ export type DesktopApi = {
}
magicNotes: {
list: () => Promise<MagicNotesSnapshot>
search: (input: MagicNoteSearchInput) => Promise<MagicNoteSummary[]>
get: (noteId: string) => Promise<MagicNoteDetail>
create: (input: MagicNoteCreateInput) => Promise<MagicNoteDetail>
create: (input: MagicNoteCreateInput) => Promise<MagicNoteCreateResult>
update: (input: MagicNoteUpdateInput) => Promise<MagicNoteDetail>
remove: (noteId: string) => Promise<void>
createEntry: (
+1
View File
@@ -319,6 +319,7 @@ export const ipcChannels = {
contextReadClipboard: 'context:read-clipboard',
contextRemove: 'context:remove',
magicNotesList: 'magic-notes:list',
magicNotesSearch: 'magic-notes:search',
magicNotesGet: 'magic-notes:get',
magicNotesCreate: 'magic-notes:create',
magicNotesUpdate: 'magic-notes:update',
+37 -2
View File
@@ -282,11 +282,40 @@ export const magicNoteContentSchema = z.union([magicNoteRichContentSchema, magic
export type MagicNoteContent = z.infer<typeof magicNoteContentSchema>
export type MagicNoteAnalysisInputMode = 'text' | 'images' | 'canvas-images' | 'text-fallback'
export const magicNoteSourceSchema = z.object({
kind: z.enum(['message', 'conversation']),
conversationId: magicNoteIdSchema,
messageIds: z.array(magicNoteIdSchema).min(1),
capturedAt: z.string().datetime(),
conversationTitle: z.string(),
projectId: magicNoteIdSchema.optional(),
projectName: z.string().optional()
}).strict().superRefine((source, context) => {
if (source.kind === 'message' && source.messageIds.length !== 1) {
context.addIssue({ code: 'custom', path: ['messageIds'], message: 'Message sources require exactly one message' })
}
if (new Set(source.messageIds).size !== source.messageIds.length) {
context.addIssue({ code: 'custom', path: ['messageIds'], message: 'Source message IDs must be unique' })
}
})
export type MagicNoteSource = z.infer<typeof magicNoteSourceSchema>
export const magicNoteSearchSchema = z.object({
query: z.string().trim().max(1000),
limit: z.number().int().min(1).max(200).optional()
}).strict()
export type MagicNoteSearchInput = z.infer<typeof magicNoteSearchSchema>
export const magicNoteCreateSchema = z
.object({
title: z.string().trim().min(1).max(100)
title: z.string().trim().min(1).max(100),
content: magicNoteContentSchema.optional(),
source: magicNoteSourceSchema.optional()
})
.strict()
.refine((input) => !input.source || !!input.content, {
path: ['content'], message: 'Source requires initial content'
})
export type MagicNoteCreateInput = z.infer<typeof magicNoteCreateSchema>
export const magicNoteUpdateSchema = z
@@ -311,7 +340,8 @@ export const magicNoteDeleteSchema = z
export const magicNoteEntryCreateSchema = z
.object({
noteId: magicNoteIdSchema,
content: magicNoteContentSchema
content: magicNoteContentSchema,
source: magicNoteSourceSchema.optional()
})
.strict()
export type MagicNoteEntryCreateInput = z.infer<
@@ -440,6 +470,7 @@ export type MagicNoteEntry = {
id: string
noteId: string
content: MagicNoteContent
source?: MagicNoteSource
plainText: string
comments: MagicNoteComment[]
analyzedAt?: string
@@ -467,6 +498,10 @@ export type MagicNoteEntryCreateResult = MagicNoteDetail & {
createdEntryId: string
}
export type MagicNoteCreateResult = MagicNoteDetail & {
createdEntryId?: string
}
export type MagicNotesSnapshot = {
notes: MagicNoteSummary[]
}
+9
View File
@@ -13,6 +13,14 @@ const terminalId = '00000000-0000-4000-8000-000000000102'
const projectId = '00000000-0000-4000-8000-000000000201'
describe('workbar contracts', () => {
it('keeps notes as a context-independent single instance through layout normalization', () => {
const instance = { id: taskId, appId: 'notes', title: 'Notes' } as const
const layout = { instances: [instance, { ...instance, id: terminalId }], activeInstanceId: terminalId, expanded: true, dock: 'right', widthRatio: 0.3 }
expect(workbarLayoutPreferencesSchema.safeParse(layout).success).toBe(false)
expect(normalizeWorkbarLayoutPreferences(layout, [])?.instances).toEqual([instance])
expect(workbarTabInstanceSchema.safeParse({ ...instance, targetRef: { type: 'conversation', conversationId: 'A' } }).success).toBe(false)
expect(workbarTabInstanceSchema.safeParse({ ...instance, targetRef: { type: 'project', projectId } }).success).toBe(false)
})
it('persists supervision conversation and task targets without allowing task terminal bindings', () => {
for (const targetRef of [{ type: 'conversation', conversationId: 'A' }, { type: 'task', taskId: 'task-A' }]) {
const instance = { id: taskId, appId: 'tasks', title: 'Tasks', targetRef }
@@ -36,6 +44,7 @@ describe('workbar contracts', () => {
{ id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: false },
{ id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true },
{ id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true },
{ id: 'notes', instancePolicy: 'single', defaultContext: 'application', defaultOpen: false, required: false, closable: true, reorderable: true },
{
id: 'terminal',
instancePolicy: 'multiple',
+18
View File
@@ -24,6 +24,7 @@ export const workbarAppIdSchema = z.enum([
'workspace',
'browser',
'results',
'notes',
'terminal'
])
export type WorkbarAppId = z.infer<typeof workbarAppIdSchema>
@@ -152,6 +153,20 @@ export const WORKBAR_APP_DEFINITIONS = [
reorderable: true,
availability: { state: 'available' }
},
{
id: 'notes',
visibleAcrossContextSwitches: true,
label: '笔记',
icon: 'notes',
description: '浏览笔记和快速追加文字。',
instancePolicy: 'single',
defaultContext: 'application',
defaultOpen: false,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'terminal',
visibleAcrossContextSwitches: true,
@@ -205,6 +220,9 @@ export const workbarTabInstanceSchema = z
})
.strict()
.superRefine((instance, context) => {
if (instance.appId === 'notes' && instance.targetRef) {
context.addIssue({ code: 'custom', path: ['targetRef'], message: 'Notes are application scoped and do not accept target bindings' })
}
if (
instance.appId === 'terminal' &&
(!instance.targetRef || !['local', 'project'].includes(instance.targetRef.type))