Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f

### Fixes & Improvements

- Promote queued follow-ups through OMP's `promote_queued_message` command before relabeling them as steering; unsupported runtimes leave the queue unchanged.
- Cancel queued messages in OMP before removing their chips or recalling them for editing. Failed or timed-out cancellation leaves the message visible; successful recall survives composer remounts without losing newer typing. Requires native `remove_queued_message` support.
- Preserve question-dialog answers and selections when an SSE reconnect replays the same pending request or answer submission fails.
- Keep composer controls on one line, with equally sized Send, Stop, and Queue buttons and model names truncating before short effort labels.
- Align the + button and primary action with matching composer insets.
- Clearly dim Attach files while the agent is running; queued messages remain text-only.
Expand Down
6 changes: 5 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
- [omp](https://github.com/can1357/oh-my-pi) がインストールされ、`PATH` に含まれていること(または `OMP_WEB_OMP_BIN` で指定)
- Node.js `>= 22.19.0`

キュー内のフォローアップに対する **Steer** 操作には、`promote_queued_message` RPC コマンドをサポートする omp ランタイムが必要です(omp 18.1.16 では未対応)。古いランタイムではエラーを表示し、メッセージはフォローアップとしてキューに残ります。ompweb が重複するステアリングメッセージを送信することはありません。

キューの **Delete** と **Edit** には、さらに `remove_queued_message` が必要です。OMP がキャンセルを確認してから表示を削除し、編集時はキャンセル成功後にテキストを入力欄へ戻します。未対応のランタイムや、すでにキューに存在しないメッセージでは、キューを変更せず通知を表示します。

## クイックスタート

**インストールせずに直接実行:**
Expand Down Expand Up @@ -58,7 +62,7 @@ ompweb --no-open # ブラウザ自動起動を無効
## 主な機能

- **リアルタイムチャット**: ローカルの `omp` エージェントとストリーミング対話。
- **キュー削除の確認**: キュー内のフォローアップやステアメッセージをパネルから削除する前に、内容を表示して確認します。OMP 内部ですでにキューに入ったメッセージの配信は取り消しません
- **キュー削除の確認**: OMP にキュー登録されたフォローアップやステアメッセージをキャンセルする前に、内容を表示して確認します。ネイティブの `remove_queued_message` 対応が必要です。配信済みのメッセージは取り消せません
- **セッション管理**: プロジェクトごとに履歴を一覧表示、分岐やフォークにも対応。
- **下書きの復元**: 未送信のテキストを会話または新規セッションのワークスペースごとに保存し、ブラウザストレージが利用可能な場合は、同じタブでの「戻る」「進む」や再読み込み後に復元します(最大 50 件)。画像と添付ファイルはメモリ内にのみ保持されます。
- **ライブタスク&サブエージェント**: Todo リストと稼働中サブエージェントの進捗を折りたたみパネルでリアルタイム表示。
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ A clean, modern web UI for the [oh-my-pi (omp)](https://github.com/can1357/oh-my
- [omp](https://github.com/can1357/oh-my-pi) installed and available on your `PATH` (or specified via `OMP_WEB_OMP_BIN`)
- Node.js `>= 22.19.0`

The **Steer** action on a queued follow-up requires an omp runtime with the `promote_queued_message` RPC command (not available in omp 18.1.16). Older runtimes report an error and leave the message queued as a follow-up; ompweb does not send a duplicate steering message.

Queue **Delete** and **Edit** additionally require `remove_queued_message`. Deletion is confirmed by OMP before the chip disappears; editing recalls text only after cancellation succeeds. Unsupported runtimes or messages that are no longer pending leave the queue unchanged and display a notice.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Quick Start

**Run directly without installing:**
Expand Down Expand Up @@ -176,7 +180,7 @@ host (KDE Plasma, and most Wayland/X11 desktops).
## Features

- **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.
- **Queue Deletion Confirmation**: Preview and confirm before cancelling queued follow-ups or steered messages in OMP. Requires native `remove_queued_message` support; already-delivered messages cannot be recalled.
- **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.
Expand Down
6 changes: 5 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
- 已安装 [omp](https://github.com/can1357/oh-my-pi) 且在 `PATH` 中(或通过 `OMP_WEB_OMP_BIN` 指定路径)
- Node.js `>= 22.19.0`

将排队的后续消息转为 **Steer** 需要 omp 运行时支持 `promote_queued_message` RPC 命令(omp 18.1.16 尚不支持)。旧版运行时会返回错误,消息仍作为后续消息排队;ompweb 不会重复发送一条引导消息。

队列中的 **Delete** 和 **Edit** 还需要支持 `remove_queued_message`。只有 OMP 确认取消后才会移除消息;编辑时也必须先取消成功,再将文本恢复到输入框。运行时不支持该命令或消息已不在队列中时,界面保留队列并显示提示。

## 快速开始

**免安装直接运行:**
Expand Down Expand Up @@ -58,7 +62,7 @@ ompweb --no-open # 不自动打开浏览器
## 功能特性

- **实时对话**:与本地 `omp` 智能体进行低延迟流式交互。
- **队列删除确认**:从队列面板移除后续消息或引导消息前,先预览内容并确认。此操作不会取消 OMP 内部已排队消息的发送
- **队列删除确认**:取消 OMP 中排队的后续消息或引导消息前,先预览内容并确认。需要原生支持 `remove_queued_message`;已发送的消息无法撤回
- **会话管理**:按项目浏览历史会话,支持会话分叉与分支回溯。
- **草稿恢复**:未发送的文本按会话或新会话的工作区分别保存;浏览器存储可用时,在同一标签页后退、前进或重新加载后可恢复(最多 50 份草稿)。图片和文件附件仅保留在内存中。
- **实时任务与子智能体**:可折叠面板实时展示任务清单(todo)与子智能体进度,并支持查看完整转录。
Expand Down
6 changes: 5 additions & 1 deletion components/ChatInput-banners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,30 @@ export function QueuedActionButton({
onClick,
title,
accent = false,
disabled = false,
children,
}: {
onClick: () => void;
title: string;
accent?: boolean;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
disabled={disabled}
style={{
flexShrink: 0,
padding: "4px 8px", minHeight: 24,
border: "none",
borderRadius: 6,
background: "transparent",
color: accent ? "var(--accent)" : "var(--text-dim)",
cursor: "pointer",
cursor: disabled ? "wait" : "pointer",
opacity: disabled ? 0.5 : 1,
fontSize: 11,
fontWeight: accent ? 600 : 400,
transition: "background var(--dur-fast) var(--ease-out-warm), color var(--dur-fast) var(--ease-out-warm)",
Expand Down
111 changes: 111 additions & 0 deletions components/ChatInput.navigation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,114 @@ test("attachment-only drafts stay protected across live draft-key changes and re
delete globalThis.document;
}
});

for (const navigation of ["draft-key change", "unmount"]) {
test(`queued Edit recovers the old draft after ${navigation} while cancellation is pending`, async () => {
installBrowser();
const oldKey = `edit-old-${navigation}`;
const newKey = `edit-new-${navigation}`;
const ref = React.createRef();
let release;
const cancellation = new Promise((resolve) => { release = resolve; });
const queuedMessages = { steering: [], followUp: ["queued question"] };
const render = (draftKey) => React.createElement(ChatInput, {
ref, draftKey, queuedMessages, isStreaming: true,
onSend() {}, onAbort() {}, onRemoveQueuedMessage: () => cancellation,
});
let renderer;
const type = (value) => renderer.root.findByType("textarea").props.onChange({ target: { value, selectionStart: value.length } });
try {
await act(() => { renderer = TestRenderer.create(render(oldKey)); });
await act(() => type("original draft"));
await act(async () => {
ref.current.addFiles([new File(["keep this attachment"], "notes.txt", { type: "text/plain" })]);
});
const originalFiles = getDraft(oldKey).files;
await act(() => renderer.root.findAllByType("button").find((button) => button.props.children === "Edit").props.onClick());
await act(() => type("draft updated while waiting"));
assert.equal(getDraft(oldKey).value, "draft updated while waiting", "Edit must wait for cancellation acknowledgement");

if (navigation === "unmount") {
await act(() => renderer.unmount());
await act(() => { renderer = TestRenderer.create(render(newKey)); });
} else {
await act(() => renderer.update(render(newKey)));
}
await act(() => type("new session draft"));
await act(async () => {
release(true);
await cancellation;
});
assert.equal(renderer.root.findByType("textarea").props.value, "new session draft");
assert.equal(getDraft(newKey).value, "new session draft");
assert.equal(getDraft(oldKey).value, "queued question\n\ndraft updated while waiting");
assert.deepEqual(getDraft(oldKey).files, originalFiles);

if (navigation === "unmount") {
await act(() => renderer.unmount());
await act(() => { renderer = TestRenderer.create(render(oldKey)); });
} else {
await act(() => renderer.update(render(oldKey)));
}
assert.equal(renderer.root.findByType("textarea").props.value, "queued question\n\ndraft updated while waiting");
assert.equal(getDraft(newKey).value, "new session draft", "returning to the old chat preserves the new chat's draft");
} finally {
await act(() => renderer?.unmount());
clearDraft(oldKey);
clearDraft(newKey);
delete globalThis.window;
delete globalThis.document;
}
});
}

for (const navigation of ["original composer", "same-key remount"]) {
test(`queued Edit restores ${navigation} without losing batched typing or duplicating recalled text`, async () => {
installBrowser();
const draftKey = `edit-recovery-${navigation}`;
const ref = React.createRef();
let release;
const cancellation = new Promise((resolve) => { release = resolve; });
const render = () => React.createElement(ChatInput, {
ref, draftKey, queuedMessages: { steering: [], followUp: ["queued question"] }, isStreaming: true,
onSend() {}, onAbort() {}, onRemoveQueuedMessage: () => cancellation,
});
let renderer;
const textarea = () => renderer.root.findByType("textarea");
const type = (value) => textarea().props.onChange({ target: { value, selectionStart: value.length } });
try {
await act(() => { renderer = TestRenderer.create(render()); });
await act(() => type("original draft"));
await act(async () => {
ref.current.addFiles([new File(["keep this attachment"], "notes.txt", { type: "text/plain" })]);
});
const originalFiles = getDraft(draftKey).files;
await act(() => renderer.root.findAllByType("button").find((button) => button.props.children === "Edit").props.onClick());
if (navigation === "same-key remount") {
await act(() => renderer.unmount());
await act(() => { renderer = TestRenderer.create(render()); });
}
await act(async () => {
type("typed while waiting");
ref.current.insertText("and queued insertion");
assert.equal(getDraft(draftKey).value, "original draft", "pending React updates have not been persisted yet");
release(true);
await cancellation;
});
const recovered = "queued question\n\ntyped while waiting and queued insertion";
assert.equal(textarea().props.value, recovered);
assert.equal(getDraft(draftKey).value, recovered);
assert.deepEqual(getDraft(draftKey).files, originalFiles);

await act(() => type(`${textarea().props.value}\nnext user edit`));
assert.equal(textarea().props.value, `${recovered}\nnext user edit`);
assert.equal(getDraft(draftKey).value, `${recovered}\nnext user edit`);
assert.deepEqual(getDraft(draftKey).files, originalFiles);
} finally {
await act(() => renderer?.unmount());
clearDraft(draftKey);
delete globalThis.window;
delete globalThis.document;
}
});
}
Loading