From 4961a6f4b227e3d5903dbc2834b8881b38e3a69c Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 13 Nov 2026 21:44:53 +0000 Subject: [PATCH 1/3] docs(sidebar): design for the Needs attention section and request_attention tool --- .../2026-11-13-attention-sidebar-design.md | 98 +++++++++++++++++++ lib/types.ts | 2 + 2 files changed, 100 insertions(+) create mode 100644 docs/specs/2026-11-13-attention-sidebar-design.md diff --git a/docs/specs/2026-11-13-attention-sidebar-design.md b/docs/specs/2026-11-13-attention-sidebar-design.md new file mode 100644 index 000000000..26c51e337 --- /dev/null +++ b/docs/specs/2026-11-13-attention-sidebar-design.md @@ -0,0 +1,98 @@ +# Needs attention: sidebar section and agent attention signal + +## Intent + +A user running many sessions across hosts must see at a glance which ones are +blocked on them. Today a blocked session is marked inside its workspace (#461, +the "Waiting for your input" icon), but finding it means scanning every +expanded workspace. A **Needs attention** section above Workspaces lists exactly +those sessions, and nothing else. + +## What counts as needing attention + +A session needs attention while either holds: + +1. **An open blocking dialog** waits for the user: `ask`, `confirm`, `select`, + `input`, `editor` or `open_url` (`BLOCKING_UI_METHODS`). This covers tool + permission prompts ("Allow tool: …", unless auto-approved) and omp-web's own + confirmations (`confirmFromServer`, used by `send_to_session` and + `new_session`). The server already reports this per running session as + `RunningRpcSession.awaitingInput` (#461). +2. **The agent said it waits for the user.** The model calls the + `request_attention` tool when it ends its turn waiting for an instruction, + an answer, a decision or an approval. The tool persists a marker entry. The + session needs attention while that marker is newer than the session's newest + user message **and** the session is not running. The next user message + clears it, so the agent never has to withdraw it. + +The second signal lives in the session file, so it survives idle reaping (the +omp process is gone), web server restarts and daemon restarts. + +## Agent tool: `request_attention` + +- Lives in a bundled omp extension, `lib/omp/attention-extension.mjs`, loaded + with `--extension` beside the session-edit extension on every spawn + (`lib/hosts/local-host.ts`), so every omp-web session on every host gets it. + Terminal-only omp sessions do not have it. +- Parameters: `{ reason: string }`, a short sentence on what the agent needs. +- Effect: `pi.appendEntry("omp-web-attention", { reason })`, persisted as + `{"type":"custom","customType":"omp-web-attention","data":{"reason":…},…}`. + It does not change the session title. +- The tool description tells the model when to call it: at the end of a turn + that waits on the user. Not for progress updates, not mid-task. +- The compiled binary ships the file (`scripts/build-binary.ts` copy list). + +## Detection on disk + +`scanTailMessage` (`lib/omp/session-files.ts`) already walks the tail window +backwards to the newest message entry. It continues backwards until the newest +`role: "user"` message; an `omp-web-attention` custom entry found before that +sets `attention: { reason, at }` (`at` is the entry's timestamp). Only the +32 KiB tail window is scanned; a marker followed by more than 32 KiB of later +entries without a user message is not found (accepted: the agent calls the tool +as its last act). + +`SessionInfo.attention?: { reason: string; at: string }` carries it to the +client through the existing per-host session lists. The session list refreshes +on `agent_end` already, so a marker written during a run appears when the run +ends. + +## Sidebar + +- A **Needs attention** section above the Workspaces header. Hidden while no + session needs attention. +- The set: `awaitingInput` sessions from the `running` channel, plus sessions + whose `attention` is set and that are not running. +- Same structure, rows and badges as Workspaces: host group (when host groups + show), then workspace row, then session rows, rendered with the same + components. Sessions stay in Workspaces too. +- Always alphabetical: hosts by name, workspaces by label, sessions by display + title. The user's sort mode, pins and manual order do not apply. +- No pin control, no drag or move, no sort/filter/add buttons in the section + header. Search and the running filter do not filter it. +- Workspace rows in the section start expanded and show every attention + session (no "show more"). Collapsing a host or workspace there does not + affect Workspaces. +- A session row's tooltip carries the reason when the attention comes from + the tool. +- The section scrolls on its own and takes at most about 40% of the sidebar + height, so Workspaces stays reachable. + +## Approaches considered + +1. **Title emoji convention** (`⏸️`, `❓`, `🚧` from a personal + `session_phase` extension). Rejected: omp-web would hardcode one user's + convention. +2. **Live extension status** (`ctx.ui.setStatus`). Rejected: in-memory only; + the session would leave the section when its idle process is reaped while + still waiting. +3. **Bundled tool writing a session entry** (chosen). Works for every agent of + every omp-web session, persists, needs no omp change. +4. **Native omp attention API**. Cleanest, but needs an upstream oh-my-pi + change; can replace 3 later. + +## Non-goals + +- Notifications: the existing `input` notification is unchanged. +- Inferring attention from free text ("Should I…?"). +- Showing attention for terminal-only omp sessions. diff --git a/lib/types.ts b/lib/types.ts index c1e728eb5..b9c8a637f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -449,6 +449,8 @@ export interface SessionInfo { /** Registered logical identity; physical owner/cwd/projectRoot remain unchanged. */ poolId?: string; projectId?: string; + /** The agent asked for the user (`request_attention`) after the newest user message. */ + attention?: { reason: string; at: string }; } /** Metadata for a gzip-compressed session in OMP's archive tree. */ From 7330f42361d9e9ee5c70a097c96a860a0158774a Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Fri, 13 Nov 2026 22:00:05 +0000 Subject: [PATCH 2/3] feat(sidebar): Needs attention section and request_attention tool Sessions waiting on the user (an open blocking dialog, or the agent's request_attention marker newer than the last user message while idle) appear in a Needs attention section above Workspaces, grouped by host and workspace and sorted alphabetically. ompweb's omp build (runner/) gives every session it starts the request_attention tool. --- AGENTS.md | 1 + CHANGELOG.md | 1 + components/SessionSidebar-helpers.test.mjs | 59 ++++++ components/SessionSidebar-helpers.ts | 80 +++++++- components/SessionSidebar-rows.tsx | 16 +- .../SessionSidebar.presentation.test.mjs | 50 +++++ components/SessionSidebar.tsx | 185 +++++++++++++----- .../2026-11-13-attention-sidebar-design.md | 20 +- lib/hosts/hub.test.mjs | 3 +- lib/hosts/hub.ts | 11 +- lib/i18n/locales/en.json | 2 + lib/i18n/locales/ja.json | 2 + lib/i18n/locales/zh-CN.json | 2 + lib/i18n/locales/zh-TW.json | 2 + lib/omp/session-files.ts | 49 ++++- lib/session-reader.test.mjs | 43 ++++ lib/session-reader.ts | 1 + runner/src/attention.ts | 53 +++++ runner/src/session.ts | 15 +- runner/test/attention.test.ts | 44 +++++ 20 files changed, 564 insertions(+), 75 deletions(-) create mode 100644 runner/src/attention.ts create mode 100644 runner/test/attention.test.ts diff --git a/AGENTS.md b/AGENTS.md index fe89e94fa..35decc621 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1457,6 +1457,7 @@ during the wait. - Compaction state has one owner at a time. A manual compaction this tab sends holds it until its command settles (`localCompactionsRef`): omp may not have the command yet, so an idle snapshot cannot release it; when it settles during an auto-compaction (`autoCompactingRef`), the control stays with the auto-compaction. Native snapshots (state polls, catch-up, attach, mount) apply only if no compaction started or ended since they were requested (`compactionGenRef`); `setCompacting` keeps `isCompactingRef` and the rendered state together. - `useAgentSession` still treats its `session` channel as primary for chat events, but while a run is active it periodically calls `GET /api/agent/[id]` and also reconciles on `visibilitychange`/`online`. This fixes missed `agent_end` events from background tabs or half-open connections. - Prompt runs use a monotonic run id; late SSE or slow reconciliation responses from an old run must be ignored so they cannot resurrect stale streaming bubbles. +- **Needs attention** (`docs/specs/2026-11-13-attention-sidebar-design.md`): a session needs the user while it has an open blocking dialog (`RunningRpcSession.awaitingInput`) or, when not running, while its agent's `request_attention` marker is newer than its newest user message. The tool is part of ompweb's omp build: `runner/src/attention.ts`, which `createHostedAgentSession` (`runner/src/session.ts`) hands every top-level session as an SDK custom tool (`customTools`; `loadMode: "essential"`; omp keeps SDK custom tools active under `--tools ` and `--no-tools`, and subagents do not inherit them). It only appends `{"type":"custom","customType":"omp-web-attention","data":{"reason"}}`; the entry type and the 300-character reason cap are a literal on each side (`runner/src/attention.ts`, `ATTENTION_ENTRY_TYPE`/`ATTENTION_REASON_MAX` in `lib/omp/session-files.ts`). `scanTailMessage` (`lib/omp/session-files.ts`) walks the 32 KiB tail back to the newest user message and reports the newest marker before it as `SessionInfo.attention`; the next user message clears it, so nothing ever deletes a marker. `sessionEntry` in `lib/hosts/hub.ts` must keep passing `attention` through from a daemon's list. ### Composer-attached panels (`components/ComposerPanels.tsx`) - The live todo plan (`TodoList`) and the subagent roster live **pinned above diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fc2456a..7805a8ec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - **Traditional Chinese (`zh-TW`) interface language.** Select Traditional Chinese (繁體中文) in the top-bar language menu. On compact layouts, open **More controls** first. Alternatively, let omp-web pick it automatically when the browser language is `zh-TW`, `zh-HK`, `zh-MO`, or `zh-Hant`. Localized Web Push notifications and a Traditional Chinese README (`README.zh-TW.md`) are included. - **Several web apps can share one host daemon and the same live sessions.** Link additional servers with `ompweb host login --add --url ` (token from stdin). Prompts, tool progress, results and dialogs stay synchronized, and restarting one web app leaves the other connected to the same omp process. Link protocol v7 moves session startup, idle cleanup and interrupted-run recovery into the host; older links remain supported without sharing their processes. Schedule the first daemon upgrade while its host is quiescent. - **Sidebar marks sessions with an unsent draft.** A session whose composer still holds text or attachments shows a small pencil beside its title, so a half-written message is easy to find again. The mark follows this browser tab's drafts. +- **Needs attention in the sidebar.** A **Needs attention** section above Workspaces lists every session that is waiting for you, on every host, with the same host, workspace and session rows as Workspaces, sorted by name. A session appears there while it has an open question, confirmation or permission prompt, or after its agent called the new `request_attention` tool to say it ends its turn waiting for your instruction, answer, decision or approval; hover the session for the agent's reason. Your next message to the session clears it. The section is hidden while nothing needs attention. The tool is part of ompweb's omp build, so every session omp-web starts has it; sessions of a stock omp terminal do not. - **Sidebar marks sessions waiting for your input.** A session with an open question, confirmation or other blocking dialog shows a small question-bubble icon beside its title, next to the unsent-draft pencil. The mark clears as soon as the dialog is answered, cancelled or expires, in every open browser. - **Identify untagged builds from the logo.** Clicking or tapping the ompweb logo still reveals the version; builds made from an untagged Git commit also show its full hash and commit date (`YYYY-MM-DD`). Builds made on a tag keep the version-only display. The metadata is embedded in the browser bundle, so installed packages and binaries do not need Git. - **Workspace file APIs run on the owning host.** Directory listings, file metadata and previews, downloads, file search and referenced shell output go through that machine's daemon, including This machine. File downloads stream in bounded chunks rather than being limited by the host link's per-result size. A stopped or older daemon reports unavailable or unsupported instead of reading a path on the web server. diff --git a/components/SessionSidebar-helpers.test.mjs b/components/SessionSidebar-helpers.test.mjs index 2e9af6752..8790b05c8 100644 --- a/components/SessionSidebar-helpers.test.mjs +++ b/components/SessionSidebar-helpers.test.mjs @@ -65,3 +65,62 @@ test("sessions on different workers share one logical project while a same-path ["pool:workers:other", { running: 0, unread: 0, exited: 0 }], ]); }); + +test("attention: an open dialog counts even while running; the agent's marker only while the session is not running", async () => { + const { attentionSessionIds } = await import("./SessionSidebar-helpers.ts"); + const marker = { reason: "Pick one", at: "2026-11-13T10:00:00.000Z" }; + const sessions = [ + { id: "marker-idle", attention: marker }, + { id: "marker-running", attention: marker }, + { id: "dialog-running" }, + { id: "plain" }, + ]; + const running = new Set(["marker-running", "dialog-running", "new"]); + assert.deepEqual([...attentionSessionIds(sessions, running, new Set(["dialog-running"]))].sort(), ["dialog-running", "marker-idle"]); + // A dialog on a session the list does not have yet counts too (its row is a live placeholder). + assert.deepEqual([...attentionSessionIds([], running, new Set(["new"]))], ["new"]); +}); + +test("attention entries: only attention sessions, workspaces by label and sessions by title A→Z, workspaces without one dropped", async () => { + const { attentionProjectEntries } = await import("./SessionSidebar-helpers.ts"); + const session = (id, cwd, extra) => ({ id, cwd, path: `${cwd}/${id}.jsonl`, created: "", modified: "2026-11-13T10:00:00.000Z", messageCount: 1, firstMessage: "", ...extra }); + // The manual order (sortOrder) and pins do not apply. + const projects = [ + { path: "/srv/zeta", sortOrder: 0 }, + { path: "/srv/quiet", sortOrder: 1 }, + { path: "/srv/Beta", sortOrder: 2, pinOrder: 0 }, + { path: "/srv/x", alias: "alpha", sortOrder: 3 }, + ]; + const sessions = [ + session("z1", "/srv/zeta", { name: "fix tests" }), + session("q1", "/srv/quiet", { name: "idle" }), + session("b2", "/srv/Beta", { name: "Zebra" }), + session("b1", "/srv/Beta", { firstMessage: "apple pie" }), + session("b3", "/srv/Beta", { name: "item 10", parentSessionId: "b2" }), + session("b4", "/srv/Beta", { name: "Item 9" }), + session("bx", "/srv/Beta", { name: "Aardvark" }), + session("x1", "/srv/x", { name: "deploy" }), + ]; + const byProject = groupSessionsByProject(projects, sessions); + const entries = attentionProjectEntries(projects, byProject, new Set(["z1", "b2", "b1", "b3", "b4", "x1"])); + assert.deepEqual(entries.map(({ project, hostId, tree }) => [project.path, hostId, tree.map((node) => node.session.id)]), [ + ["/srv/x", "local", ["x1"]], + ["/srv/Beta", "local", ["b1", "b4", "b3", "b2"]], + ["/srv/zeta", "local", ["z1"]], + ]); + assert.ok(entries.every(({ tree }) => tree.every((node) => node.children.length === 0)), "a fork does not nest under its parent"); + assert.deepEqual(attentionProjectEntries(projects, byProject, new Set()), []); +}); + +test("attention host groups: only hosts with an entry, A→Z by shown name with this machine not pinned first", async () => { + const { attentionHostGroups } = await import("./SessionSidebar-helpers.ts"); + const hostList = [{ id: "local", label: "" }, { id: "h1", label: "build box" }, { id: "h2", label: "Zed" }, { id: "h3", label: "Empty" }]; + const names = { local: "This machine", h1: "build box", h2: "Zed", h3: "Empty", "pool:cluster": "Cluster" }; + const nameOf = (host) => names[host.id]; + const entries = [{ hostId: "h2", name: "z" }, { hostId: "local", name: "l" }, { hostId: "h1", name: "b2" }, { hostId: "h1", name: "b1" }]; + const shape = (groups) => groups.map(({ host, projects }) => [host.id, projects.map((entry) => entry.name)]); + assert.deepEqual(shape(attentionHostGroups(entries, hostList, undefined, nameOf)), [["h1", ["b2", "b1"]], ["local", ["l"]], ["h2", ["z"]]]); + // A cluster member's workspaces list under the cluster, as in Workspaces. + const pools = [{ id: "cluster", members: [{ hostId: "h2" }] }]; + assert.deepEqual(shape(attentionHostGroups(entries, [...hostList, { id: "pool:cluster", label: "Cluster" }], pools, nameOf)), [["h1", ["b2", "b1"]], ["pool:cluster", ["z"]], ["local", ["l"]]]); +}); diff --git a/components/SessionSidebar-helpers.ts b/components/SessionSidebar-helpers.ts index 2c1e91bd2..fbe8c2188 100644 --- a/components/SessionSidebar-helpers.ts +++ b/components/SessionSidebar-helpers.ts @@ -1,6 +1,6 @@ import { isLinkUp, type HostStatus } from "@/lib/hosts/host"; -import type { SessionInfo } from "@/lib/types"; -import { LOCAL_HOST_ID, type ProjectSortMode } from "@/lib/project-ordering"; +import type { ManagedProject, SessionInfo } from "@/lib/types"; +import { LOCAL_HOST_ID, groupProjectsByHost, projectIdentity, projectTargetId, type PoolMembers, type ProjectSortMode } from "@/lib/project-ordering"; import { comparableProjectPath } from "@/lib/comparable-path"; interface WorktreeEntry { path: string; @@ -140,6 +140,21 @@ function projectLabel(projectPath: string): string { const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); return index >= 0 ? trimmed.slice(index + 1) : trimmed; } + +/** The name a workspace row shows: its alias, else its name, else its folder. */ +function workspaceLabel(project: ManagedProject): string { + return project.alias ?? project.name ?? projectLabel(project.path); +} + +/** The title a session row shows. */ +function sessionTitle(session: SessionInfo): string { + return session.name || session.firstMessage.slice(0, 50) || session.id.slice(0, 12); +} + +/** A→Z as the user reads it: case-insensitive, digits numerically. */ +function compareLabels(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }); +} function formatRelativeTime(value: string, _locale: string, now: number): string | null { const timestamp = new Date(value).getTime(); if (!Number.isFinite(timestamp)) return null; @@ -242,6 +257,62 @@ function hostsCameOnline( const wasUp = new Set(prev.filter((host) => isLinkUp(host.status)).map((host) => host.id)); return next.some((host) => isLinkUp(host.status) && !wasUp.has(host.id)); } + +/** Sessions blocked on the user: an open blocking dialog (`awaitingInputIds`, + * running by definition), or the agent's `request_attention` marker on a + * session that is not running (a run in progress is not waiting). */ +function attentionSessionIds( + sessions: readonly SessionInfo[], + runningSessionIds: ReadonlySet, + awaitingInputIds: ReadonlySet, +): Set { + const ids = new Set(awaitingInputIds); + for (const session of sessions) { + if (session.attention && !runningSessionIds.has(session.id)) ids.add(session.id); + } + return ids; +} + +interface AttentionEntry { + project: ManagedProject; + /** The host group the workspace lists under (`projectTargetId`). */ + hostId: string; + tree: SessionTreeNode[]; +} + +/** The Needs attention section's workspaces: each with only its attention + * sessions, workspaces without one dropped. Workspaces A→Z by label and + * sessions A→Z by title, whatever the Workspaces sort. Every session is its + * own root (forks do not nest), so the order holds. */ +function attentionProjectEntries( + projects: readonly ManagedProject[], + sessionsByProject: ReadonlyMap, + ids: ReadonlySet, +): AttentionEntry[] { + if (ids.size === 0) return []; + const entries: AttentionEntry[] = []; + for (const project of projects) { + const sessions = (sessionsByProject.get(projectIdentity(project)) ?? []).filter((session) => ids.has(session.id)); + if (sessions.length === 0) continue; + sessions.sort((a, b) => compareLabels(sessionTitle(a), sessionTitle(b))); + entries.push({ project, hostId: projectTargetId(project), tree: sessions.map((session) => ({ session, children: [] })) }); + } + return entries.sort((a, b) => compareLabels(workspaceLabel(a.project), workspaceLabel(b.project))); +} + +/** The Needs attention section's host groups: only hosts with an entry, A→Z by + * the name the group shows (this machine is not pinned first). Entries keep + * their order inside a group. */ +function attentionHostGroups( + entries: T[], + hosts: { id: string; label: string }[], + pools: PoolMembers | undefined, + nameOf: (host: { id: string; label: string }) => string, +): { host: { id: string; label: string }; projects: T[] }[] { + return groupProjectsByHost(entries, hosts, pools) + .filter((group) => group.projects.length > 0) + .sort((a, b) => compareLabels(nameOf(a.host), nameOf(b.host))); +} const MAX_PROJECT_SESSIONS = 5; export { EMPTY_PROJECT_SET, @@ -250,6 +321,9 @@ export { INITIAL_RESTORE_RETRY_MS, MAX_PROJECT_SESSIONS, UNREAD_SESSIONS_STORAGE_KEY, + attentionHostGroups, + attentionProjectEntries, + attentionSessionIds, buildSessionTree, displayCwd, formatRelativeTime, @@ -263,6 +337,8 @@ export { saveExpandedProjects, saveProjectSortMode, saveUnreadSessionIds, + sessionTitle, + workspaceLabel, type LivePlace, type SessionTreeNode, type WorktreeContext, diff --git a/components/SessionSidebar-rows.tsx b/components/SessionSidebar-rows.tsx index 6b14c3ebd..aa18fa6ea 100644 --- a/components/SessionSidebar-rows.tsx +++ b/components/SessionSidebar-rows.tsx @@ -14,7 +14,8 @@ import { hasDraft, subscribeDrafts } from "@/lib/draft-store"; import { MAX_PROJECT_SESSIONS, formatRelativeTime, - projectLabel, + sessionTitle, + workspaceLabel, type SessionTreeNode, } from "./SessionSidebar-helpers"; import { @@ -117,6 +118,8 @@ interface ProjectRowProps { /** A sort is active: show the pin toggle; only pinned rows reorder by hand. */ pinnable: boolean; onTogglePin: (project: ManagedProject) => Promise; + /** False where the list has a fixed order (Needs attention): no drag, no Move up/down. */ + reorderable?: boolean; isDragTarget: boolean; removeBusy: boolean; onSelectSession: (s: SessionInfo) => void; @@ -156,6 +159,7 @@ function ProjectRow({ onMoveProject, pinnable, onTogglePin, + reorderable = true, isDragTarget, removeBusy, onSelectSession, @@ -196,9 +200,9 @@ function ProjectRow({ // The sidebar already reports failed presentation writes in a toast. void onUpdatePresentation(project, { alias }).catch(() => {}); }, [aliasValue, project, onUpdatePresentation]); - const label = project.alias ?? project.name ?? projectLabel(project.path); + const label = workspaceLabel(project); const pinned = pinnable && project.pinOrder !== undefined; - const canReorder = !pinnable || pinned; + const canReorder = reorderable && (!pinnable || pinned); const hasActivity = Boolean(activity && (activity.running > 0 || activity.unread > 0 || activity.exited > 0)); const visibleRoots = hiddenCount > 0 && !showAllSessions ? tree.slice(0, MAX_PROJECT_SESSIONS) @@ -743,7 +747,9 @@ const SessionItem = memo(function SessionItem({ const inputRef = useRef(null); const contentButtonRef = useRef(null); const menuButtonRef = useRef(null); - const title = session.name || session.firstMessage.slice(0, 50) || session.id.slice(0, 12); + const title = sessionTitle(session); + // The agent's request_attention reason; a running session is not waiting on it. + const attentionReason = isRunning ? undefined : session.attention?.reason; // A session's composer draft is keyed by its id (ChatWindow's `draftKey`). const hasUnsentDraft = useSyncExternalStore(subscribeDrafts, () => hasDraft(session.id), () => false); const relativeTime = formatRelativeTime(session.modified, locale, relativeTimeNow); @@ -893,7 +899,7 @@ const SessionItem = memo(function SessionItem({ <> {depth > 0 &&