fix: restore unsent drafts after browser navigation (#68)

* fix: restore unsent drafts after browser navigation

* fix: prune persisted draft overflow on restore

* fix: discard empty persisted drafts during restore
This commit is contained in:
Andre Brait authored and GitHub committed 2026-09-10 21:19:28 +03:00
1 parent 759e183393
commit 01fc51aea5
5 files changed
+167 -17

No files matched your search

+1
View File
@@ -60,6 +60,7 @@ ompweb --no-open # ブラウザ自動起動を無効
- **リアルタイムチャット**: ローカルの `omp` エージェントとストリーミング対話。
- **キュー削除の確認**: キュー内のフォローアップやステアメッセージをパネルから削除する前に、内容を表示して確認します。OMP 内部ですでにキューに入ったメッセージの配信は取り消しません。
- **セッション管理**: プロジェクトごとに履歴を一覧表示、分岐やフォークにも対応。
- **下書きの復元**: 未送信のテキストを会話または新規セッションのワークスペースごとに保存し、ブラウザストレージが利用可能な場合は、同じタブでの「戻る」「進む」や再読み込み後に復元します(最大 50 件)。画像と添付ファイルはメモリ内にのみ保持されます。
- **ライブタスク&サブエージェント**: Todo リストと稼働中サブエージェントの進捗を折りたたみパネルでリアルタイム表示。
- **ファイル閲覧・プレビュー**: チャットと並べてファイルを閲覧、コード・Markdown・画像・音声・PDF をプレビュー。
- **Git Worktree サポート**: サイドバーから直接 Git ワークツリーを切り替え・管理。
+1
View File
@@ -117,6 +117,7 @@ password is stored there in plain text).
- **Interactive Chat**: Real-time streaming conversation with your local `omp` agent — tool calls, thinking levels, token counts, cost, context gauge, queue controls, and interrupt & retry.
- **Queue Deletion Confirmation**: Preview and confirm before removing queued follow-ups or steered messages from the queue panel. This does not cancel delivery already queued inside OMP.
- **Session Management**: Browse past conversations by project, fork sessions, branch within a session, archive/restore, import session files, and deep-link via URL.
- **Draft Recovery**: Unsent text stays scoped to its conversation or new-session workspace and is restored after Back/Forward navigation or reload in the same tab when browser storage is available (up to 50 drafts). Images and file attachments remain in memory only.
- **Live Plans & Subagents**: Collapsible panels pinned above the composer track live todo phases and running subagents (status, tool, retries, tokens/cost, nested tasks) with transcript dialogs and history recovery.
- **Tool Preset Picker**: Choose the toolset for new sessions in the composer — `none` / `default` (`read,bash,edit,write`) / `full` (all tools including subagents). Persists to localStorage.
- **File Explorer & Previews**: Browse workspaces side-by-side with chat; preview code, markdown, Mermaid, images, audio, PDFs, and diffs with allow-listed access.
+1
View File
@@ -60,6 +60,7 @@ ompweb --no-open # 不自动打开浏览器
- **实时对话**:与本地 `omp` 智能体进行低延迟流式交互。
- **队列删除确认**:从队列面板移除后续消息或引导消息前,先预览内容并确认。此操作不会取消 OMP 内部已排队消息的发送。
- **会话管理**:按项目浏览历史会话,支持会话分叉与分支回溯。
- **草稿恢复**:未发送的文本按会话或新会话的工作区分别保存;浏览器存储可用时,在同一标签页后退、前进或重新加载后可恢复(最多 50 份草稿)。图片和文件附件仅保留在内存中。
- **实时任务与子智能体**:可折叠面板实时展示任务清单(todo)与子智能体进度,并支持查看完整转录。
- **文件管理与预览**:与对话并排浏览文件,支持代码、Markdown、图片、音频及 PDF 预览。
- **Git Worktree 支持**:直接在侧边栏切换与管理 Git 工作树。
+124 -14
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { clearDraft, getDraft, getDraftSummary, setDraft } from "./draft-store.ts";
@@ -30,21 +31,130 @@ test("getDraftSummary returns text and attachment presence without deep cloning"
assert.equal(getDraft(key), null);
});
test("setDraft caps total stored drafts to prevent memory bloat", () => {
for (let i = 0; i < 60; i++) {
setDraft(`eviction-session-${i}`, {
value: `Draft ${i}`,
images: [],
test("evicted drafts stay evicted after a fresh document", () => {
const saved = inFreshDocument([], `
for (let i = 0; i < 60; i++) {
setDraft("eviction-session-" + i, { value: "Draft " + i, images: [], files: [] });
}
const result = null;
`);
const restored = inFreshDocument(saved.storage, `
const result = Array.from({ length: 60 }, (_, i) => getDraft("eviction-session-" + i)?.value ?? null);
`);
assert.deepEqual(restored.result, [
...Array(10).fill(null),
...Array.from({ length: 50 }, (_, i) => "Draft " + (i + 10)),
]);
});
test("empty and overflowing stored drafts are removed without affecting unrelated settings", () => {
const overflow = [
["unrelated-setting", "keep"],
["omp-draft-empty", ""],
...Array.from({ length: 55 }, (_, i) => ["omp-draft-overflow-" + i, "Draft " + i]),
];
const restored = inFreshDocument(overflow, `
const result = [];
for (let i = 0; i < 55; i++) {
const key = "overflow-" + i;
const draft = getDraft(key);
if (draft) {
result.push(draft.value);
clearDraft(key);
}
}
`);
assert.equal(restored.result.length, 50);
const cleared = inFreshDocument(restored.storage, `
const result = Array.from({ length: 55 }, (_, i) => getDraft("overflow-" + i));
`);
assert.deepEqual(cleared.result, Array(55).fill(null));
assert.deepEqual(cleared.storage, [["unrelated-setting", "keep"]]);
});
// Each process is a fresh document: retain only Web Storage, never module state.
function inFreshDocument(storage, action, setup = "") {
const script = `
const storage = new Map(JSON.parse(process.env.DRAFT_STORAGE));
globalThis.sessionStorage = {
get length() { return storage.size; },
key: (index) => [...storage.keys()][index] ?? null,
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key),
};
${setup}
const { setDraft, getDraft, getDraftSummary, clearDraft } =
await import(${JSON.stringify(new URL("./draft-store.ts", import.meta.url).href)});
${action}
console.log(JSON.stringify({ storage: [...storage], result }));
`;
const child = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", script], {
encoding: "utf8",
env: { ...process.env, DRAFT_STORAGE: JSON.stringify(storage) },
});
assert.equal(child.status, 0, child.stderr);
return JSON.parse(child.stdout);
}
test("unsent text survives a fresh document, stays scoped, and cannot reappear after clearing", () => {
const text = " Unsent first line\n第二行 — café\n";
const saved = inFreshDocument([], `
setDraft("session-a", { value: ${JSON.stringify(text)}, images: [], files: [] });
setDraft("new:/workspace-b", { value: "Another draft", images: [], files: [] });
const result = null;
`);
const restored = inFreshDocument(saved.storage, `
const result = [
getDraft("session-a")?.value,
getDraftSummary("new:/workspace-b").text,
getDraft("session-c"),
];
clearDraft("session-a");
setDraft("new:/workspace-b", { value: "", images: [], files: [] });
`);
assert.deepEqual(restored.result, [text, "Another draft", null]);
const cleared = inFreshDocument(restored.storage, `
const result = [getDraft("session-a"), getDraft("new:/workspace-b")];
`);
assert.deepEqual(cleared.result, [null, null]);
});
test("only text survives navigation, including when an attachment-only draft replaces it", () => {
const saved = inFreshDocument([], `
setDraft("attachments", {
value: "Keep text",
images: [{ data: "data:image/png;base64,AAAA", mimeType: "image/png" }],
files: [{ name: "notes.txt", mimeType: "text/plain", content: "File content", size: 12 }],
});
const result = getDraftSummary("attachments").hasAttachments;
`);
assert.equal(saved.result, true);
const restored = inFreshDocument(saved.storage, `
const result = getDraft("attachments");
setDraft("attachments", {
value: "",
images: [{ data: "data:image/png;base64,AAAA", mimeType: "image/png" }],
files: [],
});
}
`);
assert.deepEqual(restored.result, { value: "Keep text", images: [], files: [] });
const cleared = inFreshDocument(restored.storage, `
const result = getDraft("attachments");
`);
assert.equal(cleared.result, null);
});
// Oldest drafts beyond the 50 cap should have been evicted
assert.equal(getDraft("eviction-session-0"), null);
assert.equal(getDraft("eviction-session-1"), null);
// Newest drafts should be present
const recent = getDraft("eviction-session-59");
assert.ok(recent);
assert.equal(recent.value, "Draft 59");
test("denied browser storage still allows in-memory editing and clearing", () => {
const document = inFreshDocument([], `
setDraft("blocked", { value: "Still editable", images: [], files: [] });
const result = [getDraft("blocked")?.value];
clearDraft("blocked");
result.push(getDraft("blocked"));
`, `
Object.defineProperty(globalThis, "sessionStorage", {
get() { throw new DOMException("Storage denied", "SecurityError"); },
});
`);
assert.deepEqual(document.result, ["Still editable", null]);
});
+40 -3
View File
@@ -22,7 +22,32 @@ declare global {
}
const MAX_DRAFTS = 50;
const drafts: Map<string, ChatDraft> = (globalThis.__ompChatDrafts ??= new Map());
const STORAGE_PREFIX = "omp-draft-";
const drafts: Map<string, ChatDraft> = (globalThis.__ompChatDrafts ??= readStoredDrafts());
function readStoredDrafts(): Map<string, ChatDraft> {
const stored = new Map<string, ChatDraft>();
try {
// Snapshot keys: removing overflow can change Storage's enumeration order.
const storageKeys = Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i));
for (const storageKey of storageKeys) {
if (!storageKey?.startsWith(STORAGE_PREFIX)) continue;
if (stored.size >= MAX_DRAFTS) {
sessionStorage.removeItem(storageKey);
continue;
}
const value = sessionStorage.getItem(storageKey);
if (value) {
stored.set(storageKey.slice(STORAGE_PREFIX.length), { value, images: [], files: [] });
} else {
sessionStorage.removeItem(storageKey);
}
}
} catch {
// Storage may be unavailable (SSR or browser policy); keep drafts in memory.
}
return stored;
}
function cloneDraft(draft: ChatDraft): ChatDraft {
return {
@@ -52,16 +77,28 @@ export function getDraftSummary(key: string): { text: string; hasAttachments: bo
export function setDraft(key: string, draft: ChatDraft): void {
if (isEmptyDraft(draft)) {
drafts.delete(key);
clearDraft(key);
return;
}
if (drafts.size >= MAX_DRAFTS && !drafts.has(key)) {
const oldestKey = drafts.keys().next().value;
if (oldestKey) drafts.delete(oldestKey);
if (oldestKey !== undefined) clearDraft(oldestKey);
}
drafts.set(key, cloneDraft(draft));
try {
// Persist only text: attachment payloads can exhaust the tab's storage quota.
if (draft.value) sessionStorage.setItem(STORAGE_PREFIX + key, draft.value);
else sessionStorage.removeItem(STORAGE_PREFIX + key);
} catch {
// Preserve the in-memory draft if storage is unavailable or full.
}
}
export function clearDraft(key: string): void {
drafts.delete(key);
try {
sessionStorage.removeItem(STORAGE_PREFIX + key);
} catch {
// Storage may be unavailable.
}
}