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:
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' } }
|
||||
] });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user