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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions src/osprey/interfaces/artifacts/logbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
69 changes: 57 additions & 12 deletions src/osprey/interfaces/artifacts/static/js/logbook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -455,19 +492,27 @@ async function submitLogbook() {
return;
}
const data = await resp.json();
const body = document.getElementById("logbook-body");
if (body) {
body.innerHTML = `
<div style="text-align:center; padding:var(--art-space-6); color:var(--color-success);">
<div style="font-size:var(--art-text-xl); margin-bottom:var(--art-space-2);">Draft created</div>
<div style="font-size:var(--art-text-sm); color:var(--text-secondary);">
${data.draft_id}<br>
<a href="${data.url}" target="_blank" rel="noopener"
style="color:var(--color-accent-light);">Open in ARIEL</a>
</div>
</div>
`;

// 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;
Expand Down
20 changes: 19 additions & 1 deletion src/osprey/interfaces/design_system/static/css/highlight.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
}
}
6 changes: 6 additions & 0 deletions src/osprey/interfaces/web_terminal/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
83 changes: 73 additions & 10 deletions src/osprey/interfaces/web_terminal/routes/agent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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))}
Loading
Loading