mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-07 10:56:00 -10:00
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:
1 parent
759e183393
commit
01fc51aea5
5 files changed
+167
-17
No files matched your search
@@ -60,6 +60,7 @@ ompweb --no-open # ブラウザ自動起動を無効
|
||||
- **リアルタイムチャット**: ローカルの `omp` エージェントとストリーミング対話。
|
||||
- **キュー削除の確認**: キュー内のフォローアップやステアメッセージをパネルから削除する前に、内容を表示して確認します。OMP 内部ですでにキューに入ったメッセージの配信は取り消しません。
|
||||
- **セッション管理**: プロジェクトごとに履歴を一覧表示、分岐やフォークにも対応。
|
||||
- **下書きの復元**: 未送信のテキストを会話または新規セッションのワークスペースごとに保存し、ブラウザストレージが利用可能な場合は、同じタブでの「戻る」「進む」や再読み込み後に復元します(最大 50 件)。画像と添付ファイルはメモリ内にのみ保持されます。
|
||||
- **ライブタスク&サブエージェント**: Todo リストと稼働中サブエージェントの進捗を折りたたみパネルでリアルタイム表示。
|
||||
- **ファイル閲覧・プレビュー**: チャットと並べてファイルを閲覧、コード・Markdown・画像・音声・PDF をプレビュー。
|
||||
- **Git Worktree サポート**: サイドバーから直接 Git ワークツリーを切り替え・管理。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,6 +60,7 @@ ompweb --no-open # 不自动打开浏览器
|
||||
- **实时对话**:与本地 `omp` 智能体进行低延迟流式交互。
|
||||
- **队列删除确认**:从队列面板移除后续消息或引导消息前,先预览内容并确认。此操作不会取消 OMP 内部已排队消息的发送。
|
||||
- **会话管理**:按项目浏览历史会话,支持会话分叉与分支回溯。
|
||||
- **草稿恢复**:未发送的文本按会话或新会话的工作区分别保存;浏览器存储可用时,在同一标签页后退、前进或重新加载后可恢复(最多 50 份草稿)。图片和文件附件仅保留在内存中。
|
||||
- **实时任务与子智能体**:可折叠面板实时展示任务清单(todo)与子智能体进度,并支持查看完整转录。
|
||||
- **文件管理与预览**:与对话并排浏览文件,支持代码、Markdown、图片、音频及 PDF 预览。
|
||||
- **Git Worktree 支持**:直接在侧边栏切换与管理 Git 工作树。
|
||||
|
||||
+124
-14
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user