fix: preserve drafts, canvas edits, and remote Continue tools

Keep paragraph formatting and checklist todos when removing canvas pages,
and preserve accepted undo/redo history after oversized input is rejected.
Merge restored queued text into the latest conversation draft and retain
image delivery when OCR returns image references and generated warnings only.

Deliver session MCP servers to bridged remote Continue Execute while keeping
Ask read-only and preserving local model-profile isolation. Add regression
coverage and update feature documentation. Correct stale conversation-limit
and forced-color assertions and the measured Electron layout test deadline.

Validation: 4,826 tests passed, 67 skipped; full typecheck and lint passed.
Current-source Linux x64 Execute/Ask/Execute MCP delivery passed, using 10
real text-provider requests and no image-provider requests. Image generation
itself was not exercised. Staged whitespace checks passed before commit.

Release note: 修复恢复队列时覆盖新输入、画布删页丢失清单格式及超限输入清空撤销历史的问题;图片 OCR 无正文时保留原图发送方式,远程 Continue Execute 正确保留会话 MCP 工具。
This commit is contained in:
mesalogo
2026-09-19 23:09:48 +08:00
parent 375b2159c8
commit a3b7a27045
29 changed files with 664 additions and 60 deletions
@@ -0,0 +1,119 @@
import { readFile, rm, mkdtemp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { Agent } from '@agentclientprotocol/sdk'
import { afterEach, expect, it, vi } from 'vitest'
import { ContinueHostAdapter, type ContinueHostRunOptions } from '../main/agent/continue-host-adapter'
import { runContinueAcpHelper } from './continue-acp-helper'
const transport = vi.hoisted(() => ({
agent: undefined as Agent | undefined,
close: () => {},
mode: 'execute' as 'ask' | 'execute'
}))
vi.mock('@agentclientprotocol/sdk', () => ({
PROTOCOL_VERSION: 1,
ndJsonStream: vi.fn(),
AgentSideConnection: class {
closed = new Promise<void>(resolve => { transport.close = resolve })
constructor(factory: () => Agent) { transport.agent = factory() }
sessionUpdate = vi.fn()
extNotification = vi.fn()
}
}))
vi.mock('./model-bridge-helper', () => ({
MODEL_BRIDGE_SDK_AUTH_SENTINEL: 'bridge-test-sentinel',
openCodeModelBridgeModelId: () => 'bridge-model',
ModelBridgeLoopbackProxy: class {
listen = async () => 'http://127.0.0.1:12345'
close = async () => {}
}
}))
vi.mock('./model-bridge-broker', () => ({
createUnixModelBridgeExchange: vi.fn()
}))
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
transport.agent = undefined
})
const server = (name: string) => ({
name, type: 'http' as const, url: `http://127.0.0.1:12346/${name}`,
headers: [{ name: 'Authorization', value: 'Bearer session-test-token' }]
})
// Keep the actual helper and adapter config builder together: mocking the
// adapter constructor would miss the model-profile configuration-loss bug.
it('delivers current ACP session MCP capabilities through the bridged model config, but not Ask', async () => {
const configs: Array<Record<string, unknown>> = []
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
operationId: 'operation', workMode: transport.mode
})))
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async function (
this: ContinueHostAdapter, _prompt, _signal, _authorize, options
) {
const path = await (this as unknown as {
createRunConfig(options: ContinueHostRunOptions): Promise<string>
}).createRunConfig(options!)
configs.push(JSON.parse(await readFile(path, 'utf8')))
await rm(path)
return { text: 'OK' }
})
const running = runContinueAcpHelper({
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
supportsImageInput: false, workMode: 'execute', sharedSessions: true, entrypoint: 'unused'
})
try {
await vi.waitFor(() => expect(transport.agent).toBeDefined())
const agent = transport.agent!
const { sessionId } = await agent.newSession({ cwd: tmpdir(), mcpServers: [server('first')] })
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'OK' }] })
await prompt()
await agent.resumeSession!({ sessionId, cwd: tmpdir(), mcpServers: [server('replacement')] })
await prompt()
transport.mode = 'ask'
await prompt()
transport.mode = 'execute'
await agent.loadSession!({ sessionId, cwd: tmpdir(), mcpServers: [] })
await prompt()
expect(configs.map(config => config.mcpServers ?? [])).toEqual([
[{ name: 'first', type: 'streamable-http', url: server('first').url,
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
[{ name: 'replacement', type: 'streamable-http', url: server('replacement').url,
requestOptions: { headers: { Authorization: 'Bearer session-test-token' } } }],
[], []
])
expect(configs[0].models).toEqual([expect.objectContaining({ model: 'test-model' })])
} finally {
transport.close()
await running
}
})
it('keeps local profile MCP scoping and filters explicit session servers in Ask at the adapter boundary', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-'))
try {
const configPath = join(root, 'native.json')
await writeFile(configPath, JSON.stringify({ mcpServers: [{ name: 'local-native', command: 'unused' }] }))
const adapter = new ContinueHostAdapter({
binaryPath: 'unused', cacheRoot: root, workspace: root, configPath,
modelProfile: { id: 'test', name: 'test', modelName: 'test',
protocol: 'openai-chat-completions', authentication: 'none', baseUrl: 'http://127.0.0.1:12345' }
})
const create = (options: ContinueHostRunOptions) => (adapter as unknown as {
createRunConfig(options: ContinueHostRunOptions): Promise<string>
}).createRunConfig(options)
for (const workMode of ['ask', 'execute'] as const) {
const path = await create({ workMode })
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
}
const path = await create({ workMode: 'ask', sessionMcpServers: [{
name: 'remote', type: 'streamable-http', url: 'http://127.0.0.1:12346', requestOptions: { headers: {} }
}] })
expect(JSON.parse(await readFile(path, 'utf8')).mcpServers ?? []).toEqual([])
} finally {
await rm(root, { recursive: true, force: true })
}
})
+8 -11
View File
@@ -1,7 +1,7 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable, Writable } from 'node:stream'
@@ -88,15 +88,13 @@ export async function runContinueAcpHelper(options: {
return await response.json() as { operationId: string; workMode: 'ask' | 'execute' }
}) : undefined
const workMode = route?.workMode ?? options.workMode
const configPath = join(root, `${sessionId}.json`)
await writeFile(configPath, JSON.stringify({ name: 'GoodBuddy', version: '1.0.0', schema: 'v1',
mcpServers: workMode === 'execute' ? session.mcpServers.map(server => {
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
return { name: server.name, type: 'streamable-http', url: server.url,
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
}) : [] }), { mode: 0o600 })
const sessionMcpServers = workMode === 'execute' ? session.mcpServers.map(server => {
if (!('type' in server) || server.type !== 'http') throw new Error('Continue remote MCP requires HTTP')
return { name: server.name, type: 'streamable-http' as const, url: server.url,
requestOptions: { headers: Object.fromEntries(server.headers.map(header => [header.name, header.value])) } }
}) : []
const adapter = new ContinueHostAdapter({
binaryPath: options.entrypoint, configPath, workspace: session.cwd, cacheRoot: root, mode: 'chat',
binaryPath: options.entrypoint, configPath: '', workspace: session.cwd, cacheRoot: root, mode: 'chat',
modelProfile: { id: 'agent-bridge', name: 'GoodBuddy', modelName: options.model,
protocol: options.protocol, authentication: 'api-key', apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL,
baseUrl: `${origin}/v1`, supportsImageInput: options.supportsImageInput,
@@ -114,7 +112,7 @@ export async function runContinueAcpHelper(options: {
try {
const result = await adapter.run(promptWithUntrustedConversationHistory({ prompt: text, history: session.history }, true), abort.signal,
async () => workMode === 'execute' ? 'once' : 'deny', {
workMode, images,
workMode, images, sessionMcpServers,
onEvent: async event => {
if (event.type === 'text') await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: event.delta }
@@ -156,7 +154,6 @@ export async function runContinueAcpHelper(options: {
} finally {
await adapter.dispose()
for (const [id, pending] of questions) if (pending.adapter === adapter) questions.delete(id)
await rm(configPath, { force: true })
session.active = undefined
finish()
}
+15
View File
@@ -253,6 +253,13 @@ export type ContinueHostAdapterDependencies = {
export type ContinueHostRunOptions = {
workMode?: 'ask' | 'execute'
images?: AgentImage[]
/** Explicit session capabilities, not MCP servers from a local model config. */
sessionMcpServers?: Array<{
name: string
type: 'streamable-http'
url: string
requestOptions: { headers: Record<string, string> }
}>
knowledgeCapability?: {
endpoint: string
token: string
@@ -1257,6 +1264,9 @@ export class ContinueHostAdapter {
)
}
const capabilityServers = [
...(runOptions.workMode === 'execute'
? runOptions.sessionMcpServers ?? []
: []),
...(knowledgeCapability
? [
createLoopbackMcpServer(
@@ -1274,6 +1284,11 @@ export class ContinueHostAdapter {
]
: [])
]
if (capabilityServers.length > maximumConfiguredMcpServers) {
throw new Error(
`Continue 配置文件中的 MCP Server 不能超过 ${maximumConfiguredMcpServers} 个`
)
}
const selectedPreset = presetConfig(runOptions.preset)
const hasPresetContent =
selectedPreset.rules.length > 0 ||
+13 -9
View File
@@ -140,14 +140,16 @@ it('includes older pinned conversations beyond the recent 100 and sorts pins by
database.saveLocalConversations(headers.map(header => ({ header, messages: [] })))
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: true })
database.setConversationPinned({ conversationId: headers[1]!.id, pinned: true })
const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(3).reverse().map(header => header.id)]
const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(2).reverse().map(header => header.id)]
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds)
const summaries = database.listConversationSummaries()
expect(summaries.map(item => item.id)).toEqual(expectedIds)
expect(summaries.slice(0, 2).every(item => item.pinned)).toBe(true)
expect(database.searchConversations('Conversation 0')).toEqual([headers[0]!.id])
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: false })
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds.filter(id => id !== headers[0]!.id))
expect(database.listConversations().map(item => item.id)).toEqual([
headers[1]!.id, ...headers.slice(2).reverse().map(header => header.id), headers[0]!.id
])
expect(database.getConversation(headers[0]!.id)).toMatchObject({ pinned: false, updatedAt: 1000 })
} finally {
database.close()
@@ -450,7 +452,7 @@ describe('AssistantDatabase', () => {
}
})
it('includes old live schedule and streaming conversations beyond recent history without exposing hidden or archived entries', async () => {
it('lists all active conversations while preserving task visibility and excluding archived conversations', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-'))
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
@@ -466,7 +468,7 @@ describe('AssistantDatabase', () => {
projectId, title, prompt: title, recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00Z'
}))
const [running, approval, pending, , hidden, archived] = schedules
const [running, approval, pending, idle, hidden, archived] = schedules
for (const schedule of [running!, approval!, hidden!, archived!]) {
claimManualScheduleQueueItem(database, schedule.id)
}
@@ -516,6 +518,7 @@ describe('AssistantDatabase', () => {
})
const tasks = database.listTasks(1)
expect(tasks).toHaveLength(6)
expect(tasks.some(task => task.id === hidden!.taskId)).toBe(false)
expect(tasks).toEqual(expect.arrayContaining([
historyTask,
expect.objectContaining({ id: running!.taskId, status: 'running' }),
@@ -523,18 +526,19 @@ describe('AssistantDatabase', () => {
expect.objectContaining({ id: archived!.taskId, status: 'running' })
]))
const conversations = database.listConversations()
expect(conversations).toHaveLength(105)
expect(conversations).toHaveLength(109)
expect(new Set(conversations.map((conversation) => conversation.id))).toEqual(
new Set([
...recent.slice(1).map((conversation) => conversation.header.id),
running!.conversationId, approval!.conversationId, streamingId,
...recent.map((conversation) => conversation.header.id),
running!.conversationId, approval!.conversationId, pending!.conversationId,
idle!.conversationId, hidden!.conversationId, streamingId,
...liveConversations
])
)
expect(conversations.find((conversation) => conversation.id === streamingId))
.toMatchObject({ projectId, messages: [streamingMessage] })
expect(conversations.slice(0, 100).map((conversation) => conversation.id))
.toEqual(recent.slice(1).reverse().map((conversation) => conversation.header.id))
expect(conversations.slice(0, recent.length).map((conversation) => conversation.id))
.toEqual(recent.slice().reverse().map((conversation) => conversation.header.id))
} finally {
raw.close()
database.close()
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { hasExtractedDocumentText } from './document-extracted-text'
describe('extracted document text', () => {
it.each([
'',
' \n ',
'![第 1 页图片](asset:image)',
'[第 1 页图片未保存:未返回内联图片字节]',
'![第 1 页图片](asset:image)\n[第 1 页图片未保存:图片格式不支持]',
'<p>&nbsp;</p>\n[第 2 页图片未保存:Base64 无效]'
])('does not count image references or generated warnings as text: %s', (content) => {
expect(hasExtractedDocumentText(content)).toBe(false)
})
it.each([
'Recognized text',
'![第 1 页图片](asset:image)\n实际正文\n[第 1 页图片未保存:Base64 无效]',
'<table><tr><td>实际表格</td></tr></table>'
])('retains recognized text: %s', (content) => {
expect(hasExtractedDocumentText(content)).toBe(true)
})
})
+8
View File
@@ -0,0 +1,8 @@
import { convert } from 'html-to-text'
export function hasExtractedDocumentText(content: string): boolean {
const text = content
.replace(/!\[[^\]]*\]\([^)]*\)/gu, '')
.replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, '')
return Boolean(convert(text, { wordwrap: false }).trim())
}
+2 -2
View File
@@ -1,5 +1,4 @@
import { extname } from 'node:path'
import { convert } from 'html-to-text'
import {
documentParsingDiagnosticSchema,
documentParsingSettingsUpdateSchema,
@@ -16,6 +15,7 @@ import type { DocumentOcrBroker } from './document-ocr-broker'
import type { DocumentOcrModelManager } from './document-ocr-model-manager'
import type { DocumentParsingSettingsStore } from './document-parsing-settings-store'
import { HttpDocumentOcr } from './http-document-ocr'
import { hasExtractedDocumentText } from './document-extracted-text'
import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf'
import type { DocumentResultStorage } from './document-result-storage'
import { extractPptxPages } from './knowledge/pptx-parser'
@@ -242,7 +242,7 @@ export class DocumentParsingService {
warnings = result.warnings
}
const content = sections.map((section) => section.content).join('\n\n')
if (!convert(content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()) throw new Error('未提取到文字,原图片发送方式保持不变')
if (!hasExtractedDocumentText(content)) throw new Error('未提取到文字,原图片发送方式保持不变')
ensureNotAborted(signal)
return { title: name, sourceFormat: extname(name).toLowerCase(), content, sections, warnings, pageCount: 1, images, missingImages, parsingSettings: settings, parsingDurationMs: Date.now() - startedAt }
}
+3 -3
View File
@@ -1,15 +1,15 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join } from 'node:path'
import { convert } from 'html-to-text'
import { createCanvas, loadImage } from '@napi-rs/canvas'
import { documentResultSchema, type DocumentResult } from '../shared/document-result-contracts'
import type { DocumentParsingSettings } from '../shared/document-parsing-contracts'
import type { ParsedDocument } from './knowledge/document-parser'
import { hasExtractedDocumentText } from './document-extracted-text'
export function parsedCompleteness(parsed: ParsedDocument): DocumentResult['completeness'] {
const text = convert(parsed.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, ''), { wordwrap: false }).trim()
return !text && parsed.images?.length ? 'images-only' : parsed.warnings.length ? 'partial' : 'complete'
return !hasExtractedDocumentText(parsed.content) && parsed.images?.length
? 'images-only' : parsed.warnings.length ? 'partial' : 'complete'
}
export function originalImageMime(data: Buffer): 'image/png' | 'image/jpeg' | 'image/webp' | undefined {
@@ -1,10 +1,17 @@
// @vitest-environment node
import { createServer } from 'node:http'
import { once } from 'node:events'
import { mkdtemp, rm } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createCanvas } from '@napi-rs/canvas'
import { expect, it } from 'vitest'
import { HttpDocumentOcr } from './http-document-ocr'
import { defaultHttpOcrSettings } from '../shared/document-parsing-contracts'
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
import { DocumentParsingService } from './document-parsing-service'
import { DocumentResultStorage } from './document-result-storage'
it('uses the production HTTP client for Bearer authentication, errors, cancellation and limits', async () => {
const calls: Array<{ authorization?: string; path?: string }> = []
@@ -39,3 +46,60 @@ it('uses the production HTTP client for Bearer authentication, errors, cancellat
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
it.each([false, true])('classifies missing-image warnings consistently through HTTP parsing and storage (text=%s)', async (hasText) => {
const image = createCanvas(4, 4).toBuffer('image/png')
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ocr-text-'))
const results = new DocumentResultStorage(directory)
const paths: string[] = []
const server = createServer(async (request, response) => {
for await (const chunk of request) void chunk
paths.push(request.url ?? '')
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({ result: { layoutParsingResults: [{
prunedResult: {},
markdown: {
text: `${hasText ? 'Recognized body\n' : ''}![valid](valid.png)\n![missing](missing.png)`,
images: { 'valid.png': image.toString('base64') }
}
}] } }))
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
try {
const settings = {
...defaultDocumentParsingSettings,
ocrProvider: 'paddleocr-vl' as const,
httpOcr: {
...defaultHttpOcrSettings,
baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`
}
}
const parsed = await new HttpDocumentOcr(settings.httpOcr).recognize(image, 1, [1])
expect(parsed.images).toHaveLength(1)
expect(parsed.missingImages).toHaveLength(1)
const saved = await results.save('scan.png', image, {
...parsed, title: 'scan.png', sourceFormat: '.png',
content: parsed.sections.map(section => section.content).join('\n\n'),
pageCount: 1
}, settings, 0)
expect((await results.get(saved.id)).completeness).toBe(hasText ? 'partial' : 'images-only')
expect(saved.content).toContain('图片未保存')
const service = new DocumentParsingService({
forOperation: async () => ({ settings, apiKey: () => undefined })
} as never, {} as never, {} as never)
const extracted = service.extractImage('scan.png', image)
if (hasText) {
await expect(extracted).resolves.toMatchObject({ content: expect.stringContaining('Recognized body') })
} else {
await expect(extracted).rejects.toThrow('未提取到文字,原图片发送方式保持不变')
}
expect(paths).toEqual(['/layout-parsing', '/layout-parsing'])
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
await results.close()
await rm(directory, { recursive: true, force: true })
}
})
+4 -3
View File
@@ -1,12 +1,12 @@
import { randomUUID } from 'node:crypto'
import { loadImage } from '@napi-rs/canvas'
import { convert } from 'html-to-text'
import { z } from 'zod'
import {
maximumDocumentExtractedCharacters,
type httpOcrSettingsSchema
} from '../shared/document-parsing-contracts'
import type { ParsedDocument, ParsedSection } from './knowledge/document-parser'
import { hasExtractedDocumentText } from './document-extracted-text'
type Settings = z.infer<typeof httpOcrSettingsSchema>
export type ParsedDocumentImage = {
@@ -203,8 +203,9 @@ export class HttpDocumentOcr {
}
}
signal?.throwIfAborted()
const text = sections.map((section) => convert(section.content.replace(/!\[[^\]]*\]\([^)]*\)/gu, '').replace(/\[第 \d+ 页图片未保存:[^\]]*\]/gu, ''), { wordwrap: false })).join('').trim()
if (!text && images.length === 0) throw new Error('HTTP OCR 未返回文字或有效图片')
if (!hasExtractedDocumentText(sections.map((section) => section.content).join('\n\n')) && images.length === 0) {
throw new Error('HTTP OCR 未返回文字或有效图片')
}
return { sections, images, missingImages, warnings, ...(restructure ? { restructure } : {}) }
}
}
+83
View File
@@ -1225,6 +1225,9 @@ describe("App", () => {
return item;
});
vi.mocked(api.conversationQueue.remove).mockReset().mockResolvedValue();
vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockResolvedValue({
conversationId: "", prompt: "", attachments: [],
});
vi.mocked(api.conversationQueue.interruptAndRun)
.mockReset()
.mockResolvedValue();
@@ -2233,6 +2236,86 @@ describe("App", () => {
);
});
it.each(["empty", "existing", "edited", "cleared"] as const)(
"merges restored queued text into the latest %s draft",
async (draftState) => {
const conversationId = "00000000-0000-4000-8000-000000000951";
const itemId = "00000000-0000-4000-8000-000000000953";
vi.mocked(api.conversations.list).mockResolvedValue([{
id: conversationId, projectId, title: "恢复草稿", updatedAt: Date.now(), messages: [],
}]);
vi.mocked(api.conversationQueue.list).mockResolvedValue([{
id: itemId, conversationId, source: "user", label: "待恢复输入",
createdAt: "2026-09-19T00:00:00.000Z",
}]);
const response = deferred<Awaited<ReturnType<DesktopApi["conversationQueue"]["restoreToDraft"]>>>();
vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockReturnValue(response.promise);
render(<App />);
const restore = await screen.findByRole("button", { name: "恢复到草稿:待恢复输入" });
const input = screen.getByLabelText("向 GoodBuddy 提问");
const original = draftState === "empty" ? "" : "Original draft";
fireEvent.change(input, { target: { value: original } });
fireEvent.click(restore);
await waitFor(() => expect(api.conversationQueue.restoreToDraft).toHaveBeenCalledWith(itemId, ""));
expect(input).not.toBeDisabled();
const latest = draftState === "edited" ? "Edited while restoring"
: draftState === "cleared" ? "" : original;
fireEvent.change(input, { target: { value: latest } });
await act(async () => response.resolve({
conversationId, prompt: "Queued prompt", attachments: [],
}));
await waitFor(() => expect(input).toHaveValue([latest, "Queued prompt"].filter(Boolean).join("\n\n")));
},
);
it("preserves draft text when queued restoration fails", async () => {
const conversationId = "00000000-0000-4000-8000-000000000951";
vi.mocked(api.conversations.list).mockResolvedValue([{
id: conversationId, projectId, title: "恢复失败", updatedAt: Date.now(), messages: [],
}]);
vi.mocked(api.conversationQueue.list).mockResolvedValue([{
id: "00000000-0000-4000-8000-000000000953", conversationId,
source: "user", label: "不可恢复输入", createdAt: "2026-09-19T00:00:00.000Z",
}]);
const response = deferred<Awaited<ReturnType<DesktopApi["conversationQueue"]["restoreToDraft"]>>>();
vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockReturnValue(response.promise);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "恢复到草稿:不可恢复输入" }));
await waitFor(() => expect(api.conversationQueue.restoreToDraft).toHaveBeenCalled());
const input = screen.getByLabelText("向 GoodBuddy 提问");
fireEvent.change(input, { target: { value: "Retained draft" } });
await act(async () => response.reject(new Error("Restore failed")));
expect(input).toHaveValue("Retained draft");
expect(await screen.findByText("Restore failed")).toBeInTheDocument();
});
it("keeps queued restoration attached to its original conversation after switching", async () => {
const conversationId = "00000000-0000-4000-8000-000000000951";
const otherId = "00000000-0000-4000-8000-000000000954";
const item = {
id: "00000000-0000-4000-8000-000000000953", conversationId,
source: "user" as const, label: "原会话输入", createdAt: "2026-09-19T00:00:00.000Z",
};
vi.mocked(api.conversations.list).mockResolvedValue([
{ id: conversationId, projectId, title: "Original draft conversation", updatedAt: 200, messages: [] },
{ id: otherId, projectId, title: "Other draft conversation", updatedAt: 100, messages: [] },
]);
vi.mocked(api.conversationQueue.list).mockImplementation(async id => !id || id === conversationId ? [item] : []);
const response = deferred<Awaited<ReturnType<DesktopApi["conversationQueue"]["restoreToDraft"]>>>();
vi.mocked(api.conversationQueue.restoreToDraft).mockReset().mockReturnValue(response.promise);
render(<App />);
const restore = await screen.findByRole("button", { name: "恢复到草稿:原会话输入" });
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Original draft" } });
fireEvent.click(restore);
await waitFor(() => expect(api.conversationQueue.restoreToDraft).toHaveBeenCalled());
fireEvent.click(screen.getByText("Other draft conversation").closest("button")!);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Other draft" } });
await act(async () => response.resolve({ conversationId, prompt: "Queued prompt", attachments: [] }));
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Other draft");
fireEvent.click(screen.getByText("Original draft conversation").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Original draft\n\nQueued prompt"));
});
it("preserves scoped queue updates that arrive during another refresh", async () => {
const firstConversationId = "00000000-0000-4000-8000-000000000954";
const secondConversationId = "00000000-0000-4000-8000-000000000955";
+2 -2
View File
@@ -9939,8 +9939,8 @@ function App(): React.JSX.Element {
onRemove={removeConversationQueueItem}
onRestore={async (itemId) => {
await attachmentSaveQueue.current;
const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, input);
setInput(restored.prompt);
const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, "");
setInput((current) => [current, restored.prompt].filter(Boolean).join("\n\n"));
}}
running={conversationExecutionRunning}
/>
+3 -11
View File
@@ -267,17 +267,9 @@ describe('WorkspacePrimitives', () => {
})
it('keeps forced-color and docked split contracts authoritative', () => {
const forcedColorsStart = stylesheet.indexOf(
'@media (forced-colors: active)'
)
const forcedColorsEnd = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)',
forcedColorsStart
)
const forcedColors = stylesheet.slice(
forcedColorsStart,
forcedColorsEnd
)
const forcedColors = [...stylesheet.matchAll(
/@media \(forced-colors: active\)\s*\{(?:[^{}]|\{[^{}]*\})*\}/gu
)].map(([block]) => block).join('\n')
expect(forcedColors).toContain(':root :is(')
expect(forcedColors).not.toContain(':where(')
expect(forcedColors).toMatch(
+9 -2
View File
@@ -73,7 +73,10 @@ reference refreshes the viewer. It does not own a modal or persistence.
- Pen, highlighter, whole-object eraser, Fabric selection/move/scale/rotation,
floating editable text, and JPEG/PNG/GIF/WebP images.
- Quill body text flows across pages. Adding a page in flow mode inserts a page
break. Deleting a page removes its break and reflows body text. Deleting a
break. Deleting a page removes only its break and reflows body text, retaining
paragraph terminators and their checklist/heading formats. Mid-paragraph
insertion uses Quill's normal block split; removing the break does not merge
the resulting paragraphs or discard either paragraph's formatting. Deleting a
nonempty annotation/PDF page requires a second click within five seconds.
- Undo/redo is per annotation page or Quill body mode, matching PeopleLib.
Page insertion/deletion and PDF import are not document-wide undo steps.
@@ -112,7 +115,11 @@ Canvas assets do not use the rich-text image or aggregate attachment budgets.
The shared canvas schema and Main validation accept this image size; persistence
stores decoded assets in files and hydrates them on read without a smaller byte cap.
Exactly 20,000 body code units plus Quill's terminal newline round-trip intact.
Oversized user edits revert to the preceding Delta and call `onError`.
Oversized user edits apply only the rejected edit's inverse Delta and call
`onError`, preserving the previous selection and accepted undo/redo history.
The canvas-local History module validates before recording, so rejection cannot
merge into accepted typing or clear redo; other Quill editors keep their normal
History module. Rejection does not emit an accepted content change.
Oversized initial/API content throws a `RangeError`; `flush()` rejects rather
than returning a document with missing flow text. Callers must not save a
replacement empty document after that rejection.
+31 -6
View File
@@ -10,6 +10,24 @@ const INLINE_FORMATS = ['bold', 'italic', 'underline', 'strike', 'code'];
const BLOCK_FORMATS = ['header', 'blockquote', 'code-block', 'list', 'indent'];
const FLOW_FORMATS = [...INLINE_FORMATS, ...BLOCK_FORMATS, 'canvasPageBreak'];
class FlowHistory extends Quill.import('modules/history') {
record(delta, oldDelta) {
try {
normalizeFlowContent({ version: FLOW_VERSION, ops: oldDelta.compose(delta).ops });
} catch {
// Reject before History merges the edit or clears accepted redo entries.
this.rejectedRange = this.currentRange;
return;
}
super.record(delta, oldDelta);
}
}
// Keep the custom History module local to canvas editors.
class FlowQuill extends Quill {
static imports = { ...Quill.imports, 'modules/history': FlowHistory };
}
function cloneJson(value) {
return JSON.parse(JSON.stringify(value));
}
@@ -173,7 +191,7 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
layerHost.appendChild(editorHost);
toolbarHost.appendChild(toolbar);
const quill = new Quill(editorHost, {
const quill = new FlowQuill(editorHost, {
theme: 'snow',
placeholder: '输入正文,内容超过纸张后会自动分页',
formats: FLOW_FORMATS,
@@ -307,7 +325,18 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
try {
content();
} catch (error) {
quill.setContents(oldDelta, 'silent');
const history = quill.history;
const range = history.rejectedRange;
history.ignoreChange = true;
try {
quill.updateContents(delta.invert(oldDelta), 'silent');
} finally {
history.ignoreChange = false;
}
if (range) quill.setSelection(range, 'silent');
history.currentRange = range;
history.rejectedRange = null;
suppressUserFollowSelection = false;
options.onError?.(error.message);
scheduleLayout();
return;
@@ -359,10 +388,6 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
if (index < 0) return false;
suppressUserFollowSelection = true;
quill.deleteText(index, 1, 'user');
if (index > 0 && quill.getText(index - 1, 1) === '\n') {
suppressUserFollowSelection = true;
quill.deleteText(index - 1, 1, 'user');
}
scheduleLayout();
return true;
},
@@ -44,12 +44,66 @@ function mount(initial) {
const toolbar = document.createElement('div');
document.body.append(layer, toolbar);
const onError = vi.fn();
const editor = mountFlowText(layer, toolbar, initial, { onError });
const onChange = vi.fn();
const editor = mountFlowText(layer, toolbar, initial, { onError, onChange });
mounted.push(editor);
return { editor, quill: Quill.find(layer.firstElementChild), onError };
const quill = Quill.find(layer.firstElementChild);
// jsdom has no Range geometry; actual selection scrolling runs in Electron.
vi.spyOn(quill, 'scrollSelectionIntoView').mockImplementation(() => {});
return { editor, quill, onError, onChange };
}
describe('canvas flow limits', () => {
it('keeps normal accepted undo/redo and does not replace other Quill editors history modules', () => {
const History = Quill.import('modules/history');
const { editor, quill, onError } = mount({ version: 1, ops: [{ insert: 'x'.repeat(19995) + '\n' }] });
quill.insertText(19995, 'HELLO', 'user');
editor.undo();
expect(editor.text()).toBe('x'.repeat(19995));
editor.redo();
expect(editor.text()).toBe('x'.repeat(19995) + 'HELLO');
expect(onError).not.toHaveBeenCalled();
expect(Quill.import('modules/history')).toBe(History);
expect(quill.history.constructor).not.toBe(History);
});
it.each([false, true])('retains accepted undo and redo after overflow (cutoff: %s)', (cutoff) => {
const { editor, quill, onError, onChange } = mount({ version: 1, ops: [{ insert: 'x'.repeat(19995) + '\n' }] });
quill.setSelection(19995, 0, 'api');
quill.insertText(19995, 'HELLO', 'user');
quill.setSelection(20000, 0, 'api');
if (cutoff) quill.history.cutoff();
quill.insertText(20000, '!', 'user');
expect(onError).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledTimes(1);
expect(quill.getSelection()).toEqual({ index: 20000, length: 0 });
expect(quill.history.stack.undo).toHaveLength(1);
editor.undo();
expect(editor.text()).toBe('x'.repeat(19995));
expect(editor.canRedo()).toBe(true);
quill.setSelection(3, 4, 'api');
quill.insertText(3, 'REJECTED', 'user');
expect(quill.getSelection()).toEqual({ index: 3, length: 4 });
expect(editor.text()).toBe('x'.repeat(19995));
expect(editor.canUndo()).toBe(false);
expect(editor.canRedo()).toBe(true);
editor.redo();
expect(editor.text()).toBe('x'.repeat(19995) + 'HELLO');
});
it('preserves replacement selections and accepted formatting when rejecting an oversized paste', () => {
const { editor, quill } = mount({ version: 1, ops: [{ insert: 'x'.repeat(20000) + '\n' }] });
quill.formatText(0, 5, 'bold', true, 'user');
const accepted = editor.content();
quill.setSelection(10, 3, 'api');
const Delta = Quill.import('delta');
quill.updateContents(new Delta().retain(10).delete(3).insert('TOO LONG'), 'user');
expect(editor.content()).toEqual(accepted);
expect(quill.getSelection()).toEqual({ index: 10, length: 3 });
editor.undo();
expect(editor.content()).toEqual({ version: 1, ops: [{ insert: 'x'.repeat(20000) + '\n' }] });
});
it.each([0.25, 0.5, 2, 3])('measures logical columns and selection at %sx display scale', (scale) => {
const { editor, quill } = mount({ version: 1, ops: [{ insert: 'Body\n' }] });
editor.setLayout(640, 960, 1);
@@ -93,3 +147,34 @@ describe('canvas flow limits', () => {
expect(quill.getText()).toHaveLength(20002);
});
});
describe('canvas page-break removal', () => {
it.each([{ list: 'unchecked' }, { list: 'checked' }, { header: 1 }, { blockquote: true }, {}])(
'preserves existing paragraph terminators and formats: %j', (attributes) => {
const { editor, quill } = mount({ version: 1, ops: [
{ insert: 'Task' }, { insert: '\n', attributes }, { insert: '\n' }
] });
const original = editor.content();
quill.setSelection(5, 0, 'api');
expect(editor.insertPageBreak('page-2')).toBe(true);
expect(editor.insertPageBreak('page-2')).toBe(false);
expect(editor.removePageBreak('missing')).toBe(false);
expect(editor.removePageBreak('page-2')).toBe(true);
expect(editor.content()).toEqual(original);
expect(editor.removePageBreak('page-2')).toBe(false);
}
);
it('retains Quill paragraph splitting and both checklist formats when inserting within a line', () => {
const { editor, quill } = mount({ version: 1, ops: [
{ insert: 'Task body' }, { insert: '\n', attributes: { list: 'unchecked' } }
] });
quill.setSelection(4, 0, 'api');
editor.insertPageBreak('page-2');
editor.removePageBreak('page-2');
expect(editor.content()).toEqual({ version: 1, ops: [
{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } },
{ insert: ' body' }, { insert: '\n', attributes: { list: 'unchecked' } }
] });
});
});