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..d97323b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ 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, and so does the agent carrying on with more tool calls. 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. + **Before upgrading:** sessions on a linked host get the `request_attention` tool once that host's ompweb (and its omp runner) is updated; until then only open dialogs mark them. - **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/AppShell.tsx b/components/AppShell.tsx index d85afcddc..13b371d2e 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -11,7 +11,7 @@ import { isMacPlatform, navigateShortcutHint, NAVIGATION_HISTORY_MAX_ENTRIES } f import { useMobileSidebarGestures } from "@/hooks/useMobileSidebarGestures"; import { SessionSidebar } from "./SessionSidebar"; import type { SearchMatchTarget } from "./SessionSearchMatches"; -import type { WorktreeContext } from "./SessionSidebar-helpers"; +import { workspaceLabel, type WorktreeContext } from "./SessionSidebar-helpers"; import { ToastProvider } from "./ui/toast"; import { toast } from "./ui/toast"; import { notificationHistory, useUnreadNotificationCount } from "@/lib/notification-history"; @@ -2604,7 +2604,7 @@ export function AppShell({ appName }: { appName: string }) { )} {hostProjects.map((project) => { - const name = project.alias ?? project.name ?? projectLabel(project.path); + const name = workspaceLabel(project); // A member's pinned project: its option names the node. const node = !project.poolId && projectTargetId(project, pools) !== (project.hostId ?? LOCAL_HOST_ID) ? hosts?.find((host) => host.id === (project.hostId ?? LOCAL_HOST_ID)) : undefined; return ( 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 &&