diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a73279e0..ae27f5858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,15 @@ Compatibility is documented in release notes, not encoded in the version string. `config: {control_system.type: ...}`, so a connector can be chosen from the command line with `--set connector=epics`. Giving both spellings on one command line is an error rather than a silent last-one-wins. +- You can see what the agent did to your workspace. A tile the agent focuses + or rearranges glows briefly, and its rail tab flashes with it, so a layout + that changes under you is never unattributed — your own clicks stay quiet. + An activity strip names each action in plain words ("agent opened + WORKSPACE"), and its history popover holds the recent ones for when you + looked away. Panels that changed while you were elsewhere keep a badge + across a reload until you visit them. Every agent tool that changes + something — queue and plan authoring, logbook entries, Phoebus drives, + python execution, lattice and window management — reports itself there. ### Changed diff --git a/eslint.config.js b/eslint.config.js index c28b77550..bc7f82d17 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -54,6 +54,19 @@ export default [ }, }, + // (4c) max-lines ratchet on panel-manager.js alone. It sits right at the 450 + // cap above while three in-flight tasks (wire-glow-call-sites, + // strip-verbs-and-labels, badge-ack-restore) all land agent-visibility code + // in it. Splitting the module mid-flight would be more destabilizing than the + // raised ceiling; the split is earmarked for the polish pass, and this block + // goes away with it. Scoped to the one file so nothing else drifts upward. + { + files: ['src/osprey/interfaces/web_terminal/static/js/panel-manager.js'], + rules: { + 'max-lines': ['error', { max: 550, skipComments: true, skipBlankLines: true }], + }, + }, + // (5) Root config files: node globals. { files: ['vitest.config.js', 'eslint.config.js'], diff --git a/pyproject.toml b/pyproject.toml index 653a662fd..4a5b813ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -497,6 +497,7 @@ markers = [ "e2e_services: E2E tests requiring local MCP service infrastructure (PostgreSQL, AccelPapers, etc.)", "dockerbuild: E2E tests that run a real docker build (skipped when docker is unavailable)", "real_workspace_watcher: Web-terminal app test that needs a live filesystem observer — opts out of the conftest stub that keeps broadcaster assertions deterministic", + "real_http_posters: MCP-server test of the HTTP posters themselves — opts out of the conftest stub that keeps notify_* POSTs from reaching a live web terminal", ] # Ruff configuration for modern Python linting diff --git a/src/osprey/interfaces/artifacts/logbook.py b/src/osprey/interfaces/artifacts/logbook.py index d4df1fc5c..1c7783a62 100644 --- a/src/osprey/interfaces/artifacts/logbook.py +++ b/src/osprey/interfaces/artifacts/logbook.py @@ -19,7 +19,6 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from osprey.mcp_server.http import notify_panel_focus from osprey.mcp_server.session import gather_session_metadata from osprey.models.tiers import VALID_TIERS from osprey.utils.workspace import resolve_shared_data_root @@ -576,12 +575,17 @@ async def submit(req: SubmitRequest): base_url = os.environ.get("ARIEL_WEB_URL", "/panel/ariel") url = f"{base_url}/#create?draft={draft_id}" - # Notify web terminal to switch to ARIEL panel (non-fatal) - try: - notify_panel_focus("ariel", url=url) - except Exception: - pass - + # No panel_focus broadcast here. Composing a logbook entry is a HUMAN + # gesture in the gallery, and notify_panel_focus is an agent-source, + # all-clients channel: it painted agent styling on every connected + # browser and yanked every operator's workspace to ARIEL because one + # person clicked Submit. Navigation is now sender-local — the gallery + # page posts `osprey:navigate` to its host window (see the submit + # success path in static/js/logbook.js and the host listener in + # web_terminal/static/js/app.js), so only the client that gestured + # moves, with no agent attribution. A standalone (non-embedded) + # gallery has no host to notify and keeps the returned URL as its + # only affordance. return SubmitResponse( draft_id=draft_id, url=url, diff --git a/src/osprey/interfaces/artifacts/static/js/logbook.js b/src/osprey/interfaces/artifacts/static/js/logbook.js index fdd546e46..09191d742 100644 --- a/src/osprey/interfaces/artifacts/static/js/logbook.js +++ b/src/osprey/interfaces/artifacts/static/js/logbook.js @@ -419,6 +419,43 @@ function showError(msg) { // ---- Submit ---- +/** + * The "Draft created" card that replaces the form body on a successful submit. + * + * Built as DOM rather than an interpolated HTML string for the same reason the + * artifact picker assigns its checkbox value as a property: the two server + * strings reach text and an href, and property assignment bypasses HTML + * parsing entirely, so neither can break out of its slot. + * + * @param {string} draftId + * @param {string} url + * @returns {HTMLElement} + */ +function buildSuccessCard(draftId, url) { + const card = document.createElement("div"); + card.style.cssText = "text-align:center; padding:var(--art-space-6); color:var(--color-success);"; + + const heading = document.createElement("div"); + heading.style.cssText = "font-size:var(--art-text-xl); margin-bottom:var(--art-space-2);"; + heading.textContent = "Draft created"; + + const meta = document.createElement("div"); + meta.style.cssText = "font-size:var(--art-text-sm); color:var(--text-secondary);"; + meta.appendChild(document.createTextNode(draftId)); + meta.appendChild(document.createElement("br")); + + const link = document.createElement("a"); + link.href = url; + link.target = "_blank"; + link.rel = "noopener"; + link.style.color = "var(--color-accent-light)"; + link.textContent = "Open in ARIEL"; + meta.appendChild(link); + + card.append(heading, meta); + return card; +} + async function submitLogbook() { clearError(); @@ -455,19 +492,27 @@ async function submitLogbook() { return; } const data = await resp.json(); - const body = document.getElementById("logbook-body"); - if (body) { - body.innerHTML = ` -
-
Draft created
-
- ${data.draft_id}
- Open in ARIEL -
-
- `; + + // Sender-local navigation. Submitting a draft is a HUMAN gesture, so only + // THIS client's workspace may move: we ask our host window to open ARIEL + // instead of letting the server broadcast a panel_focus, which was + // agent-source and all-clients (it painted agent styling on every + // connected browser and yanked every operator to ARIEL because one person + // clicked Submit). The host applies a plain activation — no agent + // attribution anywhere in this payload. Same-origin on both ends: we + // target our own origin, and the host re-checks event.origin. + // + // Guarded on actually being embedded: a standalone gallery has no host, + // and the success card's "Open in ARIEL" link below stays its affordance. + if (window.parent !== window) { + window.parent.postMessage( + { type: "osprey:navigate", panel: "ariel", url: data.url }, + window.location.origin, + ); } + + const body = document.getElementById("logbook-body"); + if (body) body.replaceChildren(buildSuccessCard(data.draft_id, data.url)); const actions = document.getElementById("logbook-actions"); if (actions) actions.innerHTML = ""; modal = null; diff --git a/src/osprey/interfaces/design_system/static/css/highlight.css b/src/osprey/interfaces/design_system/static/css/highlight.css index fc877b92d..4c44b8ec9 100644 --- a/src/osprey/interfaces/design_system/static/css/highlight.css +++ b/src/osprey/interfaces/design_system/static/css/highlight.css @@ -51,8 +51,26 @@ pointer-events: none; } +/* Reduced motion: still a flash, just a still one. + + `animation: none` cannot go here — it never fires `animationend`, so + flashElement's cleanup listener never runs and `.agent-flash` stays on the + element forever. Instead the same keyframes name is re-declared as a + constant ring and stepped through: nothing moves, the element holds a + static attribution ring, and the animation still ends and self-cleans. + The name must stay `agent-flash-glow` — flashElement ignores animationend + for any other name, which would strand the class just as `none` did. */ @media (prefers-reduced-motion: reduce) { + @keyframes agent-flash-glow { + 0%, + 100% { + box-shadow: 0 0 0 3px var(--accent-tint-25); + } + } + .agent-flash { - animation: none; + /* Held longer than the 900ms decay: a static ring has no motion to catch + the eye, so it needs the extra dwell to register. */ + animation: agent-flash-glow 1200ms steps(1, end); /* hygiene-allow-scale: reduced-motion hold */ } } diff --git a/src/osprey/interfaces/web_terminal/app.py b/src/osprey/interfaces/web_terminal/app.py index 46c4a4958..9a3d8283f 100644 --- a/src/osprey/interfaces/web_terminal/app.py +++ b/src/osprey/interfaces/web_terminal/app.py @@ -8,6 +8,7 @@ import asyncio import os +from collections import deque from contextlib import asynccontextmanager, suppress from pathlib import Path from typing import TYPE_CHECKING, NamedTuple @@ -27,6 +28,7 @@ from osprey.interfaces.web_terminal.ownership import OwnershipStoreError from osprey.interfaces.web_terminal.pty_manager import PtyRegistry from osprey.interfaces.web_terminal.routes import router +from osprey.interfaces.web_terminal.routes.agent_activity import ACTIVITY_RING_MAX from osprey.interfaces.web_terminal.url_prefix import apply_url_prefix, compute_url_prefix from osprey.profiles.web_panels import BUILTIN_PANELS, UNIVERSAL_PANELS @@ -700,6 +702,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) app.state.broadcaster = FileEventBroadcaster() app.state.active_panel = None + # Bounded history of agent-activity events. The SSE stream only reaches + # browsers that are already connected, so the ring is what a browser + # opened (or reloaded) mid-session reads to catch up on recent actions. + app.state.agent_activity_ring = deque(maxlen=ACTIVITY_RING_MAX) # Optional human-readable deployment name shown in the header so # otherwise-identical web terminals are distinguishable. The # ``OSPREY_WEB_APP_NAME`` environment variable takes precedence over diff --git a/src/osprey/interfaces/web_terminal/routes/agent_activity.py b/src/osprey/interfaces/web_terminal/routes/agent_activity.py index 400854e09..3fe9e41a5 100644 --- a/src/osprey/interfaces/web_terminal/routes/agent_activity.py +++ b/src/osprey/interfaces/web_terminal/routes/agent_activity.py @@ -9,18 +9,33 @@ The payload shape is a fixed interface contract shared with the frontend:: - request: {"tool": str, "target": {"kind": "panel"|"channel"|"run"|"artifact", + request: {"tool": str, "target": {"kind": "panel"|"channel"|"run" + |"artifact"|"config"|"ui", "panel"?: str, "detail"?: str}} broadcast: {"type": "agent_activity", "tool": ..., "target": {...}, "ts": ...} The server adds ``type`` and ``ts``; optional target fields are omitted from the broadcast when absent. Like the panel routes, this endpoint relies on the loopback baseline for access control — no additional auth. + +Every accepted event is also appended to a bounded in-memory history ring on +``app.state.agent_activity_ring`` before it is broadcast, so a browser that +connects (or reconnects) after the fact can still see what the agent has been +doing. SSE clients see no difference — the ring is a pure side channel. + +``GET /api/agent-activity/recent?limit=N`` reads that ring back, newest first:: + + response: {"events": [{"type": "agent_activity", "tool": ..., + "target": {...}, "ts": ...}, ...]} + +The events are the broadcast frames verbatim, so a browser can feed them +through the same handler it uses for the SSE stream. """ from __future__ import annotations import time +from itertools import islice from typing import Literal from fastapi import APIRouter, Request @@ -34,11 +49,15 @@ _MAX_NAME_LEN = 256 _MAX_DETAIL_LEN = 1024 +#: Size of the ``app.state.agent_activity_ring`` history buffer. A browser +#: replays at most this many recent events on connect; the oldest fall off. +ACTIVITY_RING_MAX = 50 + class AgentActivityTarget(BaseModel): """The surface the agent is acting on.""" - kind: Literal["panel", "channel", "run", "artifact"] + kind: Literal["panel", "channel", "run", "artifact", "config", "ui"] panel: str | None = Field(default=None, max_length=_MAX_NAME_LEN) detail: str | None = Field(default=None, max_length=_MAX_DETAIL_LEN) @@ -50,19 +69,63 @@ class AgentActivityRequest(BaseModel): target: AgentActivityTarget +def record_activity(request: Request, tool: str, target: dict) -> dict: + """Stamp an agent-activity frame, append it to the history ring, return it. + + The single place the frame shape is written, so every producer — this + module's POST route and the panel routes, which mirror agent-origin panel + commands that never pass through it — puts the same thing in the ring that + ``GET /api/agent-activity/recent`` serves and the SSE stream carries. + + Appending is not broadcasting: callers that also broadcast pass the + returned frame on, and callers recording history only just drop it. + + Args: + request: Incoming FastAPI request carrying ``app.state``. + tool: Name of the tool (or synthetic verb) the frame reports. + target: Already-serialised target, optional keys omitted. + + Returns: + The frame, whether or not the app carries a ring. + """ + event = {"type": "agent_activity", "tool": tool, "target": target, "ts": time.time()} + # Apps that mount these routers standalone (tests, embedders) need not + # carry a ring; history is then simply unavailable, and the caller's own + # work (a broadcast, or nothing) still runs. + ring = getattr(request.app.state, "agent_activity_ring", None) + if ring is not None: + ring.append(event) + return event + + @router.post("/api/agent-activity") async def post_agent_activity(body: AgentActivityRequest, request: Request): """Broadcast an agent-activity event to all connected browsers via SSE. Malformed bodies and unknown target kinds are rejected with 422 by the Pydantic model before this handler runs — nothing is broadcast for them. + The accepted event is recorded in the history ring first, so an event is + never broadcast without also being in the history a late browser reads. """ - request.app.state.broadcaster.broadcast( - { - "type": "agent_activity", - "tool": body.tool, - "target": body.target.model_dump(exclude_none=True), - "ts": time.time(), - } - ) + event = record_activity(request, body.tool, body.target.model_dump(exclude_none=True)) + request.app.state.broadcaster.broadcast(event) return {"ok": True} + + +@router.get("/api/agent-activity/recent") +async def get_recent_agent_activity(request: Request, limit: int = ACTIVITY_RING_MAX): + """Return the most recent agent-activity events, newest first. + + A browser that opens mid-session, or reconnects after its SSE stream + dropped, reads this to rebuild the recent history it never received live. + Each event is the broadcast frame verbatim, including the server ``ts``. + + ``limit`` is clamped into ``0..ACTIVITY_RING_MAX`` rather than rejected, so + a caller asking for more than the ring can hold gets everything it has. + Apps that mount this router without a ring report an empty history. + """ + ring = getattr(request.app.state, "agent_activity_ring", None) + if ring is None: + return {"events": []} + limit = max(0, min(limit, ACTIVITY_RING_MAX)) + return {"events": list(islice(reversed(ring), limit))} diff --git a/src/osprey/interfaces/web_terminal/routes/panels.py b/src/osprey/interfaces/web_terminal/routes/panels.py index 0979a67c3..0df49f78f 100644 --- a/src/osprey/interfaces/web_terminal/routes/panels.py +++ b/src/osprey/interfaces/web_terminal/routes/panels.py @@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, Response from pydantic import BaseModel +from osprey.interfaces.web_terminal.routes.agent_activity import record_activity from osprey.interfaces.web_terminal.url_prefix import apply_url_prefix, compute_url_prefix from osprey.profiles.web_panels import BUILTIN_PANEL_LABELS, BUILTIN_PANELS @@ -336,6 +337,33 @@ def _known_panel_ids(request: Request) -> set[str]: _TERMINAL_PANEL_ID = "terminal" +def _mirror_agent_panel_activity(request: Request, tool: str, panel: str) -> None: + """Record an agent-origin panel command in the agent-activity history ring. + + Panel commands reach the browser as their own SSE frames (``panel_focus``, + ``panel_visibility``, ...), never through ``POST /api/agent-activity``, so + without this they are invisible to a client that reads + ``GET /api/agent-activity/recent`` after connecting late. The row goes in + through that route's own ``record_activity``, so a consumer feeds it + through the handler it already uses for the SSE ``agent_activity`` stream. + + ``tool`` is synthetic: the panel routes carry no tool name of their own, so + the caller supplies the MCP verb the action corresponds to (``switch_panel``, + ``show_panel``, ``hide_panel``, ``arrange_workspace``, ``register_panel``) + and the frontend words the entry from it. + + Nothing is broadcast — this is history only. Callers must invoke it for + agent-origin requests exactly once per action, and never for human ones: a + human's own gestures are not the agent's activity. + + Args: + request: Incoming FastAPI request carrying ``app.state``. + tool: Synthetic tool name naming the action. + panel: The panel id the action targeted. + """ + record_activity(request, tool, {"kind": "panel", "panel": panel}) + + class PanelFocusRequest(BaseModel): panel: str url: str | None = None @@ -378,6 +406,11 @@ async def set_panel_focus(body: PanelFocusRequest, request: Request): A panel already in the rail — which is the only kind a human can click — changes nothing and emits no visibility frame. + An agent switch is also mirrored into the activity history ring as one + ``switch_panel`` row. When the switch additionally adds rail membership, + only the focus is mirrored: the pair of frames is one agent action, and + history counts actions, not frames. + Args: body: ``panel`` (panel id), optional ``url`` to load, and optional ``source`` attribution. @@ -420,6 +453,7 @@ async def set_panel_focus(body: PanelFocusRequest, request: Request): event: dict = {"type": "panel_focus", "panel": body.panel, "source": body.source} if body.url: event["url"] = _prefix_path(body.url) + _mirror_agent_panel_activity(request, "switch_panel", body.panel) request.app.state.broadcaster.broadcast(event) return {"status": "ok", "active_panel": body.panel} @@ -434,6 +468,10 @@ class PanelVisibilityRequest(BaseModel): async def set_panel_visibility(body: PanelVisibilityRequest, request: Request): """Show or hide a panel and broadcast the change via SSE. + An agent-origin change is also mirrored into the activity history ring, as + a ``show_panel`` or ``hide_panel`` row depending on the flag, so a client + reading the history can word it the way it words the live frame. + Args: body: ``panel`` (panel id) and ``visible`` (desired visibility). request: Incoming FastAPI request carrying ``app.state``. @@ -456,6 +494,10 @@ async def set_panel_visibility(body: PanelVisibilityRequest, request: Request): event: dict = {"type": "panel_visibility", "panel": body.panel, "visible": body.visible} if body.source: event["source"] = body.source + if body.source == "agent": + _mirror_agent_panel_activity( + request, "show_panel" if body.visible else "hide_panel", body.panel + ) request.app.state.broadcaster.broadcast(event) return {"status": "ok", "panel": body.panel, "visible": body.visible} @@ -584,6 +626,9 @@ async def arrange_panels(body: PanelArrangeRequest, request: Request): broadcast still carries ``focus`` only when one was requested, leaving the client's fallback rule in charge of what is actually focused on screen. + An agent arrangement is mirrored into the activity history ring as a single + ``arrange_workspace`` row targeting the recorded focus panel. + Args: body: ``tiles`` (explicit ids, left-to-right) **or** ``preset`` (a configured layout name), an optional ``focus`` target that must be @@ -642,6 +687,11 @@ async def arrange_panels(body: PanelArrangeRequest, request: Request): event["prune_rail"] = True if body.source: event["source"] = body.source + if body.source == "agent": + # One row for the whole arrangement, targeting the panel focus lands on + # — the same id recorded as ``active_panel`` above, so the history entry + # names the tile the operator's eye is sent to. + _mirror_agent_panel_activity(request, "arrange_workspace", body.focus or tiles[0]) request.app.state.broadcaster.broadcast(event) return { "status": "ok", @@ -894,6 +944,9 @@ async def register_panel(body: PanelRegisterRequest, request: Request): If a custom panel with the same ``id`` already exists it is replaced atomically (remove-then-append) so the proxy always returns the first match. + An agent-origin registration is mirrored into the activity history ring as + a ``register_panel`` row. + Args: body: Panel registration fields: ``id``, ``label``, ``url`` (raw), ``path`` (default ``"/"``), ``health_endpoint`` (optional). @@ -970,6 +1023,8 @@ async def register_panel(body: PanelRegisterRequest, request: Request): } if body.source: event["source"] = body.source + if body.source == "agent": + _mirror_agent_panel_activity(request, "register_panel", body.id) request.app.state.broadcaster.broadcast(event) return {"status": "ok", "id": body.id, "label": body.label, "url": browser_url} diff --git a/src/osprey/interfaces/web_terminal/static/css/activity-strip.css b/src/osprey/interfaces/web_terminal/static/css/activity-strip.css index 0a757c2bb..878173541 100644 --- a/src/osprey/interfaces/web_terminal/static/css/activity-strip.css +++ b/src/osprey/interfaces/web_terminal/static/css/activity-strip.css @@ -41,7 +41,7 @@ } .activity-strip-subject { - color: var(--accent); + color: var(--color-accent); min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -84,3 +84,79 @@ opacity: 0; pointer-events: none; } + +/* ---- History popover ---- + * + * The strip is its own trigger: clicking it expands the recent-activity list. + * Cursor and focus ring are the only affordances — no added box, so the live + * line's layout is untouched. */ +.activity-strip-trigger { + cursor: pointer; +} + +.activity-strip-trigger:focus-visible { + outline: 1px solid var(--color-accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* Body-level and fixed — the hub's strip lives in the footer status bar, + * which is one row tall and overflow:hidden, so an in-place popover would be + * clipped away. Coordinates come from JS (placeHistory()); everything here is + * appearance only. Not scoped under the status bar: it is a child of . */ +.activity-history-popover { + position: fixed; + z-index: var(--z-dropdown); + min-width: 240px; + max-width: min(90vw, 420px); + max-height: min(50vh, 320px); + overflow-y: auto; + padding: var(--space-1); + background: var(--bg-elevated); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + box-shadow: var(--shadow-dropdown); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.activity-history-popover:focus { + outline: none; +} + +.activity-history-row { + display: flex; + align-items: baseline; + gap: var(--space-1); + padding: 3px var(--space-2); + white-space: nowrap; +} + +.activity-history-verb { + color: var(--text-secondary); + flex: 0 0 auto; +} + +/* The agent-supplied half: it gets the remaining width and truncates, so a + long channel list can never widen the popover past its max. */ +.activity-history-subject { + color: var(--color-accent); + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.activity-history-time { + color: var(--text-muted); + font-size: var(--text-xs); + flex: 0 0 auto; + margin-left: auto; + padding-left: var(--space-2); +} + +.activity-history-message { + padding: var(--space-2); + color: var(--text-muted); + text-align: center; +} diff --git a/src/osprey/interfaces/web_terminal/static/css/terminal.css b/src/osprey/interfaces/web_terminal/static/css/terminal.css index 7cb063ca3..0b002dbdf 100644 --- a/src/osprey/interfaces/web_terminal/static/css/terminal.css +++ b/src/osprey/interfaces/web_terminal/static/css/terminal.css @@ -1170,6 +1170,26 @@ html[data-rail-position="top"] .rail-hint { pointer-events: none; } +/* Agent attribution glow for a whole tile body. dock-iframe.js sizes one of + these to the affected panel's group rectangle and fires the design system's + `.agent-flash` on it (highlight.css supplies every colour, accent tokens + only, so it reads in all four themes). It is a separate overlay element + rather than the panel iframe because the iframe is another document the + flash cannot reach into, and flashing the iframe element itself would clip + the embedded app to the flash's border-radius. + + At rest the element is fully transparent — the flash animates background and + box-shadow back to transparent and removes its own class — so a spent glow + costs nothing parked in the overlay. `z-index` lifts it above the overlay's + iframes, which are positioned but unlayered and would otherwise paint over + any glow created before them; `pointer-events` re-states the overlay's own + inertness so the layer stays click-through even if it is ever re-parented. */ +.dock-iframe-overlay .tile-glow { + position: absolute; + z-index: 1; + pointer-events: none; +} + /* ---- Terminal panel ---- */ .terminal-panel { diff --git a/src/osprey/interfaces/web_terminal/static/js/activity-format.js b/src/osprey/interfaces/web_terminal/static/js/activity-format.js new file mode 100644 index 000000000..3c65ca709 --- /dev/null +++ b/src/osprey/interfaces/web_terminal/static/js/activity-format.js @@ -0,0 +1,104 @@ +// @ts-check +/* OSPREY Web Terminal — Agent Activity Vocabulary + * + * How an agent_activity frame is WORDED. Two surfaces render one — the live + * line in activity-strip.js and the history rows in activity-history.js — and + * both word it through here, so a hide reads the same wherever it appears. + * + * Pure by construction: no DOM, no fetch, no module state. Callers own the + * rendering, and every agent-supplied string they get back goes in as a text + * node (createElement + textContent, never innerHTML). + */ + +/** @typedef {import('./panel-manager.js').AgentActivityEvent} AgentActivityFrame */ + +/** + * The tool name the panel routes mirror an agent arrange under. Its subject is + * the workspace itself rather than a single panel, so it is worded apart from + * the PANEL_VERBS table — and the strip keys its burst coalescing on it. + */ +export const ARRANGE_TOOL = 'arrange_workspace'; + +/** + * Verb per panel action, keyed on the synthetic tool names the panel routes + * record for agent-origin gestures (see routes/panels.py). + * @type {Record} + */ +const PANEL_VERBS = { + hide_panel: 'agent closed', + show_panel: 'agent opened', + switch_panel: 'agent focused', + register_panel: 'agent added', +}; + +/** + * Human-readable two-part label for a frame. The subject carries the + * agent-supplied string and is rendered as a text node by the caller. + * @param {AgentActivityFrame} frame + * @param {{ labelOf?: (id: string) => string, count?: number }} [opts] + * `labelOf` resolves a panel id to its catalog label; without it (or for an + * id the catalog does not know) the raw id is shown. `count` is how many + * coalesced arrange frames this one line stands for. + * @returns {{ verb: string, subject: string }} + */ +export function formatActivity(frame, opts = {}) { + const t = frame.target; + /** Catalog label for a panel id, falling back to the id, then the tool. + * @param {string | undefined} id @returns {string} */ + const label = (id) => (id ? opts.labelOf?.(id) || id : frame.tool); + switch (t.kind) { + case 'channel': + // Neutral on purpose: a channel detail is the display/channel the tool + // was ASKED to drive, never a read-back of what the machine confirmed. + return { verb: 'agent wrote', subject: t.detail || frame.tool }; + case 'run': + return t.detail + ? { verb: 'agent launched run', subject: t.detail } + : { verb: 'agent launched a run', subject: '' }; + case 'artifact': + return { verb: 'agent focused', subject: t.detail || 'an artifact' }; + case 'config': + return { verb: 'agent changed config', subject: t.detail || frame.tool }; + case 'ui': + return { verb: 'agent moved window', subject: t.detail || frame.tool }; + case 'panel': { + if (frame.tool === ARRANGE_TOOL) { + // A lone arrange row (in history, or the first frame of a burst) has + // no count to report; only a coalesced run names how many tiles moved. + const n = opts.count ?? 1; + return { verb: 'agent arranged', subject: n > 1 ? `workspace (${n} tiles)` : 'workspace' }; + } + const verb = PANEL_VERBS[frame.tool]; + if (verb) return { verb, subject: label(t.panel) }; + // Generic fallback: a panel-kind frame from some other tool. + return { verb: 'agent touched', subject: t.panel || frame.tool }; + } + default: + // Unknown future kind from a newer server — still show something. + return { verb: 'agent activity', subject: frame.tool }; + } +} + +/** + * Coarse "how long ago" label for a frame's server timestamp. + * + * Deliberately coarse: history rows answer "what has the agent been doing", + * not "at exactly which instant". A missing ts (older server, or a frame + * synthesised client-side) yields an empty string, which the caller skips. + * Browser and server clocks can disagree by a little, so any non-positive + * age reads as "just now" rather than a negative number. + * @param {number | undefined} ts epoch seconds, as the server stamps them + * @param {number} nowMs current wall clock in ms (Date.now()) + * @returns {string} + */ +export function formatRelativeTime(ts, nowMs) { + if (typeof ts !== 'number' || !Number.isFinite(ts)) return ''; + const secs = Math.round(nowMs / 1000 - ts); + if (secs < 1) return 'just now'; + if (secs < 60) return `${secs}s ago`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} diff --git a/src/osprey/interfaces/web_terminal/static/js/activity-history.js b/src/osprey/interfaces/web_terminal/static/js/activity-history.js new file mode 100644 index 000000000..12103793c --- /dev/null +++ b/src/osprey/interfaces/web_terminal/static/js/activity-history.js @@ -0,0 +1,275 @@ +// @ts-check +/* OSPREY Web Terminal — Agent Activity History Popover + * + * The activity strip's live line shows only the latest action, so clicking the + * strip opens this popover listing the recent ones. The history itself lives on + * the server (a bounded ring, GET /api/agent-activity/recent) — this module + * keeps no client-side ring, it just fetches on open and renders the rows. + * Frames arriving while it is open are prepended live; because the server + * appends to its ring BEFORE broadcasting, a refetch always re-includes them, + * so a live row is never lost when a slower fetch resolves over it. + * + * The popover is a child of , position:fixed, because the hub's strip + * sits inside the footer status bar and that bar is overflow:hidden and one + * row tall — an in-place popover would be clipped away (same reason, same + * recipe as the tile contribution menu in tile-header-items.js). + * + * The strip mount is its own trigger. It is an aria-live region provided by + * the template, so this module cannot re-role it into a button or nest a + * persistent button inside it (the live slot is wiped on every frame). + * Instead the mount takes aria-expanded plus keyboard activation. + * + * All agent-supplied strings are rendered as text nodes only — createElement + + * textContent, never innerHTML. + */ + +import { withPrefix } from './api.js'; +import { formatActivity, formatRelativeTime } from './activity-format.js'; + +/** @typedef {import('./panel-manager.js').AgentActivityEvent} AgentActivityFrame */ + +/** + * Rows requested from the server ring, and the cap on rows kept in the open + * popover's DOM. Matches ACTIVITY_RING_MAX server-side: asking for more just + * gets clamped, and a popover left open through a long run must not grow + * without bound. + */ +export const HISTORY_LIMIT = 50; + +/** + * Default history reader: the server's ring, newest first. Throws on a + * transport or HTTP failure so the popover can show its error state rather + * than an empty list that would read as "the agent did nothing". + * @param {number} limit + * @returns {Promise} + */ +async function fetchRecentActivity(limit) { + const resp = await fetch(withPrefix(`/api/agent-activity/recent?limit=${limit}`), { + cache: 'no-store', + }); + if (!resp.ok) throw new Error(`recent agent activity: HTTP ${resp.status}`); + const body = await resp.json(); + // Contract: {"events": [...]} — an object, never a bare array. + return Array.isArray(body?.events) ? body.events : []; +} + +/** + * Build the history popover for a strip, anchored to and triggered by `mount`. + * Wiring the trigger is part of construction: the mount is the affordance, so + * a history that existed without it could never be opened by an operator. + * + * Dependencies are injected so tests drive it without a network. + * @param {{ + * mount: HTMLElement, + * fetchRecent?: (limit: number) => Promise, + * labelOf?: (id: string) => string, + * }} deps + * @returns {{ + * open: () => Promise, + * close: () => void, + * isOpen: () => boolean, + * prepend: (frame: AgentActivityFrame) => void, + * }} + */ +export function createActivityHistory({ + mount, + fetchRecent = fetchRecentActivity, + labelOf: panelLabel, +}) { + /** @type {HTMLElement | null} */ + let popoverEl = null; + let historyOpen = false; + /** Bumped on every open and close, so a fetch from a stale open is dropped. */ + let historyGeneration = 0; + + function ensurePopover() { + if (popoverEl) return popoverEl; + const el = document.createElement('div'); + el.className = 'activity-history-popover'; + el.setAttribute('role', 'region'); + el.setAttribute('aria-label', 'Recent agent activity'); + el.tabIndex = -1; + popoverEl = el; + return el; + } + + /** + * Replace the popover body with a single status line (loading, empty, error). + * @param {string} text + */ + function showHistoryMessage(text) { + const el = ensurePopover(); + const msg = document.createElement('div'); + msg.className = 'activity-history-message'; + msg.textContent = text; + el.replaceChildren(msg); + } + + /** + * One history row: verb, subject, and a coarse age. Every agent-supplied + * string goes in through textContent, exactly as the live entry does. + * @param {AgentActivityFrame} frame + * @returns {HTMLElement} + */ + function buildHistoryRow(frame) { + const row = document.createElement('div'); + row.className = 'activity-history-row'; + + const { verb, subject } = formatActivity(frame, { labelOf: panelLabel }); + const verbEl = document.createElement('span'); + verbEl.className = 'activity-history-verb'; + verbEl.textContent = verb; + row.appendChild(verbEl); + + if (subject) { + const subjectEl = document.createElement('span'); + subjectEl.className = 'activity-history-subject'; + subjectEl.textContent = subject; + row.appendChild(subjectEl); + } + + const age = formatRelativeTime(frame.ts, Date.now()); + if (age) { + const timeEl = document.createElement('span'); + timeEl.className = 'activity-history-time'; + timeEl.textContent = age; + row.appendChild(timeEl); + } + return row; + } + + /** + * Render the server's history, newest first — the endpoint already orders it + * that way, so rows go in array order, top to bottom. + * @param {AgentActivityFrame[]} events + */ + function renderHistory(events) { + const el = ensurePopover(); + const usable = events.filter((e) => e && e.target); + if (usable.length === 0) { + showHistoryMessage('No recent agent activity'); + return; + } + el.replaceChildren(...usable.slice(0, HISTORY_LIMIT).map(buildHistoryRow)); + } + + /** + * Add a frame that arrived while the popover is open at the top of the list. + * @param {AgentActivityFrame} frame + */ + function prependHistoryRow(frame) { + const el = ensurePopover(); + // A message line ("No recent agent activity", an error) is not a row — + // the first real event replaces it. + const msg = el.querySelector('.activity-history-message'); + if (msg) el.replaceChildren(); + el.prepend(buildHistoryRow(frame)); + while (el.children.length > HISTORY_LIMIT) el.lastElementChild?.remove(); + } + + /** + * Anchor the fixed popover to the strip. It opens upward by default: in the + * hub the strip is the footer status bar, so above is where the room is. + */ + function placeHistory() { + if (!popoverEl) return; + const anchor = mount.getBoundingClientRect(); + const w = popoverEl.offsetWidth; + const h = popoverEl.offsetHeight; + const margin = 4; + let left = anchor.left + anchor.width / 2 - w / 2; + left = Math.max(margin, Math.min(left, window.innerWidth - w - margin)); + let top = anchor.top - h - margin; + if (top < margin) top = Math.min(anchor.bottom + margin, window.innerHeight - h - margin); + popoverEl.style.left = `${Math.round(left)}px`; + popoverEl.style.top = `${Math.round(Math.max(margin, top))}px`; + } + + /** @param {MouseEvent} e */ + function onDocumentClick(e) { + if (!(e.target instanceof Node)) return; + if (mount.contains(e.target)) return; + if (popoverEl && popoverEl.contains(e.target)) return; + closeHistory(); + } + + /** @param {KeyboardEvent} e */ + function onDocumentKeydown(e) { + if (e.key === 'Escape') { + closeHistory(); + mount.focus(); + } + } + + function isHistoryOpen() { + return historyOpen; + } + + async function openHistory() { + if (historyOpen) return; + historyOpen = true; + const generation = ++historyGeneration; + + const el = ensurePopover(); + showHistoryMessage('Loading…'); + document.body.appendChild(el); + mount.setAttribute('aria-expanded', 'true'); + placeHistory(); + // Capture phase, so an outside click closes before it does anything else. + document.addEventListener('click', onDocumentClick, true); + document.addEventListener('keydown', onDocumentKeydown, true); + window.addEventListener('resize', placeHistory); + el.focus(); + + /** @type {AgentActivityFrame[] | null} */ + let events = null; + try { + events = await fetchRecent(HISTORY_LIMIT); + } catch { + events = null; + } + // Closed (or closed and reopened) while the request was in flight. + if (generation !== historyGeneration) return; + if (events == null) showHistoryMessage('Could not load recent activity'); + else renderHistory(events); + placeHistory(); + } + + function closeHistory() { + if (!historyOpen) return; + historyOpen = false; + historyGeneration++; + popoverEl?.remove(); + mount.setAttribute('aria-expanded', 'false'); + document.removeEventListener('click', onDocumentClick, true); + document.removeEventListener('keydown', onDocumentKeydown, true); + window.removeEventListener('resize', placeHistory); + } + + function toggleHistory() { + if (historyOpen) closeHistory(); + else void openHistory(); + } + + mount.classList.add('activity-strip-trigger'); + mount.setAttribute('aria-expanded', 'false'); + mount.title = 'Recent agent activity'; + if (!mount.hasAttribute('tabindex')) mount.tabIndex = 0; + mount.addEventListener('click', (e) => { + e.stopPropagation(); + toggleHistory(); + }); + mount.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleHistory(); + } + }); + + return { + open: openHistory, + close: closeHistory, + isOpen: isHistoryOpen, + prepend: prependHistoryRow, + }; +} diff --git a/src/osprey/interfaces/web_terminal/static/js/activity-strip.js b/src/osprey/interfaces/web_terminal/static/js/activity-strip.js index 906b88f39..f80740490 100644 --- a/src/osprey/interfaces/web_terminal/static/js/activity-strip.js +++ b/src/osprey/interfaces/web_terminal/static/js/activity-strip.js @@ -18,11 +18,18 @@ * fired in panel-manager). The kind→panel mapping is the SUPPRESSION table * below; suppression itself is a pure function (exported for tests). * + * Three modules, one feature: this one owns the live line, activity-format.js + * words every frame for both surfaces, and activity-history.js is the + * click-to-expand popover over the server's ring (the strip mount is its + * trigger, so the strip builds it and hands out its open/close). + * * All agent-supplied strings (tool, detail, panel) are rendered as text nodes * only — createElement + textContent, never innerHTML. */ -import { setActivityStripHandler, getActivePanel } from './panel-manager.js'; +import { setActivityStripHandler, getActivePanel, labelOf } from './panel-manager.js'; +import { ARRANGE_TOOL, formatActivity } from './activity-format.js'; +import { createActivityHistory } from './activity-history.js'; /** @typedef {import('./panel-manager.js').AgentActivityEvent} AgentActivityFrame */ /** @typedef {AgentActivityFrame['target']} ActivityTarget */ @@ -68,50 +75,44 @@ export function isSuppressed(target, activePanel) { return mapped != null && mapped === activePanel; } -/** - * Human-readable two-part label for a frame. The subject carries the - * agent-supplied string and is rendered as a text node by the caller. - * @param {AgentActivityFrame} frame - * @returns {{ verb: string, subject: string }} - */ -export function formatActivity(frame) { - const t = frame.target; - switch (t.kind) { - case 'channel': - return { verb: 'agent wrote', subject: t.detail || frame.tool }; - case 'run': - return t.detail - ? { verb: 'agent launched run', subject: t.detail } - : { verb: 'agent launched a run', subject: '' }; - case 'artifact': - return { verb: 'agent focused', subject: t.detail || 'an artifact' }; - case 'panel': - // Generic fallback: a panel-kind frame whose id had no rail entry. - return { verb: 'agent touched', subject: t.panel || frame.tool }; - default: - // Unknown future kind from a newer server — still show something. - return { verb: 'agent activity', subject: frame.tool }; - } -} - // ---- Strip factory ---- /** * Build a strip bound to a mount element. Dependencies are injected so tests - * drive it directly (frames via handleActivity, active panel via a stub). + * drive it directly (frames via handleActivity, active panel via a stub, + * history via a stub reader). * @param {{ * mount: HTMLElement, * getActivePanel: () => string | null, * clearMs?: number, + * fetchRecent?: (limit: number) => Promise, + * labelOf?: (id: string) => string, * }} deps - * @returns {{ handleActivity: (frame: AgentActivityFrame) => void, clear: () => void }} + * @returns {{ + * handleActivity: (frame: AgentActivityFrame) => void, + * clear: () => void, + * openHistory: () => Promise, + * closeHistory: () => void, + * isHistoryOpen: () => boolean, + * }} */ -export function createActivityStrip({ mount, getActivePanel, clearMs = ACTIVITY_CLEAR_MS }) { +export function createActivityStrip({ + mount, + getActivePanel, + clearMs = ACTIVITY_CLEAR_MS, + fetchRecent, + labelOf: panelLabel, +}) { /** @type {ReturnType | null} */ let timer = null; + /** Arrange frames shown back-to-back in the current live window (see below). */ + let arrangeRun = 0; + + const history = createActivityHistory({ mount, fetchRecent, labelOf: panelLabel }); function clear() { if (timer != null) { clearTimeout(timer); timer = null; } + arrangeRun = 0; mount.textContent = ''; } @@ -119,9 +120,21 @@ export function createActivityStrip({ mount, getActivePanel, clearMs = ACTIVITY_ function handleActivity(frame) { const target = frame?.target; if (!target) return; // malformed frame — ignore + + // History records what the agent did, not what the strip chose to show, + // so the live prepend happens BEFORE the suppression check — the server + // ring keeps suppressed frames too, and the two must not disagree. + if (history.isOpen()) history.prepend(frame); + if (isSuppressed(target, getActivePanel())) return; - const { verb, subject } = formatActivity(frame); + // Arranging a workspace lands one frame per tile, and the single slot would + // otherwise flicker through them. Same idiom as the latest-wins replacement + // below — the run collapses into one line — except that consecutive arrange + // frames count up instead of overwriting. Anything else ends the run. + arrangeRun = frame.tool === ARRANGE_TOOL ? arrangeRun + 1 : 0; + + const { verb, subject } = formatActivity(frame, { labelOf: panelLabel, count: arrangeRun }); // Text nodes only — agent-supplied strings must never reach innerHTML. const entry = document.createElement('span'); @@ -144,7 +157,13 @@ export function createActivityStrip({ mount, getActivePanel, clearMs = ACTIVITY_ timer = setTimeout(clear, clearMs); } - return { handleActivity, clear }; + return { + handleActivity, + clear, + openHistory: history.open, + closeHistory: history.close, + isHistoryOpen: history.isOpen, + }; } // ---- Self-boot ---- @@ -154,15 +173,32 @@ export function createActivityStrip({ mount, getActivePanel, clearMs = ACTIVITY_ // mount and this module registers itself on panel-manager's seam. Pages // without the mount (or without a running panel-manager) no-op harmlessly. -function boot() { +/** @type {ReturnType | null} */ +let bootedStrip = null; + +/** + * Boot the page's one strip on the template's #activity-strip mount and + * register it on panel-manager's seam. + * + * Idempotent, and that is load-bearing: the session page (session.js) drives + * the strip from its own SSE subscription because no panel-manager runs + * there, so it calls this to reach the same instance the module's own boot + * creates. A second strip on the shared mount would bind a second set of + * click handlers and open a second history popover. + * + * @returns {ReturnType | null} null on a page with no mount + */ +export function bootActivityStrip() { + if (bootedStrip) return bootedStrip; const mount = document.getElementById('activity-strip'); - if (!mount) return; - const strip = createActivityStrip({ mount, getActivePanel }); - setActivityStripHandler(strip.handleActivity); + if (!mount) return null; + bootedStrip = createActivityStrip({ mount, getActivePanel, labelOf }); + setActivityStripHandler(bootedStrip.handleActivity); + return bootedStrip; } if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', boot, { once: true }); + document.addEventListener('DOMContentLoaded', bootActivityStrip, { once: true }); } else { - boot(); + bootActivityStrip(); } diff --git a/src/osprey/interfaces/web_terminal/static/js/app.js b/src/osprey/interfaces/web_terminal/static/js/app.js index 3806763ad..6706b4ee8 100644 --- a/src/osprey/interfaces/web_terminal/static/js/app.js +++ b/src/osprey/interfaces/web_terminal/static/js/app.js @@ -2,7 +2,7 @@ import { initTerminal, focusTerminal, getTerminalDimensions, pasteToTerminal, clearStoredSessionId } from './terminal.js'; import { onConnectionStateChange, fetchJSON, withPrefix } from './api.js'; -import { initPanelManager, broadcastMode, handleUiModeFlip } from './panel-manager.js'; +import { initPanelManager, broadcastMode, handleUiModeFlip, navigateAndActivatePanel } from './panel-manager.js'; import '/design-system/js/components/osprey-drawer.js'; import { initSettings } from './settings.js'; import { initMemoryGallery } from './memory-gallery.js'; @@ -362,6 +362,25 @@ function initIframePasteBridge() { pasteToTerminal(e.data.text); focusTerminal(); } + // A panel asking its host to move THIS client to another panel — the + // sender-local twin of the panel_focus SSE path (the gallery's logbook + // submit is the first caller). Deliberately not a server broadcast: a + // human gesture in one browser must not move anyone else's workspace, + // and it gets a plain activation with no agent attribution. + // + // The url must be root-relative and NOT protocol-relative. The origin + // check above is necessary but not sufficient: a same-origin sender can + // still be an agent-authored artifact rendered in a sandboxed panel, and + // this url reaches an iframe src via buildEmbedSrc, which preserves + // whatever scheme it is handed. `javascript:alert(1)` survives it intact + // and would execute in the HOST origin, and `//evil.example/x` resolves + // to a cross-origin document — so a leading-slash test alone is a hole. + // Every real panel url is root-relative and already server-prefixed. + if (e.data && e.data.type === 'osprey:navigate' + && typeof e.data.panel === 'string' && typeof e.data.url === 'string' + && e.data.url.startsWith('/') && !e.data.url.startsWith('//')) { + navigateAndActivatePanel(e.data.panel, e.data.url); + } }); // Drop zone: accept dragged artifacts onto the terminal container diff --git a/src/osprey/interfaces/web_terminal/static/js/chat-render.js b/src/osprey/interfaces/web_terminal/static/js/chat-render.js index 784d871e3..f60557f49 100644 --- a/src/osprey/interfaces/web_terminal/static/js/chat-render.js +++ b/src/osprey/interfaces/web_terminal/static/js/chat-render.js @@ -34,6 +34,8 @@ * | `tool_result` | `result` | `session_reset` | `error` | `system` | … * @property {string} [content] - incremental text (`text` events) * @property {string} [tool_name] - display name, prefix-stripped (`tool_use`) + * @property {string} [tool_name_raw] - the SDK's own tool name, prefix intact + * (`tool_use`); the preferred {@link TOOL_PHRASES} key * @property {string} [message] - human-readable error text (`error` events) * @property {boolean} [is_error] - turn/tool errored (`result`, `tool_result`) */ @@ -148,6 +150,196 @@ export function renderMarkdownInto(el, text) { } } +// ---- Tool vocabulary ---- // + +/** + * Operator phrases for the tools a turn is likely to use, keyed by normalised + * tool name (see {@link normaliseToolName}). + * + * In Simple mode the chat is the whole interface, so this line is the only + * account an operator gets of what the agent is doing. Phrases say it in + * control-room terms and share the activity strip's vocabulary (open/close/ + * focus/arrange for panels, "wrote" for channels). + * + * They are lower-case gerunds — "writing control channels" — so a caller can + * drop one mid-sentence; {@link activityLabel} capitalises for the activity + * line. Coverage is deliberately partial: a tool whose formatted name already + * reads as plain English ("List Panels", "Session Summary") falls through to + * the raw-name fallback instead of earning a row. Adding one is a single line. + * + * Phrases cannot name the specific panel, channel, or file involved: the chat + * route (`_strip_for_chat`) drops a `tool_use` event's `input` before it leaves + * the server, so no arguments reach this module. + * + * @type {Readonly>} + */ +export const TOOL_PHRASES = Object.freeze({ + // Control system — the writes an operator most needs to see coming. + channel_write: 'writing control channels', + channel_read: 'reading control channels', + channel_limits: 'checking channel limits', + archiver_read: 'reading archived data', + archiver_downsample: 'thinning archived data', + + // Python executor. + execute: 'running Python', + execute_file: 'running Python', + + // Workspace panels. The synthetic panel activity uses these same names. + show_panel: 'opening a panel', + hide_panel: 'closing a panel', + switch_panel: 'switching panels', + arrange_workspace: 'arranging the workspace', + register_panel: 'adding a panel', + manage_window: 'arranging a window', + screenshot_capture: 'taking a screenshot', + + // Workspace artifacts and saved data. + artifact_save: 'saving an artifact', + artifact_get: 'opening an artifact', + artifact_focus: 'showing an artifact', + artifact_pin: 'pinning an artifact', + artifact_export: 'exporting an artifact', + artifact_delete: 'deleting an artifact', + artifact_delete_all: 'deleting every artifact', + create_static_plot: 'drawing a plot', + create_interactive_plot: 'drawing an interactive plot', + create_dashboard: 'building a dashboard', + create_document: 'writing a document', + data_list: 'listing saved data', + data_read: 'reading saved data', + data_delete: 'deleting saved data', + + // Workspace project setup and session record. + setup_inspect: 'inspecting the project setup', + setup_patch: 'changing the project setup', + session_log: 'writing to the session log', + + // Scan queue (bluesky). + queue_status: 'checking the scan queue', + queue_list: 'listing the scan queue', + queue_add: 'queueing a scan', + queue_start: 'starting the scan queue', + queue_stop: 'stopping the scan queue', + stop_run: 'stopping the running scan', + write_plan: 'drafting a scan plan', + validate_plan: 'checking the scan plan', + get_draft: 'reading the scan draft', + set_draft: 'editing the scan draft', + clear_draft: 'clearing the scan draft', + get_run: 'reading a scan run', + get_run_data: 'reading scan data', + + // Phoebus displays. + phoebus_open_panel: 'opening a Phoebus display', + phoebus_open_databrowser: 'opening the data browser', + phoebus_list_displays: 'listing Phoebus displays', + phoebus_perceive: 'reading a Phoebus display', + phoebus_perceive_region: 'reading part of a Phoebus display', + phoebus_snapshot: 'capturing a Phoebus display', + phoebus_drive: 'operating a Phoebus display', + + // Logbook (ARIEL). + browse: 'browsing the logbook', + keyword_search: 'searching the logbook', + semantic_search: 'searching the logbook', + sql_query: 'querying the logbook', + filter_options: 'listing logbook filters', + entry_get: 'reading a logbook entry', + entries_by_ids: 'reading logbook entries', + entry_create: 'drafting a logbook entry', + entry_publish: 'publishing a logbook entry', + + // Channel finder. + list_channels: 'looking up channels', + query_channels: 'looking up channels', + build_channels: 'building a channel list', + list_families: 'looking up channel families', + list_systems: 'looking up systems', + get_common_names: 'looking up channel names', + inspect_fields: 'inspecting channel fields', + + // Facility knowledge. + list_concepts: 'browsing facility knowledge', + read_concept: 'reading facility knowledge', + draft_concept: 'drafting a facility note', + + // Lattice model. The mutators are phrased; the getters fall back. + lattice_init: 'loading the lattice', + lattice_state: 'reading the lattice', + lattice_set_param: 'changing a lattice parameter', + lattice_set_baseline: 'setting the lattice baseline', + lattice_clear_baseline: 'clearing the lattice baseline', + lattice_update_settings: 'changing lattice settings', + lattice_refresh: 'refreshing the lattice view', + + // Health. + health_check: 'checking system health', + health_check_full: 'checking system health', + + // Built-in agent tools. + read: 'reading a file', + write: 'writing a file', + edit: 'editing a file', + bash: 'running a shell command', + glob: 'looking for files', + grep: 'searching files', + task: 'delegating to a helper agent', + todowrite: 'updating its plan', + webfetch: 'fetching a web page', + websearch: 'searching the web', +}); + +/** + * Fold a tool name to a {@link TOOL_PHRASES} key: drop the `mcp____` + * prefix, collapse whitespace and hyphens to underscores, lower-case. + * + * A `tool_use` event carries the name twice — `tool_name_raw` + * (`mcp__osprey__channel_write`) and the server-formatted `tool_name` + * (`Channel Write`, from operator_session `_format_tool_name`) — and both fold + * to the same key, so the table works whichever spelling an event carries. + * + * @param {string} name + * @returns {string} + */ +export function normaliseToolName(name) { + return name + .replace(/^mcp__[^_]+__/, '') + .trim() + .replace(/[\s-]+/g, '_') + .toLowerCase(); +} + +/** + * The operator phrase for a `tool_use` event, or null when the tool has no + * table entry. Lower-case and sentence-fragment shaped; capitalise at the + * point of display. + * @param {ChatEvent} event + * @returns {string | null} + */ +export function toolPhrase(event) { + for (const name of [event.tool_name_raw, event.tool_name]) { + if (!name) continue; + const phrase = TOOL_PHRASES[normaliseToolName(name)]; + if (phrase) return phrase; + } + return null; +} + +/** + * The activity-line label for a `tool_use` event. A mapped tool reads as a + * sentence ("Writing control channels…"); anything unmapped keeps its raw name + * ("Using Queue Reorder…"), so a tool added elsewhere in the codebase is never + * invisible here — only terse until it earns a phrase. + * @param {ChatEvent} event + * @returns {string} + */ +export function activityLabel(event) { + const phrase = toolPhrase(event); + if (phrase !== null) return `${phrase.charAt(0).toUpperCase()}${phrase.slice(1)}…`; + return `Using ${event.tool_name ?? event.tool_name_raw ?? 'tool'}…`; +} + // ---- Pure DOM builders ---- // /** @@ -348,7 +540,7 @@ export function createChatRenderer(container) { setActivity('Thinking…'); break; case 'tool_use': - setActivity(`Using ${event.tool_name ?? 'tool'}…`); + setActivity(activityLabel(event)); break; case 'tool_result': // Stripped of its body; nothing to render. The activity line stays as diff --git a/src/osprey/interfaces/web_terminal/static/js/dock-iframe.js b/src/osprey/interfaces/web_terminal/static/js/dock-iframe.js index d0b52cd01..89dc732e5 100644 --- a/src/osprey/interfaces/web_terminal/static/js/dock-iframe.js +++ b/src/osprey/interfaces/web_terminal/static/js/dock-iframe.js @@ -60,12 +60,14 @@ import { setServiceRedock, } from './dock-workspace.js'; import { PLACEHOLDER_PREFIX } from './dock-reconcile.js'; +import { flashElement } from '/design-system/js/highlight.js'; /** * One tracked service panel: its cached iframe (created/owned by panel-manager), * the id of the empty dockview placeholder it follows, the tab title, whether it - * is currently meant to be on screen (false once closed/hidden), and the last - * synced size (to throttle resize re-dispatch to real size changes). + * is currently meant to be on screen (false once closed/hidden), the last synced + * size (to throttle resize re-dispatch to real size changes), and its lazily + * created agent-glow overlay (see glowPanel). * @typedef {object} ManagedPanel * @property {HTMLIFrameElement} iframe * @property {string} placeholderId @@ -73,6 +75,7 @@ import { PLACEHOLDER_PREFIX } from './dock-reconcile.js'; * @property {boolean} visible * @property {number} [lastW] * @property {number} [lastH] + * @property {HTMLElement} [glowEl] */ const OVERLAY_CLASS = 'dock-iframe-overlay'; @@ -654,3 +657,80 @@ export function concealPanel(panelId) { entry.visible = false; entry.iframe.style.display = 'none'; } + +// ---- Agent attribution glow ------------------------------------------------ + +/** Class of the per-panel glow element; styled in css/terminal.css. */ +const GLOW_CLASS = 'tile-glow'; + +/** + * The glow element for a managed panel, created on first use and reused after. + * It is a sibling of the overlay iframes rather than anything inside them: the + * iframes are separate documents this stylesheet cannot reach, and an + * `.agent-flash` on the iframe element itself would clip the embedded app to + * the flash's border-radius. The element is transparent and pointer-inert at + * rest, so a spent glow can simply stay parked in the overlay. + * @param {ManagedPanel} entry + * @returns {HTMLElement} + */ +function ensureGlowEl(entry) { + if (entry.glowEl?.isConnected) return entry.glowEl; + const el = document.createElement('div'); + el.className = GLOW_CLASS; + /** @type {HTMLElement} */ (overlayEl).appendChild(el); + entry.glowEl = el; + return el; +} + +/** + * Flash the agent-activity glow over a panel's TILE BODY — the visual companion + * to the rail entry's flash, so an agent action reads on the panel it actually + * touched and not only on a ~20px rail tab. + * + * No-ops unless the panel is genuinely on screen: it must be managed, visible, + * hold a live placeholder, and be the ACTIVE tab in that placeholder's group. + * Anything else has no rectangle to glow, and glowing the tile a hidden panel + * sits behind would attribute the action to the wrong panel. + * + * The rectangle is read inside a requestAnimationFrame: a glow commonly follows + * the activation that created the tile, and dockview's geometry only lands once + * the layout settles — reading synchronously would measure a zero-sized (or + * stale) group. + * + * FALLBACK (no dockview): there are no tiles, so the single mounted host + * (#panel-content) is the panel body and takes the flash directly. + * @param {string} panelId + */ +export function glowPanel(panelId) { + const api = ensureDock(); + if (!api || !overlayEl) { + if (fallbackHostEl) flashElement(fallbackHostEl); + return; + } + if (!managed.has(panelId)) return; + requestAnimationFrame(() => flashTileGlow(panelId)); +} + +/** + * Deferred half of glowPanel: re-resolve the panel (the tile may have moved, + * closed, or lost focus during the frame), copy its group content rectangle + * onto the glow element with applyGeometry's math, and fire the flash. + * @param {string} panelId + */ +function flashTileGlow(panelId) { + const dockApi = getDockApi(); + const entry = managed.get(panelId); + if (!dockApi || !overlayEl || !entry?.visible) return; + const panel = dockApi.getPanel(entry.placeholderId); + const group = panel?.group; + const content = group?.element?.querySelector('.dv-content-container'); + if (!panel || !content || group.activePanel !== panel) return; + const base = overlayEl.getBoundingClientRect(); + const r = content.getBoundingClientRect(); + const el = ensureGlowEl(entry); + el.style.left = Math.round(r.left - base.left) + 'px'; + el.style.top = Math.round(r.top - base.top) + 'px'; + el.style.width = Math.round(r.width) + 'px'; + el.style.height = Math.round(r.height) + 'px'; + flashElement(el); +} diff --git a/src/osprey/interfaces/web_terminal/static/js/panel-manager.js b/src/osprey/interfaces/web_terminal/static/js/panel-manager.js index 6ec308304..fa7a83d8c 100644 --- a/src/osprey/interfaces/web_terminal/static/js/panel-manager.js +++ b/src/osprey/interfaces/web_terminal/static/js/panel-manager.js @@ -26,7 +26,7 @@ import { applyPreset, wirePanelHeaderControls } from './panel-presets.js'; import { setPanelVisibility, setPanelFocus, registerUrlPanel } from './panel-commands.js'; import { initDockIframeAdapter, focusPanel, hidePanel, concealPanel, - setKnownServicePanels, setServerVisiblePanels, + setKnownServicePanels, setServerVisiblePanels, glowPanel, } from './dock-iframe.js'; import { initPanelPlacement, openPanelBeside, dropPanelAt, applyAgentSwitch, applyArrange, @@ -96,7 +96,8 @@ import { * @typedef {object} AgentActivityEvent * @property {'agent_activity'} type * @property {string} tool - * @property {{ kind: 'panel' | 'channel' | 'run' | 'artifact', panel?: string, detail?: string }} target + * @property {{ kind: 'panel' | 'channel' | 'run' | 'artifact' | 'config' | 'ui', + * panel?: string, detail?: string }} target * @property {number} [ts] * * @typedef {PanelFocusEvent | PanelVisibilityEvent | PanelRegisterEvent | PanelArrangeEvent @@ -208,7 +209,11 @@ export async function initPanelManager(panelId) { getActive: () => activeTabId, clearActive: clearActivePanel, renderEmpty: renderEmptyState, - glow: flashAgentGlow, + // An arranged tile is attributed on both surfaces: its rail entry flashes, + // and the tile body itself glows. Only the arrange path passes through + // here, which is the one placement verb an agent drives — the rail ⊞ and + // drag-and-drop are human gestures and never glow. + glow: (id) => { flashAgentGlow(id); glowPanel(id); }, openTerminal: openTerminalPanel, }); @@ -407,7 +412,9 @@ export async function initPanelManager(panelId) { // without a resync a client that missed one frame never converges again. // The hook fires on every open, including the first; the extra boot-time // fetch is a no-op delta. - onOpen: () => { void resyncPanelState(); }, + // Badges are restored from the history ring after the membership delta, so + // an entry the resync just re-added can carry one (restoreAgentBadges). + onOpen: () => { void resyncPanelState().then(restoreAgentBadges); }, onMessage: (raw) => { try { const data = /** @type {PanelSSEEvent} */ (raw); @@ -425,10 +432,12 @@ export async function initPanelManager(panelId) { // the gesturing client applies it locally), so an unattributed frame // can only come from an out-of-contract caller; it keeps the plain // activation. The glow runs after the switch so a just-added entry - // can flash. + // can flash, and the tile glow after the placement so it measures the + // tile the switch actually surfaced. if (data.source === 'agent') { applyAgentSwitch(data.panel); flashAgentGlow(data.panel); + glowPanel(data.panel); } else { activateTab(data.panel); } @@ -459,7 +468,13 @@ export async function initPanelManager(panelId) { // setEntryAttention; everything the rail cannot anchor falls through // to the activity-strip seam (no-op until a handler registers). const t = data.target; - if (t.kind !== 'panel' || !t.panel || !setEntryAttention(railEl, t.panel, true)) onAgentActivity(data); + if (t.kind === 'panel' && t.panel && setEntryAttention(railEl, t.panel, true, data.ts)) { + // Remember the badge's server ts so clearing it can acknowledge + // exactly this event and the reload restore can skip it. + noteBadgeTs(t.panel, data.ts); + } else { + onAgentActivity(data); + } } } catch (err) { @@ -478,7 +493,8 @@ export async function initPanelManager(panelId) { * ends up exactly where a connected one would be. * * Order matters and is pinned by the test suite: membership + rail entry - * first (the agent glow runs after the add so a just-added entry can flash); + * first (the agent glow runs after the add so a just-added entry can flash, + * and an agent-origin change reports itself on the activity strip); * then the simple-UX chat-only reveal (showing a panel while the workspace is * suppressed brings the workspace up ON that panel — {auto: true} keeps the * health guard); then, on a hide, the dock tile drop (one panel per tile — a @@ -497,7 +513,14 @@ function applyPanelVisibility(panel, visible, source) { visiblePanels.delete(panel); removeEntry(railEl, panel); } - if (source === 'agent') flashAgentGlow(panel); + // A show can glow its just-added rail entry; a hide has no entry left to + // glow, so the strip is the only surface that can report it. Both synthesize + // an activity frame — deliberately straight to the seam, past the + // agent_activity branch's rail-anchor routing, so the two halves of the + // agent's visibility vocabulary read the same way on the strip. + if (visible && source === 'agent') flashAgentGlow(panel); + if (source === 'agent') onAgentActivity({ type: 'agent_activity', ts: Date.now(), + tool: visible ? 'show_panel' : 'hide_panel', target: { kind: 'panel', panel } }); if (visible && workspaceSuppressed) { workspaceSuppressed = false; @@ -554,6 +577,85 @@ async function resyncPanelState() { */ function flashAgentGlow(panelId) { const entry = getEntry(railEl, panelId); if (entry) flashElement(entry); } +// ---- Agent-attention badges across reloads ---- +// +// A badge must outlive the page: the server's history ring is re-read on every +// SSE open (restoreAgentBadges) and any panel activity the operator has not +// seen re-badges its entry. "Seen" is an ACKNOWLEDGMENT — the server ts of the +// newest badge the operator cleared by surfacing that panel, kept per panel in +// localStorage under `agent-ack:`. +// +// Only SERVER timestamps are ever stored or compared here. The ring's `ts` is +// the web-terminal process's clock; a browser clock skewed ahead of it would +// permanently suppress real badges, and one skewed behind would resurrect +// cleared ones on every reconnect. So a badge whose frame carried no ts leaves +// the stored ack untouched rather than substituting Date.now(). + +/** Newest badge-causing server ts seen this page lifetime, per panel. + * @type {Map} */ +const badgeTs = new Map(); + +const ACK_KEY_PREFIX = 'agent-ack:'; + +/** + * Record a badge's server ts, keeping the newest. The history ring replays a + * panel's older events alongside its newest, so this must not walk backwards. + * @param {string} panelId + * @param {number} [ts] + */ +function noteBadgeTs(panelId, ts) { + if (typeof ts !== 'number') return; + const prev = badgeTs.get(panelId); + if (prev === undefined || ts > prev) badgeTs.set(panelId, ts); +} + +/** + * The acknowledged server ts for a panel. A panel that was never acknowledged + * (and a storage read that is denied or corrupt) has seen nothing, so it reads + * as -Infinity: an unknown ack RESTORES a badge, it never suppresses one. + * @param {string} panelId + * @returns {number} + */ +function ackedTs(panelId) { + let raw = null; + try { raw = localStorage.getItem(ACK_KEY_PREFIX + panelId); } catch { return -Infinity; } + const ts = raw === null ? NaN : Number(raw); + return Number.isFinite(ts) ? ts : -Infinity; +} + +/** + * Clear a panel's badge and acknowledge it up to that badge's own server ts, + * so a reload does not bring it back. With no ts on record the badge is still + * cleared but the stored ack is left exactly as it was (see the section note). + * @param {string} panelId + */ +function clearBadge(panelId) { + setEntryAttention(railEl, panelId, false); + const ts = badgeTs.get(panelId); + if (ts === undefined) return; + try { localStorage.setItem(ACK_KEY_PREFIX + panelId, String(ts)); } catch { /* storage denied */ } +} + +/** + * Re-badge rail entries for panel activity this operator has not acknowledged. + * Runs after the membership resync on every SSE open — including the first, so + * a reload restores badges — which is also why it runs after it: an entry that + * the resync delta just added can then carry a badge. + * + * Only 'panel'-kind rows with a ts can badge anything; the strip owns the rest + * and history there is its own concern (the seam is not replayed). + */ +async function restoreAgentBadges() { + let body = null; + try { body = await fetchJSON('/api/agent-activity/recent'); } catch { return; } + for (const ev of (body?.events || [])) { + const target = ev?.target; + if (target?.kind !== 'panel' || !target.panel || typeof ev.ts !== 'number') continue; + if (ev.ts <= ackedTs(target.panel)) continue; + if (setEntryAttention(railEl, target.panel, true, ev.ts)) noteBadgeTs(target.panel, ev.ts); + } +} + /** * Interaction closures handed to every rail render/append call. Routing * activation and close through here keeps a human click/"×" and an agent MCP @@ -610,8 +712,9 @@ function railOptions() { } /** The catalog label for a panel id (falls back to the id itself). + * Exported for the activity strip, which words panel actions with labels. * @param {string} id @returns {string} */ -function labelOf(id) { +export function labelOf(id) { return PANELS.find((p) => p.id === id)?.label ?? id; } @@ -911,10 +1014,11 @@ export function activateTab(panelId, { userInitiated = false, auto = false } = { if (auto && !visiblePanels.has(panelId)) return; // Past the guards the panel actually surfaces (rail click, agent focus, - // palette, dock — any source): its agent-attention badge is served, clear it. + // palette, dock — any source): its agent-attention badge is served, so clear + // it AND acknowledge it, or the next reload would restore it from history. // The guarded returns above deliberately keep the badge on panels that // refused to surface. - setEntryAttention(railEl, panelId, false); + clearBadge(panelId); // Any surfaced panel means the workspace is open — the simple-UX chat-only // suppression (if still armed) is over for this page lifetime. @@ -1015,6 +1119,15 @@ function navigatePanel(panelId, url) { state.pendingUrl = null; } +/** Human-local twin of the panel_focus SSE path: navigate a panel to `url` + * and plain-activate it. No agent glow, no server broadcast. + * @param {string} panelId + * @param {string} url */ +export function navigateAndActivatePanel(panelId, url) { + if (url) navigatePanel(panelId, url); + activateTab(panelId); +} + // ---- Iframe Management ---- /** Build + adopt the panel's iframe via panel-iframe-factory.js (which owns diff --git a/src/osprey/interfaces/web_terminal/static/js/panel-placement.js b/src/osprey/interfaces/web_terminal/static/js/panel-placement.js index a9a68a01f..f021057b3 100644 --- a/src/osprey/interfaces/web_terminal/static/js/panel-placement.js +++ b/src/osprey/interfaces/web_terminal/static/js/panel-placement.js @@ -57,7 +57,7 @@ import { TERMINAL_RAIL_ID } from './panel-catalog.js'; * @property {() => string | null} getActive - the locally surfaced panel id * @property {() => void} clearActive - drop the local active accent/stamp * @property {(message: string) => void} renderEmpty - paint the strand-proof empty pane - * @property {(id: string) => void} glow - transient agent glow on a rail entry + * @property {(id: string) => void} glow - transient agent glow on a panel's rail entry and its tile * @property {() => void} openTerminal - reopen the native terminal tile */ diff --git a/src/osprey/interfaces/web_terminal/static/js/panel-rail.js b/src/osprey/interfaces/web_terminal/static/js/panel-rail.js index 6dc5f174d..f11d69c0f 100644 --- a/src/osprey/interfaces/web_terminal/static/js/panel-rail.js +++ b/src/osprey/interfaces/web_terminal/static/js/panel-rail.js @@ -44,7 +44,9 @@ * * * State classes on an entry: `.active` (surfaced panel), `.disabled` (backend - * not healthy yet), `.agent-attention` (badge). + * not healthy yet), `.agent-attention` (badge). A badged entry also carries a + * transient `data-title-base` holding the tooltip text the badge borrowed; + * clearing the badge restores it and removes the attribute. */ import { flashElement } from '/design-system/js/highlight.js'; @@ -80,6 +82,15 @@ import { flashElement } from '/design-system/js/highlight.js'; const BUTTON_SELECTOR = '.panel-rail-button'; +/** + * Where an entry's pre-suffix tooltip is parked while the agent-attention + * badge owns the `title`. Present only for the badge's lifetime — see + * {@link applyTouchedTooltip}. + */ +const TITLE_BASE_ATTR = 'data-title-base'; + +const TOUCHED_SEPARATOR = ' · agent touched '; + // ---- Rendering ---- /** @@ -302,23 +313,68 @@ export function setEntryEnabled(railEl, panelId, enabled) { getEntry(railEl, panelId)?.classList.toggle('disabled', !enabled); } +/** + * Point an entry's tooltip at the moment the agent touched its panel, or put + * the tooltip back the way it was. + * + * The pre-suffix text is stashed on the entry for the badge's lifetime rather + * than recomputed, so the restore is exact even if the caller retitled the + * entry, and so a second event REPLACES the time instead of appending a second + * suffix. Restoring is keyed on the stash, which makes a clear on an unbadged + * entry a true no-op. + * @param {HTMLElement} entry + * @param {number | null} ts - server epoch seconds, or null to restore the base + */ +function applyTouchedTooltip(entry, ts) { + const base = entry.getAttribute(TITLE_BASE_ATTR) ?? entry.title; + if (ts === null) { + if (entry.hasAttribute(TITLE_BASE_ATTR)) { + entry.title = base; + entry.removeAttribute(TITLE_BASE_ATTR); + } + return; + } + const touchedAt = new Date(ts * 1000).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + }); + entry.setAttribute(TITLE_BASE_ATTR, base); + entry.title = `${base}${TOUCHED_SEPARATOR}${touchedAt}`; +} + /** * Set or clear the agent-attention affordance on an entry. Turning it on * toggles the persistent `agent-attention` badge class (the design system's * highlight.css draws an absolutely-positioned `::after` accent dot — class - * only, no child nodes, no layout shift) and fires the one-shot `agent-flash` - * glow via {@link flashElement}. Turning it off removes only the badge class; - * an in-flight flash is left to finish on its own `animationend`. + * only, no child nodes, no layout shift), fires the one-shot `agent-flash` + * glow via {@link flashElement}, and scrolls the entry into view: a rail + * taller than its viewport can otherwise take a badge entirely off-screen, + * which is the one case where the affordance reports nothing to the operator. + * `block: 'nearest'` leaves an already-visible entry exactly where it is. + * + * Turning it off removes the badge class and restores the tooltip; an + * in-flight flash is left to finish on its own `animationend`. * @param {HTMLElement} railEl * @param {string} panelId * @param {boolean} on + * @param {number} [ts] - the originating event's SERVER timestamp (epoch + * seconds, as the `agent_activity` SSE frames carry it), appended to the + * entry's tooltip as "· agent touched