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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ Compatibility is documented in release notes, not encoded in the version string.

### Fixed

- One operator's tab switches no longer rearrange every other window of the
same workspace: a human panel focus is now mirrored to the server silently
(the agent can still read where the operator is looking) instead of being
broadcast back, whose delayed echo could evict tiles the operator had open —
in the gesturing window and in every other one. Closing a tile no longer
reports its side-effect focus change either.
- Web terminal panels no longer freeze permanently — rendering but ignoring
every click — when a drag from the panel rail loses its end event (for
example the dragged entry was removed mid-drag by the agent or another
Expand Down
21 changes: 14 additions & 7 deletions src/osprey/interfaces/web_terminal/routes/panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,15 @@ async def get_panel_focus(request: Request):

@router.post("/api/panel-focus")
async def set_panel_focus(body: PanelFocusRequest, request: Request):
"""Set the active panel and broadcast a focus event via SSE.
"""Set the active panel; broadcast a focus event only for agent switches.

Attribution decides the frame's fate. An ``source: "agent"`` switch is a
command every client must apply, so it broadcasts. A source-less POST is a
human gesture REPORT (panel-commands.js's ``setPanelFocus``): the server
mirrors ``active_panel`` for the agent's gaze and broadcasts nothing —
one operator's tab switches never move another client's workspace, and
the gesturing client applies its own focus locally rather than riding an
echo.

``body.url`` (e.g. from an agent-invoked ``switch_panel`` MCP call) is
run through ``_prefix_path()`` before broadcast so a root-absolute path
Expand Down Expand Up @@ -408,12 +416,11 @@ async def set_panel_focus(body: PanelFocusRequest, request: Request):
visibility_event["source"] = body.source
request.app.state.broadcaster.broadcast(visibility_event)

event: dict = {"type": "panel_focus", "panel": body.panel}
if body.url:
event["url"] = _prefix_path(body.url)
if body.source:
event["source"] = body.source
request.app.state.broadcaster.broadcast(event)
if body.source == "agent":
event: dict = {"type": "panel_focus", "panel": body.panel, "source": body.source}
if body.url:
event["url"] = _prefix_path(body.url)
request.app.state.broadcaster.broadcast(event)
return {"status": "ok", "active_panel": body.panel}


Expand Down
25 changes: 22 additions & 3 deletions src/osprey/interfaces/web_terminal/static/js/dock-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,15 +366,34 @@ export function withEchoSuppressed(fn) {
/**
* Handle a dockview active-panel change. Skips while an echo window is open
* (a server-applied focus) and for the native terminal/workspace panels; a
* genuine human dock-tab focus of a service panel POSTs setPanelFocus, whose
* SSE echo then drives the rail + iframe through panel-manager (agent ≡ human).
* genuine human dock-tab focus of a service panel applies locally through the
* registered focus handler (rail accent, active-tab state — panel-manager's
* activateTab) and POSTs setPanelFocus as a REPORT: the server mirrors the
* active panel for the agent's gaze and broadcasts nothing for human gestures,
* so the local apply cannot ride an SSE echo.
*/
function onActivePanelChange() {
if (suppressDepth > 0) return;
const api = getDockApi();
if (!api) return;
const id = serviceIdOf(api.activePanel?.id);
if (id) setPanelFocus(id);
if (!id) return;
tileFocusHandler?.(id);
setPanelFocus(id);
}

/**
* Handler a human dock-tab focus is routed to, registered by panel-manager
* (which owns the rail accent and active-tab state the focus must update).
* Called with the focused panel's service id; must NOT POST — this module
* owns the report.
* @type {((serviceId: string) => void) | null}
*/
let tileFocusHandler = null;

/** @param {((serviceId: string) => void) | null} fn */
export function setTileFocusHandler(fn) {
tileFocusHandler = fn;
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/osprey/interfaces/web_terminal/static/js/dock-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import { TERMINAL_RAIL_ID } from './panel-catalog.js';
import { registerContribHost, unregisterContribHost } from './tile-header-contrib.js';
import { PLACEHOLDER_PREFIX } from './dock-reconcile.js';
import { withEchoSuppressed } from './dock-sync.js';

/** defaultTabComponent name registered on the dockview instance. */
export const OSPREY_TAB_COMPONENT = 'osprey-tile-tab';
Expand Down Expand Up @@ -168,7 +169,11 @@ class TileTab {
close.addEventListener('click', (e) => {
if (e.defaultPrevented) return;
e.preventDefault();
this._api?.close?.();
// The removal makes dockview auto-activate a surviving tile; that is a
// side effect of the close, not a human focus gesture, so it must not
// reach the focus reporter — the same suppression retireTile applies to
// its own removal.
withEchoSuppressed(() => this._api?.close?.());
});
actions.appendChild(close);
root.appendChild(actions);
Expand Down
25 changes: 16 additions & 9 deletions src/osprey/interfaces/web_terminal/static/js/panel-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { createPanelIframe } from './panel-iframe-factory.js';
import {
PANELS, TERMINAL_RAIL_ID, TERMINAL_RAIL_LABEL, DEFAULT_PANEL_FALLBACK,
} from './panel-catalog.js';
import { initDockSync, withEchoSuppressed, setTileCloseHandler } from './dock-sync.js';
import { initDockSync, withEchoSuppressed, setTileCloseHandler, setTileFocusHandler } from './dock-sync.js';
import { initRailDrag, railDragStart, railDragEnd } from './rail-drag.js';
import { startHealthPolling as startPolling } from './panel-health.js';
import { openTerminalPanel, closeTerminalPanel } from './dock-workspace.js';
Expand Down Expand Up @@ -301,6 +301,12 @@ export async function initPanelManager(panelId) {
// active state here, never POST.
setTileCloseHandler(vacatePanel);

// A human focusing a dock tab applies locally through activateTab (rail
// accent, active-tab state, iframe reveal). dock-sync owns the mirror POST,
// and the server does not echo human focus back, so this registration is the
// only thing that keeps the gesturing client's own rail in step.
setTileFocusHandler(activateTab);

// Hand the adapter a live reference to the visible set (it prunes restored
// placeholders of server-closed panels), then finalize the registry — the
// adapter may now prune any restored placeholder whose service no longer
Expand Down Expand Up @@ -407,18 +413,19 @@ export async function initPanelManager(panelId) {
const data = /** @type {PanelSSEEvent} */ (raw);

if (data.type === 'panel_focus' && data.panel) {
// A switch — agent or human — honor unconditionally. It also ends the
// simple-UX chat-only suppression, even when the activation still
// refuses (unhealthy panel): the intent to surface the workspace is
// clear, so the next health settle may fill the slot.
// A broadcast switch also ends the simple-UX chat-only suppression,
// even when the activation still refuses (unhealthy panel): the
// intent to surface the workspace is clear, so the next health
// settle may fill the slot.
workspaceSuppressed = false;
if (data.url) navigatePanel(data.panel, data.url);
// An AGENT switch is polite: focus the panel's own tile, or open one
// beside the operator's — never take a tile away (applyAgentSwitch).
// Every other frame is the echo of a human gesture (rail click, dock
// tab focus) whose takeover semantics are the operator's own choice,
// so it keeps the plain activation. The glow runs after the switch so
// a just-added entry can flash.
// Human focus is never broadcast (the server mirrors it silently and
// 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.
if (data.source === 'agent') {
applyAgentSwitch(data.panel);
flashAgentGlow(data.panel);
Expand Down
7 changes: 5 additions & 2 deletions tests/e2e/web_terminals/test_prefix_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,10 @@ async def capturing_request(*, method, url, headers, content):

def test_panel_focus_relative_url_gets_prefixed(self, alice_client):
app, client = alice_client
resp = client.post("/api/panel-focus", json={"panel": "my-dash", "url": "/panel/my-dash"})
resp = client.post(
"/api/panel-focus",
json={"panel": "my-dash", "url": "/panel/my-dash", "source": "agent"},
)
assert resp.status_code == 200
event = app.state.broadcaster.broadcast.call_args[0][0]
assert event["url"] == f"{_PREFIX}/panel/my-dash"
Expand All @@ -416,7 +419,7 @@ def test_panel_focus_absolute_url_passes_through_unchanged(self, alice_client):
app, client = alice_client
resp = client.post(
"/api/panel-focus",
json={"panel": "my-dash", "url": "https://grafana.lan:3000/d/abc"},
json={"panel": "my-dash", "url": "https://grafana.lan:3000/d/abc", "source": "agent"},
)
assert resp.status_code == 200
event = app.state.broadcaster.broadcast.call_args[0][0]
Expand Down
2 changes: 1 addition & 1 deletion tests/interfaces/web_terminal/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def test_set_panel_focus_broadcasts_event(self, client):
# Subscribe before sending
q = broadcaster.subscribe()

client.post("/api/panel-focus", json={"panel": "artifacts"})
client.post("/api/panel-focus", json={"panel": "artifacts", "source": "agent"})

# The event should be in the queue
assert not q.empty()
Expand Down
8 changes: 6 additions & 2 deletions tests/interfaces/web_terminal/test_panels_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -935,8 +935,12 @@ def test_server_sse_focus_is_applied_without_posting_back(tmp_path, chromium_bro
page.wait_for_timeout(800)

posts = _track_panel_posts(page)
# Server-driven focus back to the already-docked data-viz.
r = requests.post(f"{base_url}/api/panel-focus", json={"panel": "data-viz"})
# Server-driven focus back to the already-docked data-viz. The source
# tag is what makes the server broadcast at all — a source-less human
# report is mirrored without a frame.
r = requests.post(
f"{base_url}/api/panel-focus", json={"panel": "data-viz", "source": "agent"}
)
assert r.status_code == 200

# It is applied — data-viz's tile takes the active focus (artifacts keeps
Expand Down
95 changes: 84 additions & 11 deletions tests/interfaces/web_terminal/test_panels_collab_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,17 +569,9 @@ def test_arrange_converges_from_two_layouts_without_a_report_loop(tmp_path, chro
_open_beside(page_b, "scope")
_open_beside(page_b, "data-viz")

# Opening a tile reports the focus, and a focus IS broadcast — every
# client applies it with its own takeover semantics, so B's setup has
# meanwhile swapped the panel in A's single tile. A's start layout is
# therefore established last, with a plain rail click (which only moves
# focus on B, where artifacts already holds a tile of its own). Wait for
# B's LAST focus to have landed on A first, or the click races it and
# the takeover happens in the wrong order.
expect(
page_a.locator('button.panel-rail-button[data-panel-id="data-viz"].active')
).to_have_count(1, timeout=10_000)
page_a.locator('button.panel-rail-button[data-panel-id="artifacts"]').click()
# B's open-beside gestures report their focus but broadcast nothing —
# human focus stays local — so A's boot layout (artifacts alone) is
# untouched by B's setup and needs no re-establishing.
_wait_for_client_tiles(page_a, ["artifacts"])
assert _client_open_tiles(page_b) == ["artifacts", "scope", "data-viz"], _client_open_tiles(
page_b
Expand Down Expand Up @@ -1067,3 +1059,84 @@ def test_occupancy_read_back_distinguishes_unknown_from_empty(tmp_path, chromium
assert isinstance(state["open_tiles_age_s"], float), state

dockless.close()


# ===========================================================================
# (e) Human gestures stay local: no focus command leaks, no cross-client echo
# ===========================================================================


def test_tile_close_with_two_tiles_commands_nothing(tmp_path, chromium_browser):
"""A tile "×" with a SECOND service tile open still commands nothing.

The single-tile variant is pinned in the sibling suite; with two tiles the
close makes dockview auto-activate the surviving service tile, and that
activation runs outside any human focus gesture — it must stay inside the
echo guard exactly like retireTile's removal does, or the close leaks a
``setPanelFocus`` command the design says it must not send. The assertion
window is generous because the leaked POST arrives asynchronously, well
after the tab is gone.
"""
workspace = tmp_path / "_agent_data"
workspace.mkdir()

with _live_server(workspace, enabled_panels={"artifacts"}, custom_panels=[_DATA_VIZ]) as (
base_url,
_app,
):
page = _open_page(chromium_browser, base_url)
_open_beside(page, "data-viz")
_wait_for_client_tiles(page, ["artifacts", "data-viz"])
page.wait_for_timeout(800) # drain boot/open-beside traffic

posts = _track_panel_posts(page)
_close_tile(page, "data-viz")
page.wait_for_timeout(1500) # the leak arrives asynchronously

commands = [e for e in _endpoints(posts) if e != "panel-layout"]
assert commands == [], f"a human tile close must not command, got {commands}"

page.close()


def test_human_focus_stays_local_to_the_gesturing_client(tmp_path, chromium_browser):
"""One operator's focus gestures never move another client's workspace.

Client B opens a second tile (its activation tail reports the focus via
``setPanelFocus`` — a human gesture). Client A must keep its own active
panel and its own tile set: human focus is a REPORT the server mirrors for
the agent's benefit, never a command broadcast back to other clients
(the contract stated in panel-commands.js and the collaborative-panels
design). The server-side mirror is asserted off the same gesture, so this
cannot pass by the report silently not landing.
"""
workspace = tmp_path / "_agent_data"
workspace.mkdir()

with _live_server(workspace, enabled_panels={"artifacts"}, custom_panels=[_DATA_VIZ]) as (
base_url,
_app,
):
page_a = _open_page(chromium_browser, base_url)
_wait_for_client_tiles(page_a, ["artifacts"])
page_b = _open_page(chromium_browser, base_url)
_wait_for_client_tiles(page_b, ["artifacts"])
assert _active_rail_id(page_a) == "artifacts"

# B's human gesture: open data-viz beside (activation reports focus).
_open_beside(page_b, "data-viz")
_wait_for_client_tiles(page_b, ["artifacts", "data-viz"])

# The server mirrors the gesture for the agent's gaze...
_wait_for_active(base_url, "data-viz")
page_a.wait_for_timeout(1000) # ...but no echo may reach client A:

assert _active_rail_id(page_a) == "artifacts", (
"client B's human focus gesture moved client A's active panel"
)
assert _client_open_tiles(page_a) == ["artifacts"], (
"client B's human focus gesture changed client A's tiles"
)

page_a.close()
page_b.close()
17 changes: 13 additions & 4 deletions tests/interfaces/web_terminal/test_panels_prefix.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ def test_root_absolute_url_prefixed_under_user(self, monkeypatch):
monkeypatch.setenv("OSPREY_TERMINAL_USER", "alice")
client = self._client()

resp = client.post("/api/panel-focus", json={"panel": "ariel", "url": "/panel/ariel"})
resp = client.post(
"/api/panel-focus",
json={"panel": "ariel", "url": "/panel/ariel", "source": "agent"},
)

assert resp.status_code == 200
event = client.app.state.broadcaster.broadcast.call_args[0][0]
Expand All @@ -214,7 +217,7 @@ def test_absolute_url_passed_through_unchanged(self, monkeypatch):

resp = client.post(
"/api/panel-focus",
json={"panel": "ariel", "url": "https://grafana.lan:3000/d/abc"},
json={"panel": "ariel", "url": "https://grafana.lan:3000/d/abc", "source": "agent"},
)

assert resp.status_code == 200
Expand All @@ -225,7 +228,10 @@ def test_protocol_relative_url_passed_through_unchanged(self, monkeypatch):
monkeypatch.setenv("OSPREY_TERMINAL_USER", "alice")
client = self._client()

resp = client.post("/api/panel-focus", json={"panel": "ariel", "url": "//evil.example/x"})
resp = client.post(
"/api/panel-focus",
json={"panel": "ariel", "url": "//evil.example/x", "source": "agent"},
)

assert resp.status_code == 200
event = client.app.state.broadcaster.broadcast.call_args[0][0]
Expand All @@ -235,7 +241,10 @@ def test_root_absolute_url_empty_prefix_unchanged(self, monkeypatch):
monkeypatch.delenv("OSPREY_TERMINAL_USER", raising=False)
client = self._client()

resp = client.post("/api/panel-focus", json={"panel": "ariel", "url": "/panel/ariel"})
resp = client.post(
"/api/panel-focus",
json={"panel": "ariel", "url": "/panel/ariel", "source": "agent"},
)

assert resp.status_code == 200
event = client.app.state.broadcaster.broadcast.call_args[0][0]
Expand Down
Loading