diff --git a/README.ja.md b/README.ja.md index 58c9dab..dafee4b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -60,6 +60,7 @@ ompweb --no-open # ブラウザ自動起動を無効 - **リアルタイムチャット**: ローカルの `omp` エージェントとストリーミング対話。 - **キュー削除の確認**: キュー内のフォローアップやステアメッセージをパネルから削除する前に、内容を表示して確認します。OMP 内部ですでにキューに入ったメッセージの配信は取り消しません。 - **セッション管理**: プロジェクトごとに履歴を一覧表示、分岐やフォークにも対応。 +- **下書きの復元**: 未送信のテキストを会話または新規セッションのワークスペースごとに保存し、ブラウザストレージが利用可能な場合は、同じタブでの「戻る」「進む」や再読み込み後に復元します(最大 50 件)。画像と添付ファイルはメモリ内にのみ保持されます。 - **ライブタスク&サブエージェント**: Todo リストと稼働中サブエージェントの進捗を折りたたみパネルでリアルタイム表示。 - **ファイル閲覧・プレビュー**: チャットと並べてファイルを閲覧、コード・Markdown・画像・音声・PDF をプレビュー。 - **Git Worktree サポート**: サイドバーから直接 Git ワークツリーを切り替え・管理。 diff --git a/README.md b/README.md index 3224e5a..d5b41af 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1df95b6..f1867a4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,6 +60,7 @@ ompweb --no-open # 不自动打开浏览器 - **实时对话**:与本地 `omp` 智能体进行低延迟流式交互。 - **队列删除确认**:从队列面板移除后续消息或引导消息前,先预览内容并确认。此操作不会取消 OMP 内部已排队消息的发送。 - **会话管理**:按项目浏览历史会话,支持会话分叉与分支回溯。 +- **草稿恢复**:未发送的文本按会话或新会话的工作区分别保存;浏览器存储可用时,在同一标签页后退、前进或重新加载后可恢复(最多 50 份草稿)。图片和文件附件仅保留在内存中。 - **实时任务与子智能体**:可折叠面板实时展示任务清单(todo)与子智能体进度,并支持查看完整转录。 - **文件管理与预览**:与对话并排浏览文件,支持代码、Markdown、图片、音频及 PDF 预览。 - **Git Worktree 支持**:直接在侧边栏切换与管理 Git 工作树。 diff --git a/lib/draft-store.test.mjs b/lib/draft-store.test.mjs index 5fbdd94..25b7985 100644 --- a/lib/draft-store.test.mjs +++ b/lib/draft-store.test.mjs @@ -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]); }); diff --git a/lib/draft-store.ts b/lib/draft-store.ts index 43667a8..d24d5a2 100644 --- a/lib/draft-store.ts +++ b/lib/draft-store.ts @@ -22,7 +22,32 @@ declare global { } const MAX_DRAFTS = 50; -const drafts: Map = (globalThis.__ompChatDrafts ??= new Map()); +const STORAGE_PREFIX = "omp-draft-"; +const drafts: Map = (globalThis.__ompChatDrafts ??= readStoredDrafts()); + +function readStoredDrafts(): Map { + const stored = new Map(); + 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. + } }