breakglass UI v2: attachable sessions (tmux model) + mobile-first redesign
Full audit-driven rework. Keeps the proven SSE-translation + verb logic; everything else upgraded for phone-primary use.
Backend — server owns the session, clients attach (Viktor's tmux idea):
- session.py: SessionManager + Session with an event log, subscriber pub/sub, and turns that run DETACHED (keep going if the client disconnects).
- GET /api/session/{id}/stream = attach (SSE): replays the transcript then tails live; per-event id: lines so an EventSource auto-reconnect resumes from Last-Event-ID (free re-attach). POST /{id}/prompt starts a detached turn; POST /{id}/cancel = Stop. Replaces the old one-shot /api/chat.
- agent_session trimmed to the argv + translate_event helpers; 21 new/updated tests (replay, Last-Event-ID resume, broadcast, detached turn, resume, cancel, routes) — 53 green.
Frontend — mobile-first via the frontend-design skill (emergency-console aesthetic):
- EventSource attach (native auto-reconnect, zero client reconnect logic); transcript.js folds events->messages with id-dedupe so replays never double-render (30 unit assertions).
- Installable PWA: manifest + icons (wrench/break-glass mark) + apple-mobile-web-app meta + theme-color; viewport-fit=cover + safe-area; 100dvh; 16px composer (no iOS zoom).
- One-tap diagnosis presets (Triage / Memory-OOM / Disk / Services / QEMU-wedged) mapped to the devvm's real failure modes; Stop button while a turn runs.
- Foldable VM-control sheet, cycle the dominant recovery action w/ confirm, output capped 46vh.
- a11y: fixed --ink-faint contrast 3.6:1 -> 6.1:1 (WCAG AA); >=44px tap targets. Deleted the obsolete fetch-reader sse.js (EventSource replaces it).
Verified: 53 backend tests + 30 transcript assertions; Playwright @390x844 (input on-screen y=721-821, presets/sheet/fold/cap); local integration smoke vs the real backend (attach->caught-up, 404, verbs, PWA served).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
@ -1,26 +1,13 @@
|
||||||
"""Drive the breakglass Claude agent and stream its work to the browser.
|
"""Claude CLI argv + stream-json → UI-event translation for the breakglass agent.
|
||||||
|
|
||||||
Each chat turn runs ``claude -p --output-format stream-json`` in the session's
|
The session lifecycle (running turns, attaching clients) lives in ``session.py``;
|
||||||
persistent workspace; the first turn opens the session with ``--session-id`` and
|
this module is just the two helpers it builds on:
|
||||||
later turns ``--resume`` it, so the conversation has memory across turns. The
|
* ``_turn_argv`` — the no-shell list argv for one ``claude -p`` turn.
|
||||||
CLI's JSON events are translated to a small, stable SSE vocabulary the UI
|
* ``translate_event`` — map a raw stream-json event to the small UI vocabulary
|
||||||
renders (``session`` / ``text`` / ``tool`` / ``result`` / ``error``) — we do not
|
(session / text / tool / result), dropping the hook/thinking-token noise.
|
||||||
leak the raw event firehose to the client.
|
|
||||||
|
|
||||||
Subprocesses use ``asyncio.create_subprocess_exec`` (list argv, no shell): the
|
|
||||||
prompt and ids are argv elements, never interpreted by a shell.
|
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from subprocess import PIPE
|
|
||||||
from typing import AsyncIterator
|
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
# Sessions we've already opened (so the next turn resumes instead of re-creating).
|
|
||||||
_started: set[str] = set()
|
|
||||||
|
|
||||||
|
|
||||||
def _turn_argv(session_id: str, prompt: str, resume: bool, model: str) -> list[str]:
|
def _turn_argv(session_id: str, prompt: str, resume: bool, model: str) -> list[str]:
|
||||||
argv = [
|
argv = [
|
||||||
|
|
@ -66,7 +53,7 @@ def translate_event(obj: dict) -> dict | None:
|
||||||
})
|
})
|
||||||
if not events:
|
if not events:
|
||||||
return None
|
return None
|
||||||
# The server flattens a "batch" into individual SSE frames.
|
# The session log flattens a "batch" into individual events.
|
||||||
return events[0] if len(events) == 1 else {"kind": "batch", "events": events}
|
return events[0] if len(events) == 1 else {"kind": "batch", "events": events}
|
||||||
|
|
||||||
if etype == "result":
|
if etype == "result":
|
||||||
|
|
@ -78,68 +65,3 @@ def translate_event(obj: dict) -> dict | None:
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def run_turn(
|
|
||||||
session_id: str, prompt: str, model: str | None = None
|
|
||||||
) -> AsyncIterator[dict]:
|
|
||||||
"""Run one chat turn, yielding translated UI events as they arrive."""
|
|
||||||
resume = session_id in _started
|
|
||||||
model = model or config.DEFAULT_MODEL
|
|
||||||
workspace = os.path.join(config.SESSIONS_DIR, session_id)
|
|
||||||
os.makedirs(workspace, exist_ok=True)
|
|
||||||
|
|
||||||
argv = _turn_argv(session_id, prompt, resume, model)
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*argv, cwd=workspace, stdout=PIPE, stderr=PIPE,
|
|
||||||
)
|
|
||||||
_started.add(session_id)
|
|
||||||
assert proc.stdout is not None and proc.stderr is not None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async def _pump() -> AsyncIterator[dict]:
|
|
||||||
async for raw in proc.stdout:
|
|
||||||
line = raw.decode(errors="replace").strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
ev = translate_event(obj)
|
|
||||||
if ev is None:
|
|
||||||
continue
|
|
||||||
if ev.get("kind") == "batch":
|
|
||||||
for sub in ev["events"]:
|
|
||||||
yield sub
|
|
||||||
else:
|
|
||||||
yield ev
|
|
||||||
|
|
||||||
async for ev in _with_timeout(_pump(), config.TURN_TIMEOUT_SECONDS):
|
|
||||||
yield ev
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
await proc.wait()
|
|
||||||
yield {"kind": "error", "error": f"turn timed out after {config.TURN_TIMEOUT_SECONDS}s"}
|
|
||||||
return
|
|
||||||
|
|
||||||
await proc.wait()
|
|
||||||
if proc.returncode not in (0, None):
|
|
||||||
err = (await proc.stderr.read()).decode(errors="replace")
|
|
||||||
yield {"kind": "error", "error": err.strip()[:500] or f"exit {proc.returncode}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _with_timeout(agen: AsyncIterator[dict], timeout: float) -> AsyncIterator[dict]:
|
|
||||||
"""Yield from an async generator but raise TimeoutError if the WHOLE turn
|
|
||||||
exceeds ``timeout`` seconds (a wedged agent shouldn't stream forever)."""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
deadline = loop.time() + timeout
|
|
||||||
it = agen.__aiter__()
|
|
||||||
while True:
|
|
||||||
remaining = deadline - loop.time()
|
|
||||||
if remaining <= 0:
|
|
||||||
raise asyncio.TimeoutError
|
|
||||||
try:
|
|
||||||
yield await asyncio.wait_for(it.__anext__(), timeout=remaining)
|
|
||||||
except StopAsyncIteration:
|
|
||||||
return
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ MAX_CONCURRENT_TURNS = int(os.environ.get("BREAKGLASS_MAX_CONCURRENT_TURNS", "2"
|
||||||
TURN_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_TURN_TIMEOUT_SECONDS", "1800"))
|
TURN_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_TURN_TIMEOUT_SECONDS", "1800"))
|
||||||
# A single PVE power verb must return fast; a wedged host shouldn't hang the UI.
|
# A single PVE power verb must return fast; a wedged host shouldn't hang the UI.
|
||||||
PVE_VERB_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_PVE_VERB_TIMEOUT_SECONDS", "120"))
|
PVE_VERB_TIMEOUT_SECONDS = int(os.environ.get("BREAKGLASS_PVE_VERB_TIMEOUT_SECONDS", "120"))
|
||||||
|
# How long an idle attach stream waits before emitting an SSE keepalive comment
|
||||||
|
# (keeps proxies/CDN from closing the long-lived connection).
|
||||||
|
SSE_KEEPALIVE_SECONDS = int(os.environ.get("BREAKGLASS_SSE_KEEPALIVE_SECONDS", "20"))
|
||||||
|
|
||||||
# Auth. The app sits behind the ingress `auth = "required"` resilience proxy
|
# Auth. The app sits behind the ingress `auth = "required"` resilience proxy
|
||||||
# (Authentik SSO, basic-auth fallback when Authentik is down). We additionally
|
# (Authentik SSO, basic-auth fallback when Authentik is down). We additionally
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,44 @@
|
||||||
"""Breakglass FastAPI app — the in-cluster emergency recovery UI.
|
"""Breakglass FastAPI app — the in-cluster emergency recovery UI.
|
||||||
|
|
||||||
|
The chat uses the tmux/attach model (see session.py): the server owns the
|
||||||
|
conversation; clients attach over SSE and the turn keeps running if they
|
||||||
|
disconnect.
|
||||||
|
|
||||||
Routes:
|
Routes:
|
||||||
GET /health — liveness (no auth)
|
GET /health — liveness (no auth)
|
||||||
GET / — the single-page UI (static)
|
GET / — the single-page UI (static)
|
||||||
POST /api/session — open a chat session, returns {session_id}
|
POST /api/session — create a session, returns {session_id}
|
||||||
POST /api/chat — run one turn, streams SSE events (text/tool/result)
|
GET /api/session/{id}/stream — ATTACH (SSE): replay + live tail
|
||||||
POST /api/pve/{verb} — LLM-independent PVE power verb (manual buttons)
|
POST /api/session/{id}/prompt — run a turn (detached; survives disconnect)
|
||||||
GET /api/pve/verbs — list allowed verbs + which mutate
|
POST /api/session/{id}/cancel — stop the in-flight turn
|
||||||
|
GET /api/pve/verbs — list allowed verbs + which mutate
|
||||||
|
POST /api/pve/{verb} — LLM-independent PVE power verb (buttons)
|
||||||
|
|
||||||
Everything under /api requires auth (edge Authentik header or bearer token).
|
Everything under /api requires auth (edge Authentik header or bearer token).
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException
|
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import agent_session, config, pve
|
from . import config, pve
|
||||||
from .auth import require_auth
|
from .auth import require_auth
|
||||||
|
from .session import SessionManager, attach_stream
|
||||||
|
|
||||||
app = FastAPI(title="Claude Breakglass")
|
app = FastAPI(title="Claude Breakglass")
|
||||||
|
|
||||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
||||||
|
manager = SessionManager()
|
||||||
|
|
||||||
|
|
||||||
class SessionResponse(BaseModel):
|
class SessionResponse(BaseModel):
|
||||||
session_id: str
|
session_id: str
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class PromptRequest(BaseModel):
|
||||||
session_id: str
|
|
||||||
prompt: str = Field(..., min_length=1)
|
prompt: str = Field(..., min_length=1)
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
||||||
|
|
@ -44,30 +50,53 @@ async def health():
|
||||||
|
|
||||||
@app.post("/api/session", response_model=SessionResponse)
|
@app.post("/api/session", response_model=SessionResponse)
|
||||||
async def open_session(_identity: str = Depends(require_auth)):
|
async def open_session(_identity: str = Depends(require_auth)):
|
||||||
# Claude wants a UUID for --session-id.
|
return SessionResponse(session_id=manager.create().id)
|
||||||
return SessionResponse(session_id=str(uuid.uuid4()))
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chat")
|
@app.get("/api/session/{session_id}/stream")
|
||||||
async def chat(req: ChatRequest, _identity: str = Depends(require_auth)):
|
async def attach(
|
||||||
"""Stream one chat turn as Server-Sent Events. The browser reads the
|
session_id: str,
|
||||||
response body incrementally (fetch + ReadableStream)."""
|
_identity: str = Depends(require_auth),
|
||||||
|
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||||
async def _sse():
|
):
|
||||||
try:
|
"""Attach to a session (SSE). Replays the conversation so far, then tails
|
||||||
async for ev in agent_session.run_turn(req.session_id, req.prompt, req.model):
|
live. On an EventSource auto-reconnect the browser sends Last-Event-ID, so we
|
||||||
yield f"data: {json.dumps(ev)}\n\n"
|
replay only what was missed."""
|
||||||
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
|
session = manager.get(session_id)
|
||||||
yield f"data: {json.dumps({'kind': 'error', 'error': str(exc)[:500]})}\n\n"
|
if session is None:
|
||||||
yield f"data: {json.dumps({'kind': 'done'})}\n\n"
|
raise HTTPException(status_code=404, detail="session not found")
|
||||||
|
try:
|
||||||
|
leid = int(last_event_id) if last_event_id is not None else None
|
||||||
|
except ValueError:
|
||||||
|
leid = None
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_sse(),
|
attach_stream(session, leid),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/session/{session_id}/prompt")
|
||||||
|
async def prompt(session_id: str, req: PromptRequest, _identity: str = Depends(require_auth)):
|
||||||
|
"""Start a turn. It runs DETACHED (keeps going if the client disconnects);
|
||||||
|
output is delivered via the attach stream, not this response."""
|
||||||
|
session = manager.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session not found")
|
||||||
|
if not session.start_turn(req.prompt, req.model):
|
||||||
|
raise HTTPException(status_code=409, detail="a turn is already running")
|
||||||
|
return {"status": "started"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/session/{session_id}/cancel")
|
||||||
|
async def cancel(session_id: str, _identity: str = Depends(require_auth)):
|
||||||
|
session = manager.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session not found")
|
||||||
|
cancelled = await session.cancel()
|
||||||
|
return {"cancelled": cancelled}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/pve/verbs")
|
@app.get("/api/pve/verbs")
|
||||||
async def pve_verbs(_identity: str = Depends(require_auth)):
|
async def pve_verbs(_identity: str = Depends(require_auth)):
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
201
app/breakglass/session.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
"""Attachable server-side sessions — the tmux model for the breakglass chat.
|
||||||
|
|
||||||
|
Instead of the client owning conversation state, the SERVER owns it and clients
|
||||||
|
*attach*. A turn runs as a detached task that keeps going if the client
|
||||||
|
disconnects (you can background the phone / hit a tunnel blip and the agent
|
||||||
|
keeps working); its output is appended to a per-session event log and broadcast
|
||||||
|
to every attached subscriber. A client attaches over SSE, gets the log replayed
|
||||||
|
(or only the part it missed, via Last-Event-ID), then tails live — exactly like
|
||||||
|
re-attaching to a tmux session. ``EventSource`` reconnects natively, so the
|
||||||
|
"re-attach" needs zero client logic.
|
||||||
|
|
||||||
|
This module owns the lifecycle; ``agent_session`` still provides the claude
|
||||||
|
argv + the stream-json→UI-event translation (all subprocesses use the no-shell
|
||||||
|
list-argv form), and ``config`` the knobs.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from subprocess import PIPE
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from . import agent_session, config
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""One conversation. Owns the replay log + live subscribers + the in-flight
|
||||||
|
turn. The claude ``session_id`` is reused with ``--resume`` so the agent
|
||||||
|
keeps its own context across turns."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str):
|
||||||
|
self.id = session_id
|
||||||
|
# The replay log: every UI event, in order. Index in the list IS the
|
||||||
|
# SSE event id, so a reconnecting client replays only what it missed.
|
||||||
|
self.events: list[dict] = []
|
||||||
|
self._subscribers: set[asyncio.Queue] = set()
|
||||||
|
self._turn: asyncio.Task | None = None
|
||||||
|
self._proc: asyncio.subprocess.Process | None = None
|
||||||
|
self._started = False # has claude opened this session id yet?
|
||||||
|
|
||||||
|
# ── event log + fan-out ────────────────────────────────────────────────
|
||||||
|
def add_event(self, event: dict) -> dict:
|
||||||
|
"""Append an event to the log and broadcast it to attached clients."""
|
||||||
|
stored = {**event, "id": len(self.events)}
|
||||||
|
self.events.append(stored)
|
||||||
|
for q in list(self._subscribers):
|
||||||
|
q.put_nowait(stored)
|
||||||
|
return stored
|
||||||
|
|
||||||
|
def subscribe(self) -> asyncio.Queue:
|
||||||
|
q: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._subscribers.add(q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
def unsubscribe(self, q: asyncio.Queue) -> None:
|
||||||
|
self._subscribers.discard(q)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def turn_active(self) -> bool:
|
||||||
|
return self._turn is not None and not self._turn.done()
|
||||||
|
|
||||||
|
# ── running a turn (detached from any client) ──────────────────────────
|
||||||
|
def start_turn(self, prompt: str, model: str | None = None) -> bool:
|
||||||
|
"""Kick off a turn as a background task. Returns False if one is already
|
||||||
|
running (one turn at a time per session)."""
|
||||||
|
if self.turn_active:
|
||||||
|
return False
|
||||||
|
self.add_event({"kind": "user", "text": prompt})
|
||||||
|
self._turn = asyncio.create_task(self._run_turn(prompt, model))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _run_turn(self, prompt: str, model: str | None) -> None:
|
||||||
|
model = model or config.DEFAULT_MODEL
|
||||||
|
resume = self._started
|
||||||
|
argv = agent_session._turn_argv(self.id, prompt, resume, model)
|
||||||
|
try:
|
||||||
|
self._proc = await asyncio.create_subprocess_exec(
|
||||||
|
*argv, cwd=_workspace_for(self.id), stdout=PIPE, stderr=PIPE,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.add_event({"kind": "error", "error": f"could not start agent: {exc}"})
|
||||||
|
self.add_event({"kind": "turn_end"})
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
assert self._proc.stdout is not None and self._proc.stderr is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async def _pump():
|
||||||
|
async for raw in self._proc.stdout:
|
||||||
|
line = raw.decode(errors="replace").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
ev = agent_session.translate_event(obj)
|
||||||
|
if ev is None:
|
||||||
|
continue
|
||||||
|
if ev.get("kind") == "batch":
|
||||||
|
for sub in ev["events"]:
|
||||||
|
self.add_event(sub)
|
||||||
|
else:
|
||||||
|
self.add_event(ev)
|
||||||
|
|
||||||
|
await asyncio.wait_for(_pump(), timeout=config.TURN_TIMEOUT_SECONDS)
|
||||||
|
await self._proc.wait()
|
||||||
|
if self._proc.returncode not in (0, None):
|
||||||
|
err = (await self._proc.stderr.read()).decode(errors="replace")
|
||||||
|
self.add_event({"kind": "error", "error": err.strip()[:500] or f"exit {self._proc.returncode}"})
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await self._kill_proc()
|
||||||
|
self.add_event({"kind": "error", "error": f"turn timed out after {config.TURN_TIMEOUT_SECONDS}s"})
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await self._kill_proc()
|
||||||
|
self.add_event({"kind": "cancelled"})
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._proc = None
|
||||||
|
self.add_event({"kind": "turn_end"})
|
||||||
|
|
||||||
|
async def _kill_proc(self) -> None:
|
||||||
|
if self._proc and self._proc.returncode is None:
|
||||||
|
try:
|
||||||
|
self._proc.kill()
|
||||||
|
await self._proc.wait()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def cancel(self) -> bool:
|
||||||
|
"""Stop the in-flight turn. Returns True if a turn was cancelled."""
|
||||||
|
if not self.turn_active:
|
||||||
|
return False
|
||||||
|
await self._kill_proc()
|
||||||
|
if self._turn:
|
||||||
|
self._turn.cancel()
|
||||||
|
try:
|
||||||
|
await self._turn
|
||||||
|
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_for(session_id: str) -> str:
|
||||||
|
path = os.path.join(config.SESSIONS_DIR, session_id)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Holds all live sessions. The breakglass is single-operator, so callers
|
||||||
|
typically reuse one persistent session; multiple are still supported."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sessions: dict[str, Session] = {}
|
||||||
|
|
||||||
|
def create(self) -> Session:
|
||||||
|
sid = str(uuid.uuid4())
|
||||||
|
s = Session(sid)
|
||||||
|
self.sessions[sid] = s
|
||||||
|
return s
|
||||||
|
|
||||||
|
def get(self, session_id: str) -> Session | None:
|
||||||
|
return self.sessions.get(session_id)
|
||||||
|
|
||||||
|
def get_or_create(self, session_id: str | None) -> Session:
|
||||||
|
if session_id and session_id in self.sessions:
|
||||||
|
return self.sessions[session_id]
|
||||||
|
return self.create()
|
||||||
|
|
||||||
|
|
||||||
|
async def attach_stream(session: Session, last_event_id: int | None) -> AsyncIterator[str]:
|
||||||
|
"""Yield SSE frames for an attached client: first the replay (everything, or
|
||||||
|
only events after ``last_event_id`` on a reconnect), then live events as they
|
||||||
|
arrive. Each frame carries an ``id:`` so EventSource resumes precisely."""
|
||||||
|
q = session.subscribe()
|
||||||
|
try:
|
||||||
|
start = 0 if last_event_id is None else last_event_id + 1
|
||||||
|
backlog = session.events[start:]
|
||||||
|
for ev in backlog:
|
||||||
|
yield _sse_frame(ev)
|
||||||
|
# Tell the client the replay is done and it's now live.
|
||||||
|
yield "event: caught-up\ndata: {}\n\n"
|
||||||
|
|
||||||
|
seen = backlog[-1]["id"] if backlog else (last_event_id if last_event_id is not None else -1)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ev = await asyncio.wait_for(q.get(), timeout=config.SSE_KEEPALIVE_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ": keepalive\n\n" # comment frame keeps the connection warm
|
||||||
|
continue
|
||||||
|
if ev["id"] <= seen:
|
||||||
|
continue
|
||||||
|
seen = ev["id"]
|
||||||
|
yield _sse_frame(ev)
|
||||||
|
finally:
|
||||||
|
session.unsubscribe(q)
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_frame(event: dict) -> str:
|
||||||
|
return f"id: {event['id']}\ndata: {json.dumps(event)}\n\n"
|
||||||
BIN
app/breakglass/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
app/breakglass/static/assets/index-BoWC1Onq.css
Normal file
6
app/breakglass/static/assets/index-CLbKo1Yx.js
Normal file
BIN
app/breakglass/static/icon-192.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
app/breakglass/static/icon-512.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
64
app/breakglass/static/icon.svg
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="devvm breakglass">
|
||||||
|
<defs>
|
||||||
|
<!-- layered near-black surface, matching the app theme -->
|
||||||
|
<radialGradient id="bg" cx="68%" cy="22%" r="92%">
|
||||||
|
<stop offset="0%" stop-color="#12303a"/>
|
||||||
|
<stop offset="42%" stop-color="#0b0f14"/>
|
||||||
|
<stop offset="100%" stop-color="#06080b"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="steel" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#7df0f3"/>
|
||||||
|
<stop offset="55%" stop-color="#3dd1d6"/>
|
||||||
|
<stop offset="100%" stop-color="#1f6f72"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- rounded-square field (safe for maskable: art kept within central ~80%) -->
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<rect x="6" y="6" width="500" height="500" rx="108" fill="none" stroke="#1c2530" stroke-width="3"/>
|
||||||
|
<!-- faint scanline texture -->
|
||||||
|
<g opacity="0.05" stroke="#ffffff" stroke-width="2">
|
||||||
|
<line x1="0" y1="148" x2="512" y2="148"/>
|
||||||
|
<line x1="0" y1="220" x2="512" y2="220"/>
|
||||||
|
<line x1="0" y1="292" x2="512" y2="292"/>
|
||||||
|
<line x1="0" y1="364" x2="512" y2="364"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- fracture burst (amber): the "break the glass" radiating cracks -->
|
||||||
|
<g stroke="#f5b657" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
fill="none" opacity="0.92" filter="url(#glow)">
|
||||||
|
<path d="M256 256 L142 132"/>
|
||||||
|
<path d="M256 256 L120 250"/>
|
||||||
|
<path d="M256 256 L150 372"/>
|
||||||
|
<path d="M256 256 L372 380"/>
|
||||||
|
<path d="M256 256 L392 246"/>
|
||||||
|
<path d="M256 256 L360 138"/>
|
||||||
|
<!-- cross-cracks -->
|
||||||
|
<path d="M186 196 L150 250"/>
|
||||||
|
<path d="M210 320 L172 318" opacity="0.7"/>
|
||||||
|
<path d="M326 318 L356 350" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- wrench, struck across the burst (cyan steel) -->
|
||||||
|
<g filter="url(#glow)">
|
||||||
|
<path fill="url(#steel)" stroke="#0e3133" stroke-width="6" stroke-linejoin="round"
|
||||||
|
d="M344 150
|
||||||
|
a62 62 0 0 0 -82 76
|
||||||
|
L150 338
|
||||||
|
a26 26 0 0 0 0 37
|
||||||
|
l11 11
|
||||||
|
a26 26 0 0 0 37 0
|
||||||
|
l112 -112
|
||||||
|
a62 62 0 0 0 76 -82
|
||||||
|
l-41 41
|
||||||
|
l-40 -11
|
||||||
|
l-11 -40
|
||||||
|
z"/>
|
||||||
|
<!-- handle highlight -->
|
||||||
|
<path d="M171 350 l128 -128" stroke="#bdf6f8" stroke-width="7" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -2,12 +2,31 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<!-- viewport-fit=cover so the app paints edge-to-edge and we can honour the
|
||||||
|
notch/home-indicator via env(safe-area-inset-*). maximum-scale + no
|
||||||
|
user-scaling keeps the cockpit layout stable under stress on mobile. -->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0"
|
||||||
|
/>
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
|
<!-- PWA / installable. theme-color tints the mobile status bar to the dark
|
||||||
|
theme; black-translucent lets the app draw under the iOS status bar. -->
|
||||||
|
<meta name="theme-color" content="#06080b" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="breakglass" />
|
||||||
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./icon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
||||||
|
|
||||||
<title>devvm breakglass</title>
|
<title>devvm breakglass</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DjaW81Sq.js"></script>
|
<script type="module" crossorigin src="./assets/index-CLbKo1Yx.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-DWHIP1Zw.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BoWC1Onq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
31
app/breakglass/static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "devvm breakglass",
|
||||||
|
"short_name": "breakglass",
|
||||||
|
"description": "Emergency recovery console for the devvm — chat with a repair agent or power-cycle the VM directly.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#06080b",
|
||||||
|
"theme_color": "#06080b",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./icon.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "any",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icon-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icon-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,28 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<!-- viewport-fit=cover so the app paints edge-to-edge and we can honour the
|
||||||
|
notch/home-indicator via env(safe-area-inset-*). maximum-scale + no
|
||||||
|
user-scaling keeps the cockpit layout stable under stress on mobile. -->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0"
|
||||||
|
/>
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
|
<!-- PWA / installable. theme-color tints the mobile status bar to the dark
|
||||||
|
theme; black-translucent lets the app draw under the iOS status bar. -->
|
||||||
|
<meta name="theme-color" content="#06080b" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="breakglass" />
|
||||||
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./icon.svg" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
||||||
|
|
||||||
<title>devvm breakglass</title>
|
<title>devvm breakglass</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
64
frontend/public/icon.svg
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="devvm breakglass">
|
||||||
|
<defs>
|
||||||
|
<!-- layered near-black surface, matching the app theme -->
|
||||||
|
<radialGradient id="bg" cx="68%" cy="22%" r="92%">
|
||||||
|
<stop offset="0%" stop-color="#12303a"/>
|
||||||
|
<stop offset="42%" stop-color="#0b0f14"/>
|
||||||
|
<stop offset="100%" stop-color="#06080b"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="steel" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#7df0f3"/>
|
||||||
|
<stop offset="55%" stop-color="#3dd1d6"/>
|
||||||
|
<stop offset="100%" stop-color="#1f6f72"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- rounded-square field (safe for maskable: art kept within central ~80%) -->
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<rect x="6" y="6" width="500" height="500" rx="108" fill="none" stroke="#1c2530" stroke-width="3"/>
|
||||||
|
<!-- faint scanline texture -->
|
||||||
|
<g opacity="0.05" stroke="#ffffff" stroke-width="2">
|
||||||
|
<line x1="0" y1="148" x2="512" y2="148"/>
|
||||||
|
<line x1="0" y1="220" x2="512" y2="220"/>
|
||||||
|
<line x1="0" y1="292" x2="512" y2="292"/>
|
||||||
|
<line x1="0" y1="364" x2="512" y2="364"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- fracture burst (amber): the "break the glass" radiating cracks -->
|
||||||
|
<g stroke="#f5b657" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
fill="none" opacity="0.92" filter="url(#glow)">
|
||||||
|
<path d="M256 256 L142 132"/>
|
||||||
|
<path d="M256 256 L120 250"/>
|
||||||
|
<path d="M256 256 L150 372"/>
|
||||||
|
<path d="M256 256 L372 380"/>
|
||||||
|
<path d="M256 256 L392 246"/>
|
||||||
|
<path d="M256 256 L360 138"/>
|
||||||
|
<!-- cross-cracks -->
|
||||||
|
<path d="M186 196 L150 250"/>
|
||||||
|
<path d="M210 320 L172 318" opacity="0.7"/>
|
||||||
|
<path d="M326 318 L356 350" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- wrench, struck across the burst (cyan steel) -->
|
||||||
|
<g filter="url(#glow)">
|
||||||
|
<path fill="url(#steel)" stroke="#0e3133" stroke-width="6" stroke-linejoin="round"
|
||||||
|
d="M344 150
|
||||||
|
a62 62 0 0 0 -82 76
|
||||||
|
L150 338
|
||||||
|
a26 26 0 0 0 0 37
|
||||||
|
l11 11
|
||||||
|
a26 26 0 0 0 37 0
|
||||||
|
l112 -112
|
||||||
|
a62 62 0 0 0 76 -82
|
||||||
|
l-41 41
|
||||||
|
l-40 -11
|
||||||
|
l-11 -40
|
||||||
|
z"/>
|
||||||
|
<!-- handle highlight -->
|
||||||
|
<path d="M171 350 l128 -128" stroke="#bdf6f8" stroke-width="7" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
31
frontend/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "devvm breakglass",
|
||||||
|
"short_name": "breakglass",
|
||||||
|
"description": "Emergency recovery console for the devvm — chat with a repair agent or power-cycle the VM directly.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#06080b",
|
||||||
|
"theme_color": "#06080b",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./icon.svg",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"sizes": "any",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icon-192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icon-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,100 +1,294 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { openSession } from './lib/api.js';
|
import {
|
||||||
|
openSession,
|
||||||
|
attachStream,
|
||||||
|
sendPrompt,
|
||||||
|
cancelTurn,
|
||||||
|
loadSessionId,
|
||||||
|
saveSessionId,
|
||||||
|
clearSessionId,
|
||||||
|
} from './lib/api.js';
|
||||||
|
import { createTranscript, reduceEvent } from './lib/transcript.js';
|
||||||
import Chat from './Chat.svelte';
|
import Chat from './Chat.svelte';
|
||||||
import VmControls from './VmControls.svelte';
|
import VmControls from './VmControls.svelte';
|
||||||
|
|
||||||
// ── session lifecycle ────────────────────────────────────────────────────
|
// ── lifecycle state ───────────────────────────────────────────────────────
|
||||||
|
// link: connecting | attached | error (the EventSource to the session)
|
||||||
|
let link = $state('connecting');
|
||||||
|
let linkError = $state('');
|
||||||
let sessionId = $state('');
|
let sessionId = $state('');
|
||||||
let sessionState = $state('connecting'); // connecting | ready | error
|
let caughtUp = $state(false); // replay drained → live tailing
|
||||||
let sessionError = $state('');
|
let turnActive = $state(false); // a turn is running (Stop shown, Send off)
|
||||||
let streaming = $state(false);
|
let sending = $state(false); // a prompt POST is in flight
|
||||||
|
|
||||||
// Mobile: the VM controls live in a slide-up sheet. Desktop: a side column
|
// The transcript is folded with a plain mutable object; we bump `rev` to
|
||||||
// (CSS hides the toggle and pins the sheet open as a column ≥900px).
|
// notify the view of in-place mutations (cheaper than cloning the whole
|
||||||
|
// message list on every streamed token). `tx` is $state too, so REASSIGNING
|
||||||
|
// it (reset / new session) also propagates to the Chat prop. $state.raw keeps
|
||||||
|
// the object un-proxied so the hot per-token path stays a plain mutation.
|
||||||
|
let tx = $state.raw(createTranscript());
|
||||||
|
let rev = $state(0);
|
||||||
|
|
||||||
|
let es = null; // the live EventSource
|
||||||
|
|
||||||
|
// Mobile: VM controls live in a slide-up sheet. Desktop (≥900px): a column.
|
||||||
let showControls = $state(false);
|
let showControls = $state(false);
|
||||||
|
|
||||||
async function newSession() {
|
function resetTranscript() {
|
||||||
sessionState = 'connecting';
|
tx = createTranscript();
|
||||||
sessionError = '';
|
rev++;
|
||||||
try {
|
}
|
||||||
sessionId = await openSession();
|
|
||||||
sessionState = 'ready';
|
function onEvent(ev) {
|
||||||
} catch (err) {
|
if (reduceEvent(tx, ev)) {
|
||||||
sessionState = 'error';
|
// turn liveness tracks the folder's view of the stream, so a turn started
|
||||||
sessionError = err instanceof Error ? err.message : String(err);
|
// in ANOTHER tab (or before a reload) still flips us into "active".
|
||||||
|
turnActive = tx.activeUserSeen;
|
||||||
|
rev++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(newSession);
|
function closeStream() {
|
||||||
|
if (es) {
|
||||||
function onLiveSession(id) {
|
es.close();
|
||||||
if (id) sessionId = id;
|
es = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '────────');
|
function attach(id) {
|
||||||
const dotState = $derived(
|
closeStream();
|
||||||
sessionState === 'error' ? 'error' : streaming ? 'busy' : sessionState === 'ready' ? 'ready' : 'idle'
|
sessionId = id;
|
||||||
|
caughtUp = false;
|
||||||
|
link = 'connecting';
|
||||||
|
linkError = '';
|
||||||
|
es = attachStream(id, {
|
||||||
|
onOpen: () => {
|
||||||
|
// a successful (re)connection clears any prior transient error
|
||||||
|
if (link !== 'attached') link = 'attached';
|
||||||
|
linkError = '';
|
||||||
|
},
|
||||||
|
onCaughtUp: () => {
|
||||||
|
caughtUp = true;
|
||||||
|
link = 'attached';
|
||||||
|
},
|
||||||
|
onEvent,
|
||||||
|
onError: () => {
|
||||||
|
// EventSource auto-reconnects on a transient drop (readyState
|
||||||
|
// CONNECTING). Only a terminal CLOSED state is a hard failure. The
|
||||||
|
// server keeps the turn running regardless, so we surface a soft note
|
||||||
|
// and let the browser retry.
|
||||||
|
if (es && es.readyState === EventSource.CLOSED) {
|
||||||
|
link = 'error';
|
||||||
|
linkError = 'lost the connection to the session — retrying…';
|
||||||
|
// a closed source won't retry itself; re-attach to the same id.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (sessionId === id) attach(id);
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
link = 'connecting';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
link = 'connecting';
|
||||||
|
linkError = '';
|
||||||
|
resetTranscript();
|
||||||
|
const existing = loadSessionId();
|
||||||
|
if (existing) {
|
||||||
|
// Reuse the persisted id and attach. If it's gone (pod restart → 404 on
|
||||||
|
// the stream), the EventSource errors; we detect the 404-shaped close and
|
||||||
|
// mint a fresh session below.
|
||||||
|
attach(existing);
|
||||||
|
// Probe liveness: if the attach can't open within a grace window AND the
|
||||||
|
// id is stale, create a new one. We rely on onError(CLOSED) for the 404.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFresh() {
|
||||||
|
try {
|
||||||
|
link = 'connecting';
|
||||||
|
const id = await openSession();
|
||||||
|
saveSessionId(id);
|
||||||
|
attach(id);
|
||||||
|
} catch (err) {
|
||||||
|
link = 'error';
|
||||||
|
linkError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "New session": archive the local id, mint a new one, re-attach.
|
||||||
|
async function newSession() {
|
||||||
|
if (turnActive || sending) return;
|
||||||
|
closeStream();
|
||||||
|
clearSessionId();
|
||||||
|
resetTranscript();
|
||||||
|
turnActive = false;
|
||||||
|
await createFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a prompt (typed or a preset). Output arrives via the attach stream.
|
||||||
|
async function submitPrompt(prompt) {
|
||||||
|
const text = (prompt || '').trim();
|
||||||
|
if (!text || turnActive || sending) return;
|
||||||
|
if (!sessionId) {
|
||||||
|
await createFresh();
|
||||||
|
if (!sessionId) return;
|
||||||
|
}
|
||||||
|
sending = true;
|
||||||
|
turnActive = true; // optimistic: the working indicator shows immediately
|
||||||
|
try {
|
||||||
|
const res = await sendPrompt({ session_id: sessionId, prompt: text });
|
||||||
|
if (res.status === 'busy') {
|
||||||
|
flash = 'A turn is already running.';
|
||||||
|
// turn really is active; keep the indicator, the stream will end it.
|
||||||
|
} else if (res.status === 'gone') {
|
||||||
|
// session evaporated (pod restart). Re-create and resend once.
|
||||||
|
clearSessionId();
|
||||||
|
await createFresh();
|
||||||
|
if (sessionId) await sendPrompt({ session_id: sessionId, prompt: text });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
flash = err instanceof Error ? err.message : String(err);
|
||||||
|
turnActive = tx.activeUserSeen; // back off the optimistic flag on failure
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTurn() {
|
||||||
|
if (!sessionId) return;
|
||||||
|
try {
|
||||||
|
await cancelTurn(sessionId);
|
||||||
|
// turn_end / cancelled events arrive via the stream and flip turnActive.
|
||||||
|
} catch (err) {
|
||||||
|
flash = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a transient toast (409 / network blips), auto-cleared
|
||||||
|
let flash = $state('');
|
||||||
|
let flashTimer;
|
||||||
|
$effect(() => {
|
||||||
|
if (flash) {
|
||||||
|
clearTimeout(flashTimer);
|
||||||
|
flashTimer = setTimeout(() => (flash = ''), 4200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(bootstrap);
|
||||||
|
onDestroy(closeStream);
|
||||||
|
|
||||||
|
// ── header status lamp ──────────────────────────────────────────────────
|
||||||
|
// One quietly-living "system pulse": idle/connecting (cyan breathe),
|
||||||
|
// working (amber pulse), error (steady red — the ONLY non-power red, used
|
||||||
|
// sparingly for the lamp because connection loss IS the emergency here).
|
||||||
|
const lamp = $derived(
|
||||||
|
link === 'error'
|
||||||
|
? 'error'
|
||||||
|
: turnActive
|
||||||
|
? 'working'
|
||||||
|
: link === 'attached'
|
||||||
|
? 'live'
|
||||||
|
: 'connecting'
|
||||||
);
|
);
|
||||||
|
const lampLabel = $derived(
|
||||||
|
{
|
||||||
|
error: 'link down',
|
||||||
|
working: 'agent working',
|
||||||
|
live: 'attached',
|
||||||
|
connecting: 'connecting',
|
||||||
|
}[lamp]
|
||||||
|
);
|
||||||
|
const shortId = $derived(sessionId ? sessionId.slice(0, 8) : '········');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<header class="rail">
|
<header class="rail rise-in" style="--d:0ms">
|
||||||
<div class="rail-title">
|
<div class="rail-title">
|
||||||
<span class="glyph" aria-hidden="true">🔧</span>
|
<span class="brand-mark" aria-hidden="true">
|
||||||
<h1>devvm <span class="accent">breakglass</span></h1>
|
<!-- breakglass glyph: a wrench struck through a fracture line -->
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15.5 5.5a3.6 3.6 0 0 0-4.7 4.4L4 16.7 7.3 20l6.8-6.8a3.6 3.6 0 0 0 4.4-4.7l-2.2 2.2-2.2-.6-.6-2.2 2-2.6Z" />
|
||||||
|
<path class="frac" d="M3 3l3.2 4.1L4.4 8.6 7 12" stroke-dasharray="2 2.4" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h1>devvm<span class="accent"> breakglass</span></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rail-right">
|
<div class="rail-right">
|
||||||
<span class="rail-status">
|
<span class="lamp-wrap" title={lampLabel}>
|
||||||
<span class="dot dot--{dotState}" aria-hidden="true"></span>
|
<span class="lamp lamp--{lamp}" aria-hidden="true"></span>
|
||||||
{#if sessionState === 'error'}
|
<span class="lamp-text lamp-text--{lamp}">
|
||||||
<span class="session-bad">offline</span>
|
{#if lamp === 'error'}
|
||||||
{:else if sessionState === 'connecting'}
|
link down
|
||||||
<span class="session-meta">connecting…</span>
|
{:else if lamp === 'working'}
|
||||||
{:else}
|
working
|
||||||
<code class="session-id" title={sessionId}>{shortId}</code>
|
{:else if lamp === 'live'}
|
||||||
{/if}
|
<code class="sid">{shortId}</code>
|
||||||
|
{:else}
|
||||||
|
connecting
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Mobile-only: open the VM control sheet. Hidden on desktop (column). -->
|
<!-- Mobile-only: open the VM control sheet. Hidden on desktop (column). -->
|
||||||
<button
|
<button
|
||||||
class="controls-toggle"
|
class="rail-btn rail-btn--vm"
|
||||||
onclick={() => (showControls = true)}
|
onclick={() => (showControls = true)}
|
||||||
aria-label="Open direct VM controls"
|
aria-label="Open direct VM controls"
|
||||||
>
|
>
|
||||||
⚡ <span class="controls-toggle-label">VM</span>
|
<span class="bolt" aria-hidden="true">⚡</span><span class="rail-btn-label">VM</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="new-session"
|
class="rail-btn"
|
||||||
onclick={newSession}
|
onclick={newSession}
|
||||||
disabled={streaming || sessionState === 'connecting'}
|
disabled={turnActive || sending || link === 'connecting'}
|
||||||
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
|
title={turnActive ? 'wait for the current turn to finish' : 'archive this session and start fresh'}
|
||||||
>
|
>
|
||||||
New
|
New
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if sessionState === 'error'}
|
{#if link === 'error'}
|
||||||
<div class="rail-error" role="alert">
|
<div class="rail-note" role="alert">
|
||||||
Can't reach the breakglass backend — {sessionError}. The cluster or network
|
<span>{linkError || "Can't reach the breakglass backend."}</span>
|
||||||
may be down. The <strong>⚡ VM</strong> power controls still work without the chat.
|
<span class="rail-note-aside">The <strong>⚡ VM</strong> power controls still work without the chat.</span>
|
||||||
|
<button class="rail-note-retry" onclick={bootstrap}>Reconnect</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if flash}
|
||||||
|
<div class="toast" role="status">{flash}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main class="stage">
|
<main class="stage">
|
||||||
<section class="chat-pane" aria-label="Recovery chat">
|
<section class="chat-pane rise-in" style="--d:80ms" aria-label="Recovery chat">
|
||||||
<Chat
|
<Chat
|
||||||
{sessionId}
|
{tx}
|
||||||
sessionReady={sessionState === 'ready'}
|
{rev}
|
||||||
{onLiveSession}
|
{caughtUp}
|
||||||
onStreamingChange={(v) => (streaming = v)}
|
{turnActive}
|
||||||
|
sending={sending}
|
||||||
|
linkState={link}
|
||||||
|
onSubmit={submitPrompt}
|
||||||
|
onStop={stopTurn}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="controls-pane" class:open={showControls} aria-label="Direct VM control">
|
<aside
|
||||||
|
class="controls-pane rise-in"
|
||||||
|
class:open={showControls}
|
||||||
|
style="--d:160ms"
|
||||||
|
aria-label="Direct VM control"
|
||||||
|
>
|
||||||
<div class="sheet-grip" aria-hidden="true"></div>
|
<div class="sheet-grip" aria-hidden="true"></div>
|
||||||
<div class="controls-head">
|
<div class="controls-head">
|
||||||
<span class="controls-head-title">Direct VM control</span>
|
<span class="controls-head-title">Direct VM control</span>
|
||||||
|
|
@ -104,7 +298,6 @@
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- backdrop behind the mobile sheet -->
|
|
||||||
<button
|
<button
|
||||||
class="sheet-backdrop"
|
class="sheet-backdrop"
|
||||||
class:show={showControls}
|
class:show={showControls}
|
||||||
|
|
@ -119,43 +312,51 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 1500px;
|
max-width: 1520px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
/* honour the notch on landscape / edge-to-edge */
|
||||||
|
padding-left: var(--safe-left);
|
||||||
|
padding-right: var(--safe-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status rail (compact, single row on mobile) ─────────────────────── */
|
/* ── status rail ───────────────────────────────────────────────────────── */
|
||||||
.rail {
|
.rail {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 14px;
|
padding: max(10px, var(--safe-top)) 14px 10px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(61, 209, 214, 0.03), transparent 60%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.rail-title {
|
.rail-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.glyph {
|
.brand-mark {
|
||||||
font-size: 17px;
|
color: var(--cyan);
|
||||||
transform: translateY(2px);
|
display: inline-flex;
|
||||||
filter: saturate(0.85);
|
filter: drop-shadow(0 0 10px rgba(61, 209, 214, 0.35));
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
.brand-mark .frac { color: var(--amber); stroke: var(--amber); opacity: 0.85; }
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.accent {
|
.accent {
|
||||||
color: var(--cyan);
|
color: var(--cyan);
|
||||||
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
|
text-shadow: 0 0 18px rgba(61, 209, 214, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-right {
|
.rail-right {
|
||||||
|
|
@ -164,90 +365,158 @@
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
.rail-status {
|
|
||||||
|
/* the living system-pulse lamp */
|
||||||
|
.lamp-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.session-id {
|
.lamp {
|
||||||
color: var(--cyan);
|
position: relative;
|
||||||
letter-spacing: 0.04em;
|
width: 10px;
|
||||||
}
|
height: 10px;
|
||||||
.session-meta {
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
.session-bad {
|
|
||||||
color: var(--danger-bright);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--ink-faint);
|
background: var(--ink-faint);
|
||||||
}
|
}
|
||||||
.dot--ready {
|
/* a soft halo ring that pulses outward — the "instrument is powered" tell */
|
||||||
|
.lamp::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.lamp--live {
|
||||||
background: var(--cyan);
|
background: var(--cyan);
|
||||||
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.6);
|
color: var(--cyan);
|
||||||
animation: breathe 3.4s ease-in-out infinite;
|
box-shadow: 0 0 10px 1px rgba(61, 209, 214, 0.65);
|
||||||
|
animation: lamp-breathe 3.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.dot--busy {
|
.lamp--live::after { animation: lamp-ring 3.6s ease-out infinite; }
|
||||||
|
.lamp--connecting {
|
||||||
|
background: var(--cyan-dim);
|
||||||
|
color: var(--cyan);
|
||||||
|
animation: lamp-blink 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.lamp--working {
|
||||||
background: var(--amber);
|
background: var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
box-shadow: 0 0 10px 1px rgba(245, 182, 87, 0.7);
|
box-shadow: 0 0 10px 1px rgba(245, 182, 87, 0.7);
|
||||||
animation: pulse 1s ease-in-out infinite;
|
animation: lamp-pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.dot--error {
|
.lamp--working::after { animation: lamp-ring 1s ease-out infinite; }
|
||||||
|
.lamp--error {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
box-shadow: 0 0 10px 1px var(--danger-glow);
|
box-shadow: 0 0 10px 1px var(--danger-glow);
|
||||||
|
animation: lamp-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
@keyframes lamp-breathe { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
|
||||||
@keyframes pulse {
|
@keyframes lamp-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.9; } }
|
||||||
0%, 100% { transform: scale(0.82); opacity: 0.7; }
|
@keyframes lamp-pulse {
|
||||||
50% { transform: scale(1.15); opacity: 1; }
|
0%, 100% { transform: scale(0.82); opacity: 0.75; }
|
||||||
|
50% { transform: scale(1.12); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes lamp-ring {
|
||||||
|
0% { opacity: 0.5; transform: scale(0.6); }
|
||||||
|
70% { opacity: 0; transform: scale(1.8); }
|
||||||
|
100% { opacity: 0; transform: scale(1.8); }
|
||||||
|
}
|
||||||
|
.lamp-text {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
max-width: 88px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lamp-text--live .sid { color: var(--cyan); letter-spacing: 0.06em; }
|
||||||
|
.lamp-text--working { color: var(--amber); }
|
||||||
|
.lamp-text--error { color: var(--danger-bright); }
|
||||||
|
.lamp-text--connecting { color: var(--ink-faint); }
|
||||||
|
.sid { font-family: var(--mono); }
|
||||||
|
/* On the tightest phones the title + lamp text + two buttons crowd; keep the
|
||||||
|
living dot (the system pulse) and drop the text label until there's room. */
|
||||||
|
@media (max-width: 439px) {
|
||||||
|
.lamp-text { display: none; }
|
||||||
|
.lamp-wrap { padding: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* touch-friendly buttons */
|
/* rail buttons — touch-first (≥44px tall via padding + line height) */
|
||||||
.controls-toggle,
|
.rail-btn {
|
||||||
.new-session {
|
min-height: 44px;
|
||||||
min-height: 40px;
|
padding: 0 14px;
|
||||||
padding: 0 13px;
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.03em;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
|
transition: border-color 0.15s, background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.controls-toggle {
|
.rail-btn:hover:not(:disabled) { border-color: var(--line-bright); color: var(--ink); }
|
||||||
border-color: #5a4a2a;
|
.rail-btn:active:not(:disabled) { background: var(--bg-3); }
|
||||||
|
.rail-btn:disabled { opacity: 0.42; }
|
||||||
|
.rail-btn--vm {
|
||||||
|
border-color: var(--amber-dim);
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
.controls-toggle:active,
|
.rail-btn--vm:hover:not(:disabled) { border-color: var(--amber); color: var(--amber); }
|
||||||
.new-session:active {
|
.bolt { font-size: 13px; line-height: 1; }
|
||||||
background: var(--bg-3);
|
|
||||||
}
|
|
||||||
.new-session:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-error {
|
.rail-note {
|
||||||
margin: 10px 12px 0;
|
margin: 10px 12px 0;
|
||||||
padding: 11px 14px;
|
padding: 10px 13px;
|
||||||
border: 1px solid var(--danger-deep);
|
border: 1px solid var(--danger-deep);
|
||||||
border-left-width: 3px;
|
border-left-width: 3px;
|
||||||
background: rgba(255, 77, 77, 0.07);
|
background: rgba(255, 77, 77, 0.07);
|
||||||
color: #ffd5d5;
|
color: #ffd9d9;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 12px;
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
.rail-note-aside { color: #f0b8b8; }
|
||||||
|
.rail-note-aside strong { color: #fff; font-family: var(--mono); }
|
||||||
|
.rail-note-retry {
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--danger-deep);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger-bright);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.rail-note-retry:hover { background: rgba(255, 77, 77, 0.12); }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
margin: 10px 12px 0;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--amber);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
flex: none;
|
||||||
|
animation: rise-in 0.28s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── stage ───────────────────────────────────────────────────────────── */
|
/* ── stage ───────────────────────────────────────────────────────────── */
|
||||||
.stage {
|
.stage {
|
||||||
|
|
@ -271,31 +540,37 @@
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
max-height: 86dvh;
|
max-height: 88dvh;
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--bg-1);
|
background: var(--bg-1);
|
||||||
border-top: 1px solid var(--line-strong);
|
border-top: 1px solid var(--line-strong);
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.55);
|
box-shadow: var(--shadow-sheet);
|
||||||
padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
|
padding: 8px 14px calc(14px + var(--safe-bottom));
|
||||||
transform: translateY(101%);
|
transform: translateY(102%);
|
||||||
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
|
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
/* the rise-in entrance is for the desktop column; the sheet is transform-
|
||||||
|
controlled, so cancel the shared keyframe here. */
|
||||||
|
animation: none !important;
|
||||||
}
|
}
|
||||||
.controls-pane.open {
|
.controls-pane.open {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
.sheet-grip {
|
.sheet-grip {
|
||||||
width: 38px;
|
width: 40px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: var(--line-strong);
|
background: var(--line-bright);
|
||||||
margin: 4px auto 10px;
|
margin: 4px auto 10px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.controls-head {
|
.controls-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.controls-head-title {
|
.controls-head-title {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|
@ -305,14 +580,15 @@
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
}
|
}
|
||||||
.sheet-close {
|
.sheet-close {
|
||||||
width: 34px;
|
width: 40px;
|
||||||
height: 34px;
|
height: 40px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.sheet-close:active { background: var(--bg-3); }
|
||||||
|
|
||||||
.sheet-backdrop {
|
.sheet-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -320,40 +596,40 @@
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(2, 4, 7, 0.62);
|
||||||
|
backdrop-filter: blur(1.5px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.22s;
|
transition: opacity 0.24s;
|
||||||
}
|
}
|
||||||
.sheet-backdrop.show {
|
.sheet-backdrop.show {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── desktop: controls become a static side column, sheet chrome gone ── */
|
/* ── desktop: controls become a static side column ─────────────────────── */
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.rail {
|
.rail { padding: 14px 18px; }
|
||||||
padding: 14px 18px;
|
|
||||||
}
|
|
||||||
h1 { font-size: 19px; }
|
h1 { font-size: 19px; }
|
||||||
.stage {
|
.stage {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 372px;
|
grid-template-columns: minmax(0, 1fr) 384px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
}
|
}
|
||||||
.chat-pane { display: flex; }
|
.chat-pane { display: flex; }
|
||||||
.controls-toggle { display: none; }
|
.rail-btn--vm { display: none; }
|
||||||
.controls-pane {
|
.controls-pane {
|
||||||
position: static;
|
position: static;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: visible;
|
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
z-index: auto;
|
z-index: auto;
|
||||||
|
animation: rise-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both !important;
|
||||||
|
animation-delay: var(--d, 0ms) !important;
|
||||||
}
|
}
|
||||||
.sheet-grip,
|
.sheet-grip,
|
||||||
.controls-head,
|
.controls-head,
|
||||||
|
|
|
||||||
|
|
@ -1,128 +1,105 @@
|
||||||
<script>
|
<script>
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { streamChat } from './lib/api.js';
|
|
||||||
import ToolChip from './ToolChip.svelte';
|
import ToolChip from './ToolChip.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
sessionId = '',
|
tx, // the folded transcript state (plain object, see lib/transcript.js)
|
||||||
sessionReady = false,
|
rev = 0, // bumped on every in-place mutation to retrigger reactivity
|
||||||
onLiveSession = (/** @type {string} */ _id) => {},
|
caughtUp = false, // replay drained → staggered reveal may run
|
||||||
onStreamingChange = (/** @type {boolean} */ _v) => {},
|
turnActive = false, // a turn is running: show Stop, hide Send
|
||||||
|
sending = false, // a prompt POST is in flight (brief)
|
||||||
|
linkState = 'connecting', // connecting | attached | error
|
||||||
|
onSubmit = (/** @type {string} */ _p) => {},
|
||||||
|
onStop = () => {},
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/**
|
// The five quick-action presets — the mobile win: one tap, no typing.
|
||||||
* Message model. A user message is plain text. An assistant message is an
|
const PRESETS = [
|
||||||
* ordered list of parts so streamed prose and tool chips interleave in the
|
{
|
||||||
* exact order the agent emitted them:
|
label: 'Triage',
|
||||||
* { role:'assistant', parts:[{type:'text',text}|{type:'tool',name,command}],
|
icon: '◑',
|
||||||
* result?: {is_error, text, duration_ms}, error?: string }
|
prompt:
|
||||||
* @type {Array<any>}
|
'Triage the devvm: uptime, load, memory, swap, disk usage, failed systemd units, and the last 30 lines of dmesg. Summarize what\'s wrong.',
|
||||||
*/
|
},
|
||||||
let messages = $state([]);
|
{
|
||||||
|
label: 'Memory / OOM',
|
||||||
|
icon: '▦',
|
||||||
|
prompt:
|
||||||
|
'Check devvm memory pressure: free -h, top memory consumers, any recent OOM-kills in dmesg/journal, and swap usage. Is it OOMing?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Disk',
|
||||||
|
icon: '▤',
|
||||||
|
prompt:
|
||||||
|
'What\'s filling the devvm disk? df -h, then the biggest directories/files under the fullest mount. Anything safe to clear?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Services',
|
||||||
|
icon: '⚙',
|
||||||
|
prompt:
|
||||||
|
'List failed or stuck systemd units on the devvm (systemctl --failed) and show the status + recent journal lines for any that are down.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'QEMU wedged?',
|
||||||
|
icon: '◫',
|
||||||
|
prompt:
|
||||||
|
'Is the devvm\'s QEMU wedged (I/O stall)? Check guest responsiveness over SSH, then ssh pve forensics for VM 102\'s qm status/QMP/guest-agent. Tell me if a cycle is needed.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let draft = $state('');
|
let draft = $state('');
|
||||||
let streaming = $state(false);
|
let scroller;
|
||||||
let scroller; // the scroll viewport
|
|
||||||
let inputEl;
|
let inputEl;
|
||||||
let pinnedToBottom = true; // auto-scroll only while the user is at the bottom
|
let pinnedToBottom = true;
|
||||||
|
|
||||||
const canSend = $derived(sessionReady && !streaming && draft.trim().length > 0);
|
// re-derive the message list whenever the folder mutates (rev bump). The
|
||||||
|
// transcript is folded with in-place mutation on a $state.raw object, so no
|
||||||
|
// reference changes on its own — we depend on `rev` explicitly and rebuild
|
||||||
|
// fresh objects (message + its parts array) so Svelte's keyed {#each} re-
|
||||||
|
// renders streamed prose/chips on every token. Transcripts are small; the
|
||||||
|
// per-token copy is cheap and keeps the hot streaming path bug-free.
|
||||||
|
const messages = $derived(
|
||||||
|
rev >= 0 && tx
|
||||||
|
? tx.messages.map((m) =>
|
||||||
|
m.role === 'assistant' ? { ...m, parts: m.parts.slice() } : { ...m }
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const isEmpty = $derived(messages.length === 0);
|
||||||
|
const canSend = $derived(linkState !== 'error' && !turnActive && draft.trim().length > 0);
|
||||||
|
const inputReady = $derived(!turnActive);
|
||||||
|
|
||||||
// ── scrolling ─────────────────────────────────────────────────────────────
|
// ── auto-scroll (only while pinned to the bottom) ─────────────────────────
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
const gap = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
const gap = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||||
pinnedToBottom = gap < 60;
|
pinnedToBottom = gap < 64;
|
||||||
}
|
}
|
||||||
async function scrollToBottom(force = false) {
|
async function scrollToBottom(force = false) {
|
||||||
if (!force && !pinnedToBottom) return;
|
if (!force && !pinnedToBottom) return;
|
||||||
await tick();
|
await tick();
|
||||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||||
}
|
}
|
||||||
|
// any transcript change → keep the view pinned if the user is at the bottom
|
||||||
// ── streaming a turn ────────────────────────────────────────────────────────
|
$effect(() => {
|
||||||
function lastAssistant() {
|
rev; // track
|
||||||
return messages[messages.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendText(text) {
|
|
||||||
const msg = lastAssistant();
|
|
||||||
const parts = msg.parts;
|
|
||||||
const tail = parts[parts.length - 1];
|
|
||||||
if (tail && tail.type === 'text') {
|
|
||||||
tail.text += text;
|
|
||||||
} else {
|
|
||||||
parts.push({ type: 'text', text });
|
|
||||||
}
|
|
||||||
messages = messages; // notify Svelte of the in-place mutation
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEvent(ev) {
|
|
||||||
switch (ev?.kind) {
|
|
||||||
case 'session':
|
|
||||||
onLiveSession(ev.session_id);
|
|
||||||
break;
|
|
||||||
case 'text':
|
|
||||||
if (ev.text) appendText(ev.text);
|
|
||||||
break;
|
|
||||||
case 'tool': {
|
|
||||||
// Bash carries a `command`; other tools just show their name.
|
|
||||||
const command =
|
|
||||||
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
|
|
||||||
lastAssistant().parts.push({ type: 'tool', name: ev.name || 'tool', command });
|
|
||||||
messages = messages;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'result':
|
|
||||||
lastAssistant().result = {
|
|
||||||
is_error: Boolean(ev.is_error),
|
|
||||||
text: typeof ev.result === 'string' ? ev.result : '',
|
|
||||||
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
|
|
||||||
};
|
|
||||||
messages = messages;
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
lastAssistant().error = ev.error || 'unknown error';
|
|
||||||
messages = messages;
|
|
||||||
break;
|
|
||||||
case 'done':
|
|
||||||
// handled by the stream completing; nothing to render
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fire(prompt) {
|
||||||
|
if (turnActive) return;
|
||||||
|
pinnedToBottom = true;
|
||||||
|
onSubmit(prompt);
|
||||||
|
scrollToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send() {
|
function send() {
|
||||||
const prompt = draft.trim();
|
const text = draft.trim();
|
||||||
if (!prompt || streaming || !sessionReady) return;
|
if (!text || turnActive) return;
|
||||||
|
|
||||||
messages.push({ role: 'user', text: prompt });
|
|
||||||
messages.push({ role: 'assistant', parts: [] });
|
|
||||||
messages = messages;
|
|
||||||
draft = '';
|
draft = '';
|
||||||
streaming = true;
|
fire(text);
|
||||||
onStreamingChange(true);
|
// restore single-row height after clearing
|
||||||
pinnedToBottom = true;
|
tick().then(() => inputEl?.focus());
|
||||||
await scrollToBottom(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await streamChat({ session_id: sessionId, prompt }, handleEvent);
|
|
||||||
} catch (err) {
|
|
||||||
// Network/transport failure (backend down, connection dropped mid-stream).
|
|
||||||
const msg = lastAssistant();
|
|
||||||
if (msg && msg.role === 'assistant' && !msg.error) {
|
|
||||||
msg.error =
|
|
||||||
(err instanceof Error ? err.message : String(err)) +
|
|
||||||
' — the connection to the agent failed.';
|
|
||||||
messages = messages;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
streaming = false;
|
|
||||||
onStreamingChange(false);
|
|
||||||
await scrollToBottom();
|
|
||||||
inputEl?.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e) {
|
function onKeydown(e) {
|
||||||
|
|
@ -130,7 +107,7 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
send();
|
send();
|
||||||
}
|
}
|
||||||
// Shift+Enter falls through to insert a newline.
|
// Shift+Enter → newline (default behaviour)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDuration(ms) {
|
function fmtDuration(ms) {
|
||||||
|
|
@ -139,7 +116,12 @@
|
||||||
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
|
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEmpty = $derived(messages.length === 0);
|
// a freshly-attached transcript reveals with a brief stagger; cap the delay
|
||||||
|
// so a long replay doesn't animate forever.
|
||||||
|
function revealDelay(i) {
|
||||||
|
if (!caughtUp) return 0;
|
||||||
|
return Math.min(i, 6) * 45;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="chat">
|
<div class="chat">
|
||||||
|
|
@ -150,41 +132,58 @@
|
||||||
|
|
||||||
<div class="stream" bind:this={scroller} onscroll={onScroll}>
|
<div class="stream" bind:this={scroller} onscroll={onScroll}>
|
||||||
{#if isEmpty}
|
{#if isEmpty}
|
||||||
<div class="empty">
|
<div class="empty" class:dim={linkState === 'connecting'}>
|
||||||
<div class="empty-mark">⌁</div>
|
<div class="empty-mark" aria-hidden="true">⌁</div>
|
||||||
<p class="empty-title">The agent is standing by.</p>
|
<p class="empty-title">
|
||||||
|
{#if linkState === 'error'}
|
||||||
|
The agent is unreachable.
|
||||||
|
{:else if linkState === 'connecting'}
|
||||||
|
Attaching to the session…
|
||||||
|
{:else}
|
||||||
|
The agent is standing by.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
<p class="empty-sub">
|
<p class="empty-sub">
|
||||||
Describe the symptom — "devvm is unreachable", "disk full", "ssh hangs"
|
{#if linkState === 'error'}
|
||||||
— and it will connect over SSH, investigate, and stream its work here.
|
The cluster or network may be down. You can still power-cycle the VM
|
||||||
For a hard power action when the agent can't help, use
|
with <strong>⚡ Direct VM control</strong> — it needs no agent.
|
||||||
<strong>Direct VM control</strong>.
|
{:else}
|
||||||
|
Tap a preset below or describe the symptom — "devvm unreachable",
|
||||||
|
"disk full", "ssh hangs" — and it will connect over SSH, investigate,
|
||||||
|
and stream its work here. For a hard power action, use
|
||||||
|
<strong>⚡ Direct VM control</strong>.
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each messages as msg, i (i)}
|
{#each messages as msg (msg.key)}
|
||||||
{#if msg.role === 'user'}
|
{#if msg.role === 'user'}
|
||||||
<div class="row row--user">
|
<div class="row row--user rise-in" style="--d:{revealDelay(0)}ms">
|
||||||
<div class="bubble bubble--user">{msg.text}</div>
|
<div class="bubble bubble--user">{msg.text}</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="row row--assistant">
|
<div class="row row--assistant rise-in" style="--d:{revealDelay(0)}ms">
|
||||||
<div class="bubble bubble--assistant">
|
<div class="bubble bubble--assistant">
|
||||||
{#if msg.parts.length === 0 && !msg.result && !msg.error}
|
{#if msg.parts.length === 0 && !msg.result && !msg.error && !msg.cancelled}
|
||||||
<span class="thinking" aria-label="working">
|
<span class="thinking" aria-label="working">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#each msg.parts as part, j (j)}
|
{#each msg.parts as part, j (j)}
|
||||||
{#if part.type === 'text'}
|
{#if part.type === 'text'}<span class="prose">{part.text}</span>{:else}<ToolChip name={part.name} command={part.command} />{/if}
|
||||||
<span class="prose">{part.text}</span>
|
|
||||||
{:else}
|
|
||||||
<ToolChip name={part.name} command={part.command} />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if msg.error}
|
{#if msg.error}
|
||||||
<div class="turn-note turn-note--error">⚠ {msg.error}</div>
|
<div class="turn-note turn-note--error">
|
||||||
|
<span class="turn-note-tag">error</span>
|
||||||
|
<span class="turn-note-body">{msg.error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if msg.cancelled}
|
||||||
|
<div class="turn-note turn-note--muted">
|
||||||
|
<span class="turn-note-tag">stopped</span>
|
||||||
|
<span class="turn-note-body">turn cancelled</span>
|
||||||
|
</div>
|
||||||
{:else if msg.result}
|
{:else if msg.result}
|
||||||
<div class="turn-note {msg.result.is_error ? 'turn-note--error' : 'turn-note--ok'}">
|
<div class="turn-note {msg.result.is_error ? 'turn-note--error' : 'turn-note--ok'}">
|
||||||
<span class="turn-note-tag">{msg.result.is_error ? 'failed' : 'done'}</span>
|
<span class="turn-note-tag">{msg.result.is_error ? 'failed' : 'done'}</span>
|
||||||
|
|
@ -200,36 +199,61 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<div class="dock">
|
||||||
class="composer"
|
<!-- quick-action preset bar: horizontally scrollable, one-tap prompts -->
|
||||||
onsubmit={(e) => {
|
<div class="presets" role="group" aria-label="Quick actions">
|
||||||
e.preventDefault();
|
{#each PRESETS as p (p.label)}
|
||||||
send();
|
<button
|
||||||
}}
|
class="preset"
|
||||||
>
|
onclick={() => fire(p.prompt)}
|
||||||
{#if streaming}
|
disabled={turnActive || linkState === 'error'}
|
||||||
<div class="working-bar" aria-live="polite">
|
title={p.prompt}
|
||||||
<span class="working-dots"><span></span><span></span><span></span></span>
|
>
|
||||||
agent working — streaming live
|
<span class="preset-icon" aria-hidden="true">{p.icon}</span>
|
||||||
</div>
|
<span class="preset-label">{p.label}</span>
|
||||||
{/if}
|
</button>
|
||||||
<div class="composer-row">
|
{/each}
|
||||||
<textarea
|
|
||||||
bind:this={inputEl}
|
|
||||||
bind:value={draft}
|
|
||||||
onkeydown={onKeydown}
|
|
||||||
placeholder={sessionReady
|
|
||||||
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
|
|
||||||
: 'Waiting for a session…'}
|
|
||||||
rows="1"
|
|
||||||
disabled={!sessionReady || streaming}
|
|
||||||
spellcheck="false"
|
|
||||||
></textarea>
|
|
||||||
<button type="submit" class="send" disabled={!canSend}>
|
|
||||||
{streaming ? '…' : 'Send'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<form
|
||||||
|
class="composer"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if turnActive}
|
||||||
|
<div class="working-bar" aria-live="polite">
|
||||||
|
<span class="working-dots"><span></span><span></span><span></span></span>
|
||||||
|
<span>agent working — streaming live</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="composer-row">
|
||||||
|
<textarea
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={draft}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder={inputReady
|
||||||
|
? 'Describe the problem… (Enter to send · Shift+Enter for a new line)'
|
||||||
|
: 'A turn is running — Stop it to type, or wait…'}
|
||||||
|
rows="1"
|
||||||
|
disabled={!inputReady}
|
||||||
|
spellcheck="false"
|
||||||
|
enterkeyhint="send"
|
||||||
|
></textarea>
|
||||||
|
{#if turnActive}
|
||||||
|
<button type="button" class="stop" onclick={onStop} title="Stop the running turn">
|
||||||
|
<span class="stop-glyph" aria-hidden="true"></span>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button type="submit" class="send" disabled={!canSend}>
|
||||||
|
{sending ? '···' : 'Send'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -249,9 +273,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 13px 18px;
|
padding: 12px 18px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent);
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
.chat-head-label {
|
.chat-head-label {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|
@ -263,13 +288,16 @@
|
||||||
.chat-head-hint {
|
.chat-head-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream {
|
.stream {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 18px 8px;
|
padding: 20px 16px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
@ -279,23 +307,27 @@
|
||||||
/* empty state */
|
/* empty state */
|
||||||
.empty {
|
.empty {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
max-width: 460px;
|
max-width: 470px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 28px 12px;
|
padding: 24px 14px;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
|
.empty.dim { opacity: 0.8; }
|
||||||
.empty-mark {
|
.empty-mark {
|
||||||
font-size: 40px;
|
font-size: 42px;
|
||||||
color: var(--cyan-dim);
|
color: var(--cyan-dim);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
text-shadow: 0 0 24px rgba(61, 209, 214, 0.25);
|
text-shadow: 0 0 26px rgba(61, 209, 214, 0.3);
|
||||||
|
animation: lamp-breathe 3.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
@keyframes lamp-breathe { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -303,32 +335,23 @@
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.empty-sub strong {
|
.empty-sub strong { color: var(--ink-dim); font-weight: 600; }
|
||||||
color: var(--ink-dim);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
.row { display: flex; }
|
||||||
display: flex;
|
.row--user { justify-content: flex-end; }
|
||||||
}
|
.row--assistant { justify-content: flex-start; }
|
||||||
.row--user {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.row--assistant {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
max-width: 86%;
|
max-width: 88%;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.62;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.bubble--user {
|
.bubble--user {
|
||||||
background: linear-gradient(180deg, #15333a, #0f262c);
|
background: linear-gradient(180deg, #123036, #0d2329);
|
||||||
border: 1px solid var(--cyan-dim);
|
border: 1px solid var(--cyan-dim);
|
||||||
color: #d8f6f7;
|
color: #d8f6f7;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
|
|
@ -341,12 +364,9 @@
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
/* prose renders inline so text and tool chips share the same flow */
|
.prose { white-space: pre-wrap; }
|
||||||
.prose {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* in-flight assistant "thinking" dots */
|
/* in-flight "thinking" dots */
|
||||||
.thinking,
|
.thinking,
|
||||||
.working-dots {
|
.working-dots {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -363,19 +383,15 @@
|
||||||
animation: blink 1.2s infinite ease-in-out;
|
animation: blink 1.2s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
.thinking span:nth-child(2),
|
.thinking span:nth-child(2),
|
||||||
.working-dots span:nth-child(2) {
|
.working-dots span:nth-child(2) { animation-delay: 0.18s; }
|
||||||
animation-delay: 0.18s;
|
|
||||||
}
|
|
||||||
.thinking span:nth-child(3),
|
.thinking span:nth-child(3),
|
||||||
.working-dots span:nth-child(3) {
|
.working-dots span:nth-child(3) { animation-delay: 0.36s; }
|
||||||
animation-delay: 0.36s;
|
|
||||||
}
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
|
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||||
40% { opacity: 1; transform: translateY(-2px); }
|
40% { opacity: 1; transform: translateY(-2px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* turn result / error footer inside the assistant bubble */
|
/* turn result / error / stopped footer inside the assistant bubble */
|
||||||
.turn-note {
|
.turn-note {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
|
|
@ -396,9 +412,16 @@
|
||||||
color: #bff5d3;
|
color: #bff5d3;
|
||||||
}
|
}
|
||||||
.turn-note--error {
|
.turn-note--error {
|
||||||
background: rgba(255, 77, 77, 0.08);
|
/* the error tint here is amber-leaning text on a faint warm wash, NOT the
|
||||||
border: 1px solid var(--danger-deep);
|
reserved power-action red — a turn error is not a destructive action. */
|
||||||
color: #ffd5d5;
|
background: rgba(245, 182, 87, 0.06);
|
||||||
|
border: 1px solid var(--amber-dim);
|
||||||
|
color: #f7d49a;
|
||||||
|
}
|
||||||
|
.turn-note--muted {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
.turn-note-tag {
|
.turn-note-tag {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -409,20 +432,55 @@
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.turn-note-body {
|
.turn-note-body { flex: 1; min-width: 0; }
|
||||||
flex: 1;
|
.turn-note-time { margin-left: auto; color: var(--ink-faint); }
|
||||||
min-width: 0;
|
|
||||||
}
|
/* ── dock: presets + composer, pinned to the bottom ────────────────────── */
|
||||||
.turn-note-time {
|
.dock {
|
||||||
margin-left: auto;
|
flex: none;
|
||||||
color: var(--ink-faint);
|
border-top: 1px solid var(--line);
|
||||||
|
background: linear-gradient(0deg, rgba(255, 255, 255, 0.015), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── composer ─────────────────────────────────────────────────────────── */
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 11px 12px 4px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
/* fade the right edge to hint there's more to scroll */
|
||||||
|
mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 18px), transparent 100%);
|
||||||
|
}
|
||||||
|
.presets::-webkit-scrollbar { display: none; }
|
||||||
|
.preset {
|
||||||
|
flex: none;
|
||||||
|
min-height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s, transform 0.06s;
|
||||||
|
}
|
||||||
|
.preset:hover:not(:disabled) {
|
||||||
|
border-color: var(--cyan-dim);
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
|
.preset:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.preset:disabled { opacity: 0.4; }
|
||||||
|
.preset-icon { color: var(--cyan); font-size: 12px; }
|
||||||
|
|
||||||
.composer {
|
.composer {
|
||||||
border-top: 1px solid var(--line);
|
padding: 8px 12px calc(12px + var(--safe-bottom));
|
||||||
padding: 12px;
|
|
||||||
background: linear-gradient(0deg, rgba(255, 255, 255, 0.012), transparent);
|
|
||||||
}
|
}
|
||||||
.working-bar {
|
.working-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -431,7 +489,7 @@
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
padding: 0 4px 9px;
|
padding: 2px 4px 9px;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
.composer-row {
|
.composer-row {
|
||||||
|
|
@ -442,13 +500,13 @@
|
||||||
textarea {
|
textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
resize: none;
|
resize: none;
|
||||||
max-height: 168px;
|
max-height: 160px;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 12px 13px;
|
padding: 13px 13px;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
/* 16px: anything smaller makes iOS Safari auto-zoom on focus (mobile is the
|
/* 16px: anything smaller makes iOS Safari auto-zoom on focus (mobile is the
|
||||||
primary client) — the zoom then shifts the composer out of view. */
|
primary client) — the zoom then shifts the composer out of view. */
|
||||||
|
|
@ -458,39 +516,60 @@
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
field-sizing: content; /* progressive: auto-grows where supported */
|
field-sizing: content; /* progressive: auto-grows where supported */
|
||||||
}
|
}
|
||||||
textarea::placeholder {
|
textarea::placeholder { color: var(--ink-faint); }
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: var(--cyan-dim);
|
border-color: var(--cyan-dim);
|
||||||
box-shadow: 0 0 0 3px rgba(61, 209, 214, 0.12);
|
box-shadow: 0 0 0 3px rgba(61, 209, 214, 0.12);
|
||||||
}
|
}
|
||||||
textarea:disabled {
|
textarea:disabled { opacity: 0.55; }
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send {
|
.send,
|
||||||
|
.stop {
|
||||||
flex: none;
|
flex: none;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
min-width: 78px;
|
min-width: 82px;
|
||||||
|
min-height: 48px;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--cyan-dim);
|
|
||||||
background: linear-gradient(180deg, #19474b, #103539);
|
|
||||||
color: #d8f6f7;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.05em;
|
||||||
transition: filter 0.15s, border-color 0.15s, opacity 0.15s;
|
transition: filter 0.15s, border-color 0.15s, opacity 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.send:hover:not(:disabled) {
|
.send {
|
||||||
filter: brightness(1.22);
|
border: 1px solid var(--cyan-dim);
|
||||||
border-color: var(--cyan);
|
background: linear-gradient(180deg, #16464a, #0e3438);
|
||||||
|
color: #d8f6f7;
|
||||||
}
|
}
|
||||||
|
.send:hover:not(:disabled) { filter: brightness(1.24); border-color: var(--cyan); }
|
||||||
.send:disabled {
|
.send:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
/* Stop is NOT red — red is reserved for destructive VM power. Stop is a calm
|
||||||
|
neutral control with a square "halt" glyph. */
|
||||||
|
.stop {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--line-bright);
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.stop:hover { border-color: var(--ink-faint); filter: brightness(1.1); }
|
||||||
|
.stop-glyph {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 8px rgba(245, 182, 87, 0.55);
|
||||||
|
animation: lamp-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes lamp-pulse {
|
||||||
|
0%, 100% { transform: scale(0.85); opacity: 0.8; }
|
||||||
|
50% { transform: scale(1.08); opacity: 1; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 9px 15px;
|
min-height: 44px; /* touch target */
|
||||||
|
padding: 10px 16px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -408,7 +409,8 @@
|
||||||
}
|
}
|
||||||
.confirm-yes {
|
.confirm-yes {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 9px;
|
min-height: 44px;
|
||||||
|
padding: 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--danger-bright);
|
border: 1px solid var(--danger-bright);
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
|
|
@ -424,7 +426,8 @@
|
||||||
}
|
}
|
||||||
.confirm-no {
|
.confirm-no {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 9px;
|
min-height: 44px;
|
||||||
|
padding: 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,70 @@
|
||||||
/* ───────────────────────────────────────────────────────────────────────────
|
/* ───────────────────────────────────────────────────────────────────────────
|
||||||
devvm breakglass — global theme
|
devvm breakglass — global theme
|
||||||
A recovery console: dark, high-contrast, terminal-adjacent. Calm by default;
|
Emergency recovery console / instrument panel. Dark, high-contrast, monospace
|
||||||
danger is the only loud thing on the screen. No external fonts/CDNs — system
|
identity, calm by default. Danger (red) is reserved EXCLUSIVELY for the
|
||||||
monospace carries the identity, system sans carries readable prose.
|
destructive VM power actions — nothing else on the screen is ever red. No
|
||||||
|
external fonts/CDNs (air-gapped cluster): a refined system-monospace stack
|
||||||
|
carries the identity, system-sans carries readable prose. Distinctiveness is
|
||||||
|
earned through composition, the living "system pulse" lamp, motion, hairlines,
|
||||||
|
and the reserved danger treatment — not through a downloaded typeface.
|
||||||
─────────────────────────────────────────────────────────────────────────── */
|
─────────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Surfaces — a near-black slate with cool undertone, layered for depth. */
|
/* Surfaces — a near-black slate with a cool undertone, layered for depth. */
|
||||||
--bg-0: #07090c; /* page base */
|
--bg-0: #06080b; /* page base (darkened from #07090c for crisper AA) */
|
||||||
--bg-1: #0c1015; /* panel */
|
--bg-1: #0b0f14; /* panel */
|
||||||
--bg-2: #11171e; /* raised panel / input */
|
--bg-2: #10161d; /* raised panel / input */
|
||||||
--bg-3: #161d26; /* chips, hover */
|
--bg-3: #161e27; /* chips, hover */
|
||||||
--bg-term: #06080a; /* command-output panels */
|
--bg-term: #05070a; /* command-output panels */
|
||||||
|
|
||||||
/* Hairlines & text */
|
/* Hairlines & text */
|
||||||
--line: #1d2630;
|
--line: #1c2530;
|
||||||
--line-strong: #2a3744;
|
--line-strong: #2a3744;
|
||||||
--ink: #e6edf3; /* primary text */
|
--line-bright: #3a4a5a;
|
||||||
--ink-dim: #9bb0c0; /* secondary text */
|
--ink: #e9eff5; /* primary text */
|
||||||
--ink-faint: #5d7185; /* labels, meta */
|
--ink-dim: #9bb0c0; /* secondary text — 8.0:1 on bg-2 */
|
||||||
|
/* labels/meta — was #5d7185 (3.6:1, fails AA). Lifted to 6.1:1 on bg-2. */
|
||||||
|
--ink-faint: #8499ab;
|
||||||
|
|
||||||
/* Accents */
|
/* Accents — the "alive" cyan is the spine of the calm palette. */
|
||||||
--cyan: #3dd1d6; /* "system alive" — links, focus, session dot */
|
--cyan: #3dd1d6; /* "system alive" — links, focus, session pulse */
|
||||||
|
--cyan-bright: #62e3e7;
|
||||||
--cyan-dim: #1f6f72;
|
--cyan-dim: #1f6f72;
|
||||||
|
--cyan-deep: #0e3133;
|
||||||
--amber: #f5b657; /* working / in-flight */
|
--amber: #f5b657; /* working / in-flight */
|
||||||
|
--amber-dim: #6a5226;
|
||||||
--green: #5ddb8e; /* healthy exit */
|
--green: #5ddb8e; /* healthy exit */
|
||||||
--green-dim: #1f5f3d;
|
--green-dim: #1f5f3d;
|
||||||
|
|
||||||
/* Danger — reserved EXCLUSIVELY for mutating actions. Nothing else is red. */
|
/* Danger — reserved EXCLUSIVELY for mutating power actions. Nothing else red. */
|
||||||
--danger: #ff4d4d;
|
--danger: #ff4d4d;
|
||||||
--danger-bright: #ff6363;
|
--danger-bright: #ff6363;
|
||||||
--danger-deep: #7a1717;
|
--danger-deep: #7a1717;
|
||||||
--danger-glow: rgba(255, 77, 77, 0.35);
|
--danger-glow: rgba(255, 77, 77, 0.35);
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 11px;
|
||||||
--radius-sm: 7px;
|
--radius-sm: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
|
||||||
--mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code",
|
/* A refined, deliberately-ordered monospace stack. We lead with faces that
|
||||||
"Fira Code", Menlo, Consolas, "Liberation Mono", monospace;
|
have real character (Berkeley Mono / JetBrains / Cascadia / SF Mono) and
|
||||||
|
fall back gracefully — but ship nothing; whatever the device has carries
|
||||||
|
the cockpit-readout identity. */
|
||||||
|
--mono: "Berkeley Mono", ui-monospace, "JetBrains Mono", "SF Mono",
|
||||||
|
"Cascadia Code", "Fira Code", "Source Code Pro", Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
"Helvetica Neue", Arial, sans-serif;
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
||||||
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.02) inset,
|
--shadow-panel: 0 1px 0 rgba(255, 255, 255, 0.025) inset,
|
||||||
0 16px 40px -24px rgba(0, 0, 0, 0.9);
|
0 18px 44px -26px rgba(0, 0, 0, 0.95);
|
||||||
|
--shadow-sheet: 0 -22px 48px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
/* Safe-area shorthands (notch / home-indicator). 0px fallback off-device. */
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
@ -55,23 +77,24 @@ html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
/* The page itself never scrolls — the chat stream scrolls internally. This
|
/* The page itself never scrolls — only the chat stream scrolls internally.
|
||||||
keeps the composer pinned and stops iOS rubber-banding the whole UI. */
|
This keeps the composer pinned and stops iOS rubber-banding the whole UI. */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-0);
|
background-color: var(--bg-0);
|
||||||
/* Atmosphere: a soft cyan corner-glow over a faint scanline weave, so the
|
/* Atmosphere: a soft cyan corner-glow + a faint warm counter-glow over a
|
||||||
surface reads like backlit equipment rather than flat #000. */
|
hairline scanline weave, so the surface reads as backlit equipment rather
|
||||||
|
than flat black. Fixed so it doesn't drift when the chat scrolls. */
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(120% 80% at 85% -10%, rgba(61, 209, 214, 0.07), transparent 55%),
|
radial-gradient(120% 78% at 86% -12%, rgba(61, 209, 214, 0.08), transparent 55%),
|
||||||
radial-gradient(90% 70% at 10% 110%, rgba(245, 182, 87, 0.04), transparent 50%),
|
radial-gradient(90% 70% at 8% 112%, rgba(245, 182, 87, 0.045), transparent 52%),
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
0deg,
|
0deg,
|
||||||
rgba(255, 255, 255, 0.012) 0px,
|
rgba(255, 255, 255, 0.013) 0px,
|
||||||
rgba(255, 255, 255, 0.012) 1px,
|
rgba(255, 255, 255, 0.013) 1px,
|
||||||
transparent 1px,
|
transparent 1px,
|
||||||
transparent 3px
|
transparent 3px
|
||||||
);
|
);
|
||||||
|
|
@ -84,8 +107,8 @@ body {
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
/* 100dvh (dynamic viewport height) — NOT 100vh/100% — so the composer at the
|
/* 100dvh (dynamic viewport height) — NOT 100vh/100% — so the composer at the
|
||||||
bottom is never hidden behind a mobile browser's address/tool bar. Mobile is
|
bottom is never hidden behind a mobile browser's address/tool bar. 100vh is
|
||||||
the primary client for this tool. 100vh is the fallback for old engines. */
|
the fallback for engines without dvh. Mobile is the primary client. */
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +117,6 @@ button {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
@ -119,10 +141,26 @@ button:disabled {
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
background: #3a4a5a;
|
background: var(--line-bright);
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared motion primitives ──────────────────────────────────────────────
|
||||||
|
One well-orchestrated entrance beats scattered micro-interactions: panels
|
||||||
|
and rows rise a few px with a soft fade, staggered via --d on each element. */
|
||||||
|
@keyframes rise-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.rise-in {
|
||||||
|
animation: rise-in 0.5s cubic-bezier(0.22, 0.61, 0.36, 1) both;
|
||||||
|
animation-delay: var(--d, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,41 @@
|
||||||
// Same-origin API client. Auth is handled entirely by the edge proxy
|
// Same-origin API client for the breakglass UI.
|
||||||
// (Authentik / basic-auth / bearer) — this UI never sends or stores a token.
|
//
|
||||||
import { readEventStream } from './sse.js';
|
// Auth is handled entirely by the edge proxy (Authentik / basic-auth / bearer):
|
||||||
|
// this UI never sends or stores a token, and builds no login screen.
|
||||||
|
//
|
||||||
|
// The chat uses the tmux/attach model. The conversation lives SERVER-SIDE; we
|
||||||
|
// only persist the session_id locally and ATTACH to it over an EventSource. The
|
||||||
|
// browser's native EventSource auto-reconnects and sends Last-Event-ID, and the
|
||||||
|
// server resumes from there — so there is ZERO reconnect logic here. We just
|
||||||
|
// render events idempotently by id (see transcript.js).
|
||||||
|
|
||||||
/** Open a fresh chat session. @returns {Promise<string>} session_id */
|
const SESSION_KEY = 'breakglass.session_id';
|
||||||
|
|
||||||
|
/** Read the persisted session id, or '' if none. */
|
||||||
|
export function loadSessionId() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SESSION_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the session id (best-effort; private-mode storage may throw). */
|
||||||
|
export function saveSessionId(id) {
|
||||||
|
try {
|
||||||
|
if (id) localStorage.setItem(SESSION_KEY, id);
|
||||||
|
else localStorage.removeItem(SESSION_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore — storage is a convenience, not a requirement */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the persisted session id (the "New session" archive step). */
|
||||||
|
export function clearSessionId() {
|
||||||
|
saveSessionId('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a fresh server-side session. @returns {Promise<string>} session_id */
|
||||||
export async function openSession() {
|
export async function openSession() {
|
||||||
const res = await fetch('/api/session', {
|
const res = await fetch('/api/session', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -19,30 +52,89 @@ export async function openSession() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one chat turn. Streams events to onEvent until the backend sends
|
* Attach to a session's event stream. Returns the live EventSource so the
|
||||||
* {kind:"done"} and the connection closes. Pass an AbortSignal to cancel.
|
* caller can close() it. Events arrive as:
|
||||||
|
* - default `message` events: .data is JSON {kind, id, ...}
|
||||||
|
* - a named `caught-up` event once the replay is drained (.data is {})
|
||||||
|
* - native `error` events while reconnecting (EventSource retries itself)
|
||||||
*
|
*
|
||||||
* @param {{session_id: string, prompt: string, model?: string, signal?: AbortSignal}} opts
|
* @param {string} sessionId
|
||||||
* @param {(event: object) => void} onEvent
|
* @param {{
|
||||||
|
* onEvent: (e: object) => void,
|
||||||
|
* onCaughtUp?: () => void,
|
||||||
|
* onOpen?: () => void,
|
||||||
|
* onError?: (e: Event) => void,
|
||||||
|
* }} handlers
|
||||||
|
* @returns {EventSource}
|
||||||
*/
|
*/
|
||||||
export async function streamChat({ session_id, prompt, model, signal }, onEvent) {
|
export function attachStream(sessionId, { onEvent, onCaughtUp, onOpen, onError }) {
|
||||||
const payload = { session_id, prompt };
|
const es = new EventSource(`/api/session/${encodeURIComponent(sessionId)}/stream`);
|
||||||
if (model) payload.model = model;
|
|
||||||
|
|
||||||
const res = await fetch('/api/chat', {
|
es.onopen = () => onOpen?.();
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
es.onmessage = (e) => {
|
||||||
'content-type': 'application/json',
|
if (!e || typeof e.data !== 'string' || e.data === '') return;
|
||||||
accept: 'text/event-stream',
|
let obj;
|
||||||
},
|
try {
|
||||||
body: JSON.stringify(payload),
|
obj = JSON.parse(e.data);
|
||||||
signal,
|
} catch {
|
||||||
});
|
// A malformed frame must not abort an in-progress recovery stream.
|
||||||
await readEventStream(res, onEvent);
|
return;
|
||||||
|
}
|
||||||
|
// EventSource exposes the SSE `id:` line as e.lastEventId. The server also
|
||||||
|
// embeds id in the JSON; prefer the JSON id, fall back to lastEventId.
|
||||||
|
if ((obj.id == null || obj.id === '') && e.lastEventId) obj.id = e.lastEventId;
|
||||||
|
onEvent(obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener('caught-up', () => onCaughtUp?.());
|
||||||
|
|
||||||
|
es.onerror = (e) => {
|
||||||
|
// EventSource auto-reconnects on a transient drop (readyState CONNECTING);
|
||||||
|
// we only surface a hard, terminal failure (readyState CLOSED).
|
||||||
|
onError?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return es;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List the PVE power verbs and which of them mutate VM state.
|
* Start a turn. Output arrives via the attach stream, NOT this response.
|
||||||
|
* @param {{session_id: string, prompt: string, model?: string}} opts
|
||||||
|
* @returns {Promise<{status:'started'|'busy'|'gone'}>}
|
||||||
|
* started — accepted; busy — 409 (a turn already runs); gone — 404 (re-create).
|
||||||
|
*/
|
||||||
|
export async function sendPrompt({ session_id, prompt, model }) {
|
||||||
|
const payload = { prompt };
|
||||||
|
if (model) payload.model = model;
|
||||||
|
const res = await fetch(`/api/session/${encodeURIComponent(session_id)}/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.status === 409) return { status: 'busy' };
|
||||||
|
if (res.status === 404) return { status: 'gone' };
|
||||||
|
if (!res.ok) throw new Error(`could not start the turn (HTTP ${res.status})`);
|
||||||
|
return { status: 'started' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the in-flight turn (the Stop button).
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {Promise<boolean>} whether a turn was cancelled
|
||||||
|
*/
|
||||||
|
export async function cancelTurn(sessionId) {
|
||||||
|
const res = await fetch(`/api/session/${encodeURIComponent(sessionId)}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`could not stop the turn (HTTP ${res.status})`);
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
return Boolean(body.cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the PVE power verbs and which mutate VM state.
|
||||||
* @returns {Promise<{verbs: string[], mutating: string[]}>}
|
* @returns {Promise<{verbs: string[], mutating: string[]}>}
|
||||||
*/
|
*/
|
||||||
export async function fetchVerbs() {
|
export async function fetchVerbs() {
|
||||||
|
|
@ -58,27 +150,26 @@ export async function fetchVerbs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a PVE power verb directly (no AI in the path). The backend returns 200
|
* Run a PVE power verb directly (no AI in the path). The backend returns 200 on
|
||||||
* on success and 502 when the verb's exit code is non-zero, but the JSON body
|
* success and 502 when the verb's exit code is non-zero, but the JSON body
|
||||||
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases — so we
|
* carries {verb, exit_code, stdout, stderr, rejected} in BOTH cases — so we read
|
||||||
* read the body regardless of HTTP status and let the caller style on
|
* the body regardless of HTTP status and let the caller style on exit_code.
|
||||||
* exit_code / rejected.
|
|
||||||
*
|
*
|
||||||
* @param {string} verb
|
* @param {string} verb
|
||||||
* @returns {Promise<{verb: string, exit_code: number|null, stdout: string, stderr: string, rejected: boolean}>}
|
* @returns {Promise<{verb:string, exit_code:number|null, stdout:string, stderr:string, rejected:boolean}>}
|
||||||
*/
|
*/
|
||||||
export async function runVerb(verb) {
|
export async function runVerb(verb) {
|
||||||
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
|
const res = await fetch(`/api/pve/${encodeURIComponent(verb)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
});
|
});
|
||||||
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = await res.json();
|
body = await res.json();
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
|
throw new Error(`VM control '${verb}' failed (HTTP ${res.status}, no body)`);
|
||||||
}
|
}
|
||||||
|
// 400 = unknown verb (FastAPI HTTPException) — has {detail}, not the verb shape.
|
||||||
if (res.status === 400) {
|
if (res.status === 400) {
|
||||||
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
|
throw new Error(body?.detail || `'${verb}' was rejected by the server`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
// SSE frame parsing — the load-bearing core of the breakglass UI.
|
|
||||||
//
|
|
||||||
// The /api/chat endpoint returns a text/event-stream that we read with
|
|
||||||
// fetch() + response.body.getReader() (NOT EventSource, which cannot POST).
|
|
||||||
// The backend emits one frame per event as:
|
|
||||||
//
|
|
||||||
// data: {json}\n\n
|
|
||||||
//
|
|
||||||
// getReader() hands us bytes at arbitrary boundaries: a single frame can be
|
|
||||||
// split across reads, and one read can contain several frames. So we keep a
|
|
||||||
// rolling text buffer, split it on the blank-line frame delimiter, and only
|
|
||||||
// hand back the JSON payload of *complete* frames. Per the SSE spec a frame may
|
|
||||||
// carry multiple `data:` lines (joined with "\n"); the backend emits single
|
|
||||||
// line JSON today, but we handle the general case so a future multi-line
|
|
||||||
// payload can't silently corrupt the stream.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a single SSE event block (the text between blank lines) into its data
|
|
||||||
* payload string, or null if the block carries no `data:` field (e.g. a bare
|
|
||||||
* comment or a `:` heartbeat).
|
|
||||||
* @param {string} block
|
|
||||||
* @returns {string|null}
|
|
||||||
*/
|
|
||||||
export function dataFromEventBlock(block) {
|
|
||||||
const dataLines = [];
|
|
||||||
for (const rawLine of block.split('\n')) {
|
|
||||||
const line = rawLine.replace(/\r$/, '');
|
|
||||||
if (line.startsWith(':')) continue; // SSE comment / heartbeat
|
|
||||||
if (line === 'data:' || line === 'data') {
|
|
||||||
dataLines.push('');
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
// Spec: a single leading space after the colon is stripped.
|
|
||||||
let v = line.slice('data:'.length);
|
|
||||||
if (v.startsWith(' ')) v = v.slice(1);
|
|
||||||
dataLines.push(v);
|
|
||||||
}
|
|
||||||
// field lines we don't care about (event:, id:, retry:) are ignored
|
|
||||||
}
|
|
||||||
if (dataLines.length === 0) return null;
|
|
||||||
return dataLines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A stateful splitter that turns an arbitrary sequence of decoded text chunks
|
|
||||||
* into a sequence of complete SSE event-block strings. Frames are delimited by
|
|
||||||
* a blank line; we tolerate both "\n\n" and "\r\n\r\n".
|
|
||||||
*/
|
|
||||||
export class SSEFrameSplitter {
|
|
||||||
constructor() {
|
|
||||||
this.buffer = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feed a decoded text chunk; returns the event blocks that are now complete.
|
|
||||||
* Any trailing partial frame stays buffered for the next chunk.
|
|
||||||
* @param {string} chunk
|
|
||||||
* @returns {string[]} complete event blocks (text between delimiters)
|
|
||||||
*/
|
|
||||||
push(chunk) {
|
|
||||||
this.buffer += chunk;
|
|
||||||
const blocks = [];
|
|
||||||
// Normalise CRLF delimiters to LF so a single split rule covers both.
|
|
||||||
let idx;
|
|
||||||
// Process every complete frame currently in the buffer.
|
|
||||||
while ((idx = this._nextDelimiter()) !== -1) {
|
|
||||||
const block = this.buffer.slice(0, idx.start);
|
|
||||||
this.buffer = this.buffer.slice(idx.end);
|
|
||||||
if (block.length > 0) blocks.push(block);
|
|
||||||
}
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On stream end, return whatever complete-looking content remains. A
|
|
||||||
* well-behaved backend always terminates the last frame with a blank line,
|
|
||||||
* so this is usually empty — but if the connection closed mid-trailing-frame
|
|
||||||
* with a parseable block, surface it rather than dropping data.
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
flush() {
|
|
||||||
const rest = this.buffer.trim();
|
|
||||||
this.buffer = '';
|
|
||||||
return rest ? [rest] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
_nextDelimiter() {
|
|
||||||
// Find the earliest of "\n\n", "\r\n\r\n", "\r\r".
|
|
||||||
const candidates = [
|
|
||||||
{ token: '\r\n\r\n', i: this.buffer.indexOf('\r\n\r\n') },
|
|
||||||
{ token: '\n\n', i: this.buffer.indexOf('\n\n') },
|
|
||||||
{ token: '\r\r', i: this.buffer.indexOf('\r\r') },
|
|
||||||
].filter((c) => c.i !== -1);
|
|
||||||
if (candidates.length === 0) return -1;
|
|
||||||
candidates.sort((a, b) => a.i - b.i);
|
|
||||||
const { token, i } = candidates[0];
|
|
||||||
return { start: i, end: i + token.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read an SSE Response body to completion, invoking onEvent for every parsed
|
|
||||||
* JSON event object. Resolves when the stream ends. Throws if the response is
|
|
||||||
* not ok or has no readable body (caller shows the error inline).
|
|
||||||
*
|
|
||||||
* @param {Response} response a fetch() Response with a streaming body
|
|
||||||
* @param {(event: object) => void} onEvent called per parsed JSON event
|
|
||||||
*/
|
|
||||||
export async function readEventStream(response, onEvent) {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`server returned ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error('response has no readable body (streaming unsupported)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const splitter = new SSEFrameSplitter();
|
|
||||||
|
|
||||||
const handleBlock = (block) => {
|
|
||||||
const payload = dataFromEventBlock(block);
|
|
||||||
if (payload == null || payload.trim() === '') return;
|
|
||||||
let obj;
|
|
||||||
try {
|
|
||||||
obj = JSON.parse(payload);
|
|
||||||
} catch {
|
|
||||||
// A malformed frame must not abort an in-progress recovery stream;
|
|
||||||
// skip it and keep reading.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onEvent(obj);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
const text = decoder.decode(value, { stream: true });
|
|
||||||
for (const block of splitter.push(text)) handleBlock(block);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock?.();
|
|
||||||
}
|
|
||||||
// Drain any trailing bytes the decoder held, then any final frame.
|
|
||||||
const tail = decoder.decode();
|
|
||||||
if (tail) {
|
|
||||||
for (const block of splitter.push(tail)) handleBlock(block);
|
|
||||||
}
|
|
||||||
for (const block of splitter.flush()) handleBlock(block);
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
// Standalone test of the SSE frame parser — no test framework, just node.
|
|
||||||
// Run: node src/lib/sse.test.mjs (exits non-zero on any failure)
|
|
||||||
//
|
|
||||||
// These pin the protocol described in the API contract: frames are
|
|
||||||
// `data: {json}\n\n`, the event `kind` is one of session/text/tool/result/
|
|
||||||
// error/done, and bytes arrive at arbitrary boundaries via getReader().
|
|
||||||
import { SSEFrameSplitter, dataFromEventBlock, readEventStream } from './sse.js';
|
|
||||||
|
|
||||||
let failures = 0;
|
|
||||||
function ok(name, cond) {
|
|
||||||
if (cond) {
|
|
||||||
console.log(` ok ${name}`);
|
|
||||||
} else {
|
|
||||||
failures++;
|
|
||||||
console.error(`FAIL ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function eq(name, got, want) {
|
|
||||||
const g = JSON.stringify(got);
|
|
||||||
const w = JSON.stringify(want);
|
|
||||||
ok(`${name} (got ${g})`, g === w);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- dataFromEventBlock ---------------------------------------------------
|
|
||||||
eq(
|
|
||||||
'extracts JSON payload from a data: line',
|
|
||||||
dataFromEventBlock('data: {"kind":"text","text":"hi"}'),
|
|
||||||
'{"kind":"text","text":"hi"}'
|
|
||||||
);
|
|
||||||
eq(
|
|
||||||
'strips exactly one space after the colon',
|
|
||||||
dataFromEventBlock('data: leading-space-kept'),
|
|
||||||
' leading-space-kept'
|
|
||||||
);
|
|
||||||
eq('ignores comment/heartbeat lines', dataFromEventBlock(': keep-alive'), null);
|
|
||||||
eq(
|
|
||||||
'joins multi-line data fields with newline',
|
|
||||||
dataFromEventBlock('data: line1\ndata: line2'),
|
|
||||||
'line1\nline2'
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- SSEFrameSplitter: whole frames --------------------------------------
|
|
||||||
{
|
|
||||||
const s = new SSEFrameSplitter();
|
|
||||||
const blocks = s.push('data: {"kind":"session","session_id":"abc"}\n\n');
|
|
||||||
eq('one complete frame yields one block', blocks, [
|
|
||||||
'data: {"kind":"session","session_id":"abc"}',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SSEFrameSplitter: multiple frames in one chunk ----------------------
|
|
||||||
{
|
|
||||||
const s = new SSEFrameSplitter();
|
|
||||||
const blocks = s.push(
|
|
||||||
'data: {"kind":"text","text":"a"}\n\ndata: {"kind":"text","text":"b"}\n\n'
|
|
||||||
);
|
|
||||||
eq('two frames in one chunk yield two blocks', blocks.length, 2);
|
|
||||||
eq('first block', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"a"}');
|
|
||||||
eq('second block', dataFromEventBlock(blocks[1]), '{"kind":"text","text":"b"}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SSEFrameSplitter: frame split across chunks -------------------------
|
|
||||||
{
|
|
||||||
const s = new SSEFrameSplitter();
|
|
||||||
let blocks = s.push('data: {"kind":"te');
|
|
||||||
eq('partial frame yields nothing yet', blocks, []);
|
|
||||||
blocks = s.push('xt","text":"split"}\n\n');
|
|
||||||
eq('completing the frame yields it whole', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"split"}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SSEFrameSplitter: delimiter split across chunks ---------------------
|
|
||||||
{
|
|
||||||
const s = new SSEFrameSplitter();
|
|
||||||
let blocks = s.push('data: {"kind":"done"}\n');
|
|
||||||
eq('frame held while delimiter incomplete', blocks, []);
|
|
||||||
blocks = s.push('\n');
|
|
||||||
eq('frame released once blank line completes', dataFromEventBlock(blocks[0]), '{"kind":"done"}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SSEFrameSplitter: CRLF delimiters -----------------------------------
|
|
||||||
{
|
|
||||||
const s = new SSEFrameSplitter();
|
|
||||||
const blocks = s.push('data: {"kind":"text","text":"crlf"}\r\n\r\n');
|
|
||||||
eq('CRLF-delimited frame parses', dataFromEventBlock(blocks[0]), '{"kind":"text","text":"crlf"}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- end-to-end via readEventStream over a mock streaming Response --------
|
|
||||||
function mockResponse(chunks) {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
let i = 0;
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
getReader() {
|
|
||||||
return {
|
|
||||||
read() {
|
|
||||||
if (i < chunks.length) {
|
|
||||||
return Promise.resolve({ value: enc.encode(chunks[i++]), done: false });
|
|
||||||
}
|
|
||||||
return Promise.resolve({ value: undefined, done: true });
|
|
||||||
},
|
|
||||||
releaseLock() {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await (async () => {
|
|
||||||
// A realistic turn, deliberately chopped at ugly boundaries:
|
|
||||||
// - the session frame split mid-JSON
|
|
||||||
// - two text frames glued together
|
|
||||||
// - a tool frame
|
|
||||||
// - a result frame and the terminal done frame in one chunk
|
|
||||||
const chunks = [
|
|
||||||
'data: {"kind":"sess',
|
|
||||||
'ion","session_id":"S1"}\n\n',
|
|
||||||
'data: {"kind":"text","text":"checking "}\n\ndata: {"kind":"text","text":"disk"}\n\n',
|
|
||||||
'data: {"kind":"tool","name":"Bash","input":{"command":"df -h"}}\n\n',
|
|
||||||
'data: {"kind":"result","is_error":false,"result":"ok","duration_ms":12}\n\ndata: {"kind":"done"}\n\n',
|
|
||||||
];
|
|
||||||
const events = [];
|
|
||||||
await readEventStream(mockResponse(chunks), (e) => events.push(e));
|
|
||||||
|
|
||||||
eq('event count', events.length, 6);
|
|
||||||
eq('1: session id', events[0], { kind: 'session', session_id: 'S1' });
|
|
||||||
eq('2: first text', events[1], { kind: 'text', text: 'checking ' });
|
|
||||||
eq('3: second text', events[2], { kind: 'text', text: 'disk' });
|
|
||||||
eq('4: tool kind+name', { kind: events[3].kind, name: events[3].name }, { kind: 'tool', name: 'Bash' });
|
|
||||||
eq('4: tool command', events[3].input.command, 'df -h');
|
|
||||||
eq('5: result', events[4], { kind: 'result', is_error: false, result: 'ok', duration_ms: 12 });
|
|
||||||
eq('6: done terminal', events[5], { kind: 'done' });
|
|
||||||
})();
|
|
||||||
|
|
||||||
// malformed frame in the middle must be skipped, not abort the stream
|
|
||||||
await (async () => {
|
|
||||||
const chunks = [
|
|
||||||
'data: {"kind":"text","text":"before"}\n\n',
|
|
||||||
'data: {this is not json}\n\n',
|
|
||||||
'data: {"kind":"done"}\n\n',
|
|
||||||
];
|
|
||||||
const events = [];
|
|
||||||
await readEventStream(mockResponse(chunks), (e) => events.push(e));
|
|
||||||
eq('malformed frame skipped, stream continues', events.map((e) => e.kind), ['text', 'done']);
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (failures) {
|
|
||||||
console.error(`\n${failures} assertion(s) FAILED`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('\nall SSE parser assertions passed');
|
|
||||||
196
frontend/src/lib/transcript.js
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
// transcript.js — the load-bearing core of the breakglass UI.
|
||||||
|
//
|
||||||
|
// The attach stream (EventSource) replays the conversation-so-far and then
|
||||||
|
// tails live. Replayed events are byte-identical to live ones, and on a
|
||||||
|
// reconnect the server re-replays from Last-Event-ID — so the SAME event id can
|
||||||
|
// arrive more than once. This module folds a flat, possibly-duplicated event
|
||||||
|
// sequence into an ordered list of render-ready messages, idempotently.
|
||||||
|
//
|
||||||
|
// Contract (every default `message` event's .data is one of these JSON shapes):
|
||||||
|
// {kind:"user", text, id} → opens a USER bubble
|
||||||
|
// {kind:"session", session_id, id} → informational (agent's session id)
|
||||||
|
// {kind:"text", text, id} → assistant prose; concatenated
|
||||||
|
// {kind:"tool", name, input, id} → inline tool chip (Bash → command)
|
||||||
|
// {kind:"result", is_error, result, duration_ms, id} → closes the bubble
|
||||||
|
// {kind:"error", error, id} → error note on the bubble
|
||||||
|
// {kind:"cancelled", id} → muted "stopped" note
|
||||||
|
// {kind:"turn_end", id} → the turn finished
|
||||||
|
//
|
||||||
|
// Grouping: a `user` event opens a user message; the session/text/tool events
|
||||||
|
// that follow build ONE assistant message; result/error/cancelled annotate it;
|
||||||
|
// turn_end ends it. Assistant events with no preceding user (e.g. a session
|
||||||
|
// banner on a fresh attach) still get an assistant message so nothing is lost.
|
||||||
|
//
|
||||||
|
// Idempotency: every event carries a monotonic integer-ish id. We track the
|
||||||
|
// max id folded so far and DROP any event whose id we've already passed — a
|
||||||
|
// reconnect replay therefore never double-renders. Ids are compared
|
||||||
|
// numerically when both parse as numbers, else as strings (defensive).
|
||||||
|
|
||||||
|
/** @typedef {{type:'text',text:string}|{type:'tool',name:string,command:string,raw:any}} Part */
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Message
|
||||||
|
* @property {'user'|'assistant'} role
|
||||||
|
* @property {string} key stable key for keyed {#each}
|
||||||
|
* @property {string} [text] user text
|
||||||
|
* @property {Part[]} [parts] assistant parts, in emit order
|
||||||
|
* @property {{is_error:boolean,text:string,duration_ms:number|null}} [result]
|
||||||
|
* @property {string} [error]
|
||||||
|
* @property {boolean} [cancelled]
|
||||||
|
* @property {boolean} [ended] turn_end seen for this message
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Compare two ids; numeric when both look numeric, else lexicographic. */
|
||||||
|
export function idGreater(a, b) {
|
||||||
|
const na = Number(a);
|
||||||
|
const nb = Number(b);
|
||||||
|
if (Number.isFinite(na) && Number.isFinite(nb) && `${a}`.trim() !== '' && `${b}`.trim() !== '') {
|
||||||
|
return na > nb;
|
||||||
|
}
|
||||||
|
return String(a) > String(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty transcript-folding state.
|
||||||
|
* @returns {{messages: Message[], maxId: any, sawId: boolean, openAssistant: Message|null, activeUserSeen: boolean}}
|
||||||
|
*/
|
||||||
|
export function createTranscript() {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
maxId: null,
|
||||||
|
sawId: false,
|
||||||
|
openAssistant: null,
|
||||||
|
// a turn is "active" once a user event (or local prompt) has no following
|
||||||
|
// turn_end; the UI reads `active` from reduceEvent's return.
|
||||||
|
activeUserSeen: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubbleKey(prefix, id, fallbackIndex) {
|
||||||
|
if (id != null && `${id}`.trim() !== '') return `${prefix}:${id}`;
|
||||||
|
return `${prefix}:idx:${fallbackIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should this event be applied, given the max id folded so far? Updates and
|
||||||
|
* returns the new max. Events WITHOUT an id are always applied (and don't move
|
||||||
|
* the watermark) — the protocol always carries ids, but we never drop data on a
|
||||||
|
* malformed frame.
|
||||||
|
* @returns {{apply:boolean, maxId:any}}
|
||||||
|
*/
|
||||||
|
export function admit(maxId, id) {
|
||||||
|
if (id == null || `${id}`.trim() === '') return { apply: true, maxId };
|
||||||
|
if (maxId == null) return { apply: true, maxId: id };
|
||||||
|
if (idGreater(id, maxId)) return { apply: true, maxId: id };
|
||||||
|
return { apply: false, maxId }; // already seen — dedupe
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold one event into the transcript state, mutating `state` in place.
|
||||||
|
* Returns true if the state changed (so callers can trigger a re-render).
|
||||||
|
*
|
||||||
|
* @param {ReturnType<typeof createTranscript>} state
|
||||||
|
* @param {any} ev parsed event object ({kind, id, ...})
|
||||||
|
* @returns {boolean} changed
|
||||||
|
*/
|
||||||
|
export function reduceEvent(state, ev) {
|
||||||
|
if (!ev || typeof ev !== 'object') return false;
|
||||||
|
const { apply, maxId } = admit(state.maxId, ev.id);
|
||||||
|
state.maxId = maxId;
|
||||||
|
if (!apply) return false;
|
||||||
|
if (ev.id != null && `${ev.id}`.trim() !== '') state.sawId = true;
|
||||||
|
|
||||||
|
const ensureAssistant = () => {
|
||||||
|
if (!state.openAssistant) {
|
||||||
|
const msg = {
|
||||||
|
role: 'assistant',
|
||||||
|
key: bubbleKey('a', ev.id, state.messages.length),
|
||||||
|
parts: [],
|
||||||
|
ended: false,
|
||||||
|
};
|
||||||
|
state.messages.push(msg);
|
||||||
|
state.openAssistant = msg;
|
||||||
|
}
|
||||||
|
return state.openAssistant;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'user': {
|
||||||
|
// A new user turn. Close any dangling assistant bubble first.
|
||||||
|
state.openAssistant = null;
|
||||||
|
state.messages.push({
|
||||||
|
role: 'user',
|
||||||
|
key: bubbleKey('u', ev.id, state.messages.length),
|
||||||
|
text: typeof ev.text === 'string' ? ev.text : '',
|
||||||
|
});
|
||||||
|
state.activeUserSeen = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'session': {
|
||||||
|
// Informational — does not itself render a part, but it does open the
|
||||||
|
// assistant bubble for the turn so subsequent text lands in one place.
|
||||||
|
ensureAssistant();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
if (typeof ev.text !== 'string' || ev.text === '') return false;
|
||||||
|
const msg = ensureAssistant();
|
||||||
|
const tail = msg.parts[msg.parts.length - 1];
|
||||||
|
if (tail && tail.type === 'text') {
|
||||||
|
tail.text += ev.text; // concatenate consecutive prose
|
||||||
|
} else {
|
||||||
|
msg.parts.push({ type: 'text', text: ev.text });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'tool': {
|
||||||
|
const msg = ensureAssistant();
|
||||||
|
const command =
|
||||||
|
ev.input && typeof ev.input.command === 'string' ? ev.input.command : '';
|
||||||
|
msg.parts.push({
|
||||||
|
type: 'tool',
|
||||||
|
name: typeof ev.name === 'string' && ev.name ? ev.name : 'tool',
|
||||||
|
command,
|
||||||
|
raw: ev.input ?? null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'result': {
|
||||||
|
const msg = ensureAssistant();
|
||||||
|
msg.result = {
|
||||||
|
is_error: Boolean(ev.is_error),
|
||||||
|
text: typeof ev.result === 'string' ? ev.result : '',
|
||||||
|
duration_ms: typeof ev.duration_ms === 'number' ? ev.duration_ms : null,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'error': {
|
||||||
|
const msg = ensureAssistant();
|
||||||
|
msg.error = typeof ev.error === 'string' && ev.error ? ev.error : 'unknown error';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'cancelled': {
|
||||||
|
const msg = ensureAssistant();
|
||||||
|
msg.cancelled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'turn_end': {
|
||||||
|
if (state.openAssistant) state.openAssistant.ended = true;
|
||||||
|
state.openAssistant = null;
|
||||||
|
state.activeUserSeen = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: fold an array of events into a fresh transcript (used by tests
|
||||||
|
* and by a from-scratch render). Returns the final state.
|
||||||
|
* @param {any[]} events
|
||||||
|
*/
|
||||||
|
export function foldAll(events) {
|
||||||
|
const state = createTranscript();
|
||||||
|
for (const ev of events) reduceEvent(state, ev);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
162
frontend/src/lib/transcript.test.mjs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// Standalone test of the transcript folder — no test framework, just node.
|
||||||
|
// Run: node src/lib/transcript.test.mjs (exits non-zero on any failure)
|
||||||
|
//
|
||||||
|
// These pin the attach-model contract: events carry monotonic ids, a reconnect
|
||||||
|
// re-replays already-seen ids (which MUST be deduped), and events group into
|
||||||
|
// user/assistant messages with consecutive prose concatenated.
|
||||||
|
import {
|
||||||
|
admit,
|
||||||
|
idGreater,
|
||||||
|
reduceEvent,
|
||||||
|
createTranscript,
|
||||||
|
foldAll,
|
||||||
|
} from './transcript.js';
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function ok(name, cond) {
|
||||||
|
if (cond) {
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} else {
|
||||||
|
failures++;
|
||||||
|
console.error(`FAIL ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function eq(name, got, want) {
|
||||||
|
const g = JSON.stringify(got);
|
||||||
|
const w = JSON.stringify(want);
|
||||||
|
ok(`${name} (got ${g})`, g === w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- id comparison --------------------------------------------------------
|
||||||
|
ok('idGreater numeric', idGreater(10, 9) === true);
|
||||||
|
ok('idGreater numeric not', idGreater(2, 10) === false); // not string "2" > "10"
|
||||||
|
ok('idGreater string fallback', idGreater('b', 'a') === true);
|
||||||
|
|
||||||
|
// --- admit / dedupe watermark --------------------------------------------
|
||||||
|
{
|
||||||
|
let { apply, maxId } = admit(null, 1);
|
||||||
|
eq('first id admitted', { apply, maxId }, { apply: true, maxId: 1 });
|
||||||
|
({ apply, maxId } = admit(5, 5));
|
||||||
|
ok('equal id rejected (already seen)', apply === false && maxId === 5);
|
||||||
|
({ apply, maxId } = admit(5, 3));
|
||||||
|
ok('lower id rejected', apply === false && maxId === 5);
|
||||||
|
({ apply, maxId } = admit(5, 6));
|
||||||
|
ok('higher id admitted, watermark moves', apply === true && maxId === 6);
|
||||||
|
({ apply, maxId } = admit(5, undefined));
|
||||||
|
ok('id-less event always admitted, watermark held', apply === true && maxId === 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- a full turn groups into user + one assistant bubble ------------------
|
||||||
|
{
|
||||||
|
const events = [
|
||||||
|
{ kind: 'user', text: 'triage it', id: 1 },
|
||||||
|
{ kind: 'session', session_id: 'S1', id: 2 },
|
||||||
|
{ kind: 'text', text: 'Checking ', id: 3 },
|
||||||
|
{ kind: 'text', text: 'disk usage.', id: 4 },
|
||||||
|
{ kind: 'tool', name: 'Bash', input: { command: 'df -h' }, id: 5 },
|
||||||
|
{ kind: 'result', is_error: false, result: 'ok', duration_ms: 1200, id: 6 },
|
||||||
|
{ kind: 'turn_end', id: 7 },
|
||||||
|
];
|
||||||
|
const s = foldAll(events);
|
||||||
|
eq('two messages: user + assistant', s.messages.length, 2);
|
||||||
|
eq('first is user with text', { r: s.messages[0].role, t: s.messages[0].text }, { r: 'user', t: 'triage it' });
|
||||||
|
const a = s.messages[1];
|
||||||
|
eq('assistant role', a.role, 'assistant');
|
||||||
|
// consecutive text concatenated into ONE part; tool is a separate part
|
||||||
|
eq('parts: one concatenated text + one tool', a.parts.map((p) => p.type), ['text', 'tool']);
|
||||||
|
eq('prose concatenated in order', a.parts[0].text, 'Checking disk usage.');
|
||||||
|
eq('tool command captured', a.parts[1].command, 'df -h');
|
||||||
|
eq('result attached', { e: a.result.is_error, ms: a.result.duration_ms }, { e: false, ms: 1200 });
|
||||||
|
ok('turn ended', a.ended === true);
|
||||||
|
ok('no longer active after turn_end', s.activeUserSeen === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reconnect replay: re-feeding the SAME events must NOT double-render --
|
||||||
|
{
|
||||||
|
const events = [
|
||||||
|
{ kind: 'user', text: 'hi', id: 1 },
|
||||||
|
{ kind: 'text', text: 'hello', id: 2 },
|
||||||
|
{ kind: 'turn_end', id: 3 },
|
||||||
|
];
|
||||||
|
const s = createTranscript();
|
||||||
|
for (const e of events) reduceEvent(s, e);
|
||||||
|
// simulate an EventSource reconnect that re-replays everything from the top
|
||||||
|
for (const e of events) reduceEvent(s, e);
|
||||||
|
eq('still exactly two messages after replay', s.messages.length, 2);
|
||||||
|
eq('assistant prose not doubled', s.messages[1].parts[0].text, 'hello');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- a partial replay (Last-Event-ID resume) continues the same bubble ----
|
||||||
|
{
|
||||||
|
const s = createTranscript();
|
||||||
|
reduceEvent(s, { kind: 'user', text: 'go', id: 1 });
|
||||||
|
reduceEvent(s, { kind: 'text', text: 'part-A ', id: 2 });
|
||||||
|
// reconnect: server resumes after id 2; we must drop id<=2 if re-sent and
|
||||||
|
// keep appending to the open assistant bubble.
|
||||||
|
reduceEvent(s, { kind: 'text', text: 'part-A ', id: 2 }); // dup, dropped
|
||||||
|
reduceEvent(s, { kind: 'text', text: 'part-B', id: 3 }); // new, appended
|
||||||
|
reduceEvent(s, { kind: 'turn_end', id: 4 });
|
||||||
|
eq('resume appended to same bubble', s.messages[1].parts[0].text, 'part-A part-B');
|
||||||
|
eq('still two messages', s.messages.length, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- error / cancelled annotate the open bubble ---------------------------
|
||||||
|
{
|
||||||
|
const s = foldAll([
|
||||||
|
{ kind: 'user', text: 'x', id: 1 },
|
||||||
|
{ kind: 'text', text: 'working', id: 2 },
|
||||||
|
{ kind: 'error', error: 'ssh timeout', id: 3 },
|
||||||
|
{ kind: 'turn_end', id: 4 },
|
||||||
|
]);
|
||||||
|
eq('error note on assistant bubble', s.messages[1].error, 'ssh timeout');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const s = foldAll([
|
||||||
|
{ kind: 'user', text: 'x', id: 1 },
|
||||||
|
{ kind: 'cancelled', id: 2 },
|
||||||
|
{ kind: 'turn_end', id: 3 },
|
||||||
|
]);
|
||||||
|
ok('cancelled flag on assistant bubble', s.messages[1].cancelled === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- active state: a user event with no turn_end means a turn is running ---
|
||||||
|
{
|
||||||
|
const s = createTranscript();
|
||||||
|
reduceEvent(s, { kind: 'user', text: 'go', id: 1 });
|
||||||
|
reduceEvent(s, { kind: 'text', text: '...', id: 2 });
|
||||||
|
ok('active while no turn_end', s.activeUserSeen === true);
|
||||||
|
reduceEvent(s, { kind: 'turn_end', id: 3 });
|
||||||
|
ok('inactive after turn_end', s.activeUserSeen === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- assistant-only stream (session banner on a fresh attach) still renders -
|
||||||
|
{
|
||||||
|
const s = foldAll([
|
||||||
|
{ kind: 'session', session_id: 'S1', id: 1 },
|
||||||
|
{ kind: 'text', text: 'standing by', id: 2 },
|
||||||
|
{ kind: 'turn_end', id: 3 },
|
||||||
|
]);
|
||||||
|
eq('lone assistant message created', s.messages.length, 1);
|
||||||
|
eq('assistant prose present', s.messages[0].parts[0].text, 'standing by');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- two sequential turns produce two assistant bubbles -------------------
|
||||||
|
{
|
||||||
|
const s = foldAll([
|
||||||
|
{ kind: 'user', text: 'q1', id: 1 },
|
||||||
|
{ kind: 'text', text: 'a1', id: 2 },
|
||||||
|
{ kind: 'turn_end', id: 3 },
|
||||||
|
{ kind: 'user', text: 'q2', id: 4 },
|
||||||
|
{ kind: 'text', text: 'a2', id: 5 },
|
||||||
|
{ kind: 'turn_end', id: 6 },
|
||||||
|
]);
|
||||||
|
eq('four messages (u,a,u,a)', s.messages.map((m) => m.role), ['user', 'assistant', 'user', 'assistant']);
|
||||||
|
eq('second answer in its own bubble', s.messages[3].parts[0].text, 'a2');
|
||||||
|
ok('message keys are unique', new Set(s.messages.map((m) => m.key)).size === 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures) {
|
||||||
|
console.error(`\n${failures} assertion(s) FAILED`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\nall transcript assertions passed');
|
||||||
|
|
@ -1,174 +1,251 @@
|
||||||
"""Tests for the breakglass app: verb whitelist, SSE translation, auth, routes."""
|
"""Tests for the breakglass app: session manager (attach model), verb whitelist,
|
||||||
|
SSE translation, auth, routes."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("API_BEARER_TOKEN", "test-token")
|
os.environ.setdefault("API_BEARER_TOKEN", "test-token")
|
||||||
|
# Turns chdir into a per-session workspace; point it somewhere writable for tests
|
||||||
|
# (prod uses the /workspace emptyDir). Must be set before the app imports config.
|
||||||
|
os.environ.setdefault("BREAKGLASS_SESSIONS_DIR", "/tmp/bg-test-sessions")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.breakglass import agent_session, pve
|
from app.breakglass import agent_session, pve, session as sessionmod
|
||||||
from app.breakglass.server import app
|
from app.breakglass.server import app
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# PVE verb whitelist — the security boundary mirrored client-side.
|
# Fakes for the claude subprocess a turn spawns.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
class _FakeStdout:
|
||||||
|
def __init__(self, lines):
|
||||||
|
self._lines = [(l + "\n").encode() for l in lines]
|
||||||
|
self._i = 0
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if self._i >= len(self._lines):
|
||||||
|
raise StopAsyncIteration
|
||||||
|
line = self._lines[self._i]
|
||||||
|
self._i += 1
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStderr:
|
||||||
|
async def read(self):
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
def __init__(self, lines, rc=0):
|
||||||
|
self.stdout = _FakeStdout(lines)
|
||||||
|
self.stderr = _FakeStderr()
|
||||||
|
self.returncode = None
|
||||||
|
self._rc = rc
|
||||||
|
|
||||||
|
async def wait(self):
|
||||||
|
self.returncode = self._rc
|
||||||
|
return self._rc
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.returncode = -9
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_proc(monkeypatch, lines, rc=0):
|
||||||
|
async def _fake_spawn(*argv, **kwargs):
|
||||||
|
return _FakeProc(lines, rc)
|
||||||
|
monkeypatch.setattr(sessionmod.asyncio, "create_subprocess_exec", _fake_spawn)
|
||||||
|
|
||||||
|
|
||||||
|
_TURN_LINES = [
|
||||||
|
'{"type":"system","subtype":"init","session_id":"s"}',
|
||||||
|
'{"type":"system","subtype":"thinking_tokens","estimated_tokens":5}',
|
||||||
|
'{"type":"assistant","message":{"content":[{"type":"text","text":"checking disk"}]}}',
|
||||||
|
'{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"df -h"}}]}}',
|
||||||
|
'{"type":"result","is_error":false,"result":"done","duration_ms":12}',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Session: event log + broadcast + replay/Last-Event-ID.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_add_event_assigns_sequential_ids():
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
a = s.add_event({"kind": "user", "text": "hi"})
|
||||||
|
b = s.add_event({"kind": "text", "text": "yo"})
|
||||||
|
assert a["id"] == 0 and b["id"] == 1
|
||||||
|
assert [e["kind"] for e in s.events] == ["user", "text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_receives_broadcast():
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
q = s.subscribe()
|
||||||
|
s.add_event({"kind": "text", "text": "live"})
|
||||||
|
assert q.get_nowait()["text"] == "live"
|
||||||
|
s.unsubscribe(q)
|
||||||
|
s.add_event({"kind": "text", "text": "after"})
|
||||||
|
assert q.empty()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attach_replays_then_signals_caught_up():
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
s.add_event({"kind": "user", "text": "diagnose"})
|
||||||
|
s.add_event({"kind": "text", "text": "looking"})
|
||||||
|
frames = []
|
||||||
|
async for frame in sessionmod.attach_stream(s, last_event_id=None):
|
||||||
|
frames.append(frame)
|
||||||
|
if "caught-up" in frame:
|
||||||
|
break
|
||||||
|
body = "".join(frames)
|
||||||
|
assert "diagnose" in body and "looking" in body
|
||||||
|
assert "id: 0" in body and "id: 1" in body
|
||||||
|
assert "event: caught-up" in frames[-1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_attach_reconnect_replays_only_missed():
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
for i in range(3):
|
||||||
|
s.add_event({"kind": "text", "text": f"e{i}"}) # ids 0,1,2
|
||||||
|
frames = []
|
||||||
|
async for frame in sessionmod.attach_stream(s, last_event_id=0): # already saw id 0
|
||||||
|
frames.append(frame)
|
||||||
|
if "caught-up" in frame:
|
||||||
|
break
|
||||||
|
body = "".join(frames)
|
||||||
|
assert "e0" not in body # not re-sent
|
||||||
|
assert "e1" in body and "e2" in body
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Session: running a detached turn (mocked subprocess).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_turn_streams_events_into_log(monkeypatch):
|
||||||
|
_patch_proc(monkeypatch, _TURN_LINES)
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
assert s.start_turn("diagnose the devvm") is True
|
||||||
|
await s._turn # wait for the detached turn to finish
|
||||||
|
kinds = [e["kind"] for e in s.events]
|
||||||
|
assert kinds[0] == "user"
|
||||||
|
assert "session" in kinds and "text" in kinds and "tool" in kinds
|
||||||
|
assert "result" in kinds and kinds[-1] == "turn_end"
|
||||||
|
assert "thinking_tokens" not in kinds
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_one_turn_at_a_time(monkeypatch):
|
||||||
|
_patch_proc(monkeypatch, _TURN_LINES)
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
assert s.start_turn("first") is True
|
||||||
|
assert s.start_turn("second") is False # task not done yet
|
||||||
|
await s._turn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_after_first_turn(monkeypatch):
|
||||||
|
captured = {"argvs": []}
|
||||||
|
|
||||||
|
async def _fake_spawn(*argv, **kwargs):
|
||||||
|
captured["argvs"].append(argv)
|
||||||
|
return _FakeProc(_TURN_LINES)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessionmod.asyncio, "create_subprocess_exec", _fake_spawn)
|
||||||
|
s = sessionmod.Session("s1")
|
||||||
|
s.start_turn("first"); await s._turn
|
||||||
|
s.start_turn("second"); await s._turn
|
||||||
|
assert "--session-id" in captured["argvs"][0]
|
||||||
|
assert "--resume" in captured["argvs"][1]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# SessionManager.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_manager_create_get():
|
||||||
|
m = sessionmod.SessionManager()
|
||||||
|
s = m.create()
|
||||||
|
assert m.get(s.id) is s
|
||||||
|
assert m.get("nope") is None
|
||||||
|
assert m.get_or_create(s.id) is s
|
||||||
|
assert m.get_or_create(None).id != s.id
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# PVE verb whitelist (unchanged security boundary).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
def test_allowed_verbs_match_host_script():
|
def test_allowed_verbs_match_host_script():
|
||||||
assert pve.ALLOWED_VERBS == {
|
assert pve.ALLOWED_VERBS == {"status", "forensics", "reset", "stop", "start", "cycle"}
|
||||||
"status", "forensics", "reset", "stop", "start", "cycle"
|
|
||||||
}
|
|
||||||
assert pve.MUTATING_VERBS == {"reset", "stop", "start", "cycle"}
|
assert pve.MUTATING_VERBS == {"reset", "stop", "start", "cycle"}
|
||||||
assert pve.MUTATING_VERBS < pve.ALLOWED_VERBS
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad", [
|
@pytest.mark.parametrize("bad", ["rm -rf /", "status; reboot", "status 103", "", "STATUS"])
|
||||||
"rm -rf /", "status; rm -rf /", "status 103", "shutdown", "", "STATUS",
|
|
||||||
"cycle 999", "$(reboot)", "../start",
|
|
||||||
])
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_verb_rejects_non_whitelisted_without_ssh(bad, monkeypatch):
|
async def test_run_verb_rejects_non_whitelisted_without_ssh(bad, monkeypatch):
|
||||||
"""A bad verb must be rejected locally — never spawning a subprocess."""
|
|
||||||
called = False
|
|
||||||
|
|
||||||
async def _boom(*a, **k):
|
async def _boom(*a, **k):
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
raise AssertionError("ssh must not run for a rejected verb")
|
raise AssertionError("ssh must not run for a rejected verb")
|
||||||
|
|
||||||
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _boom)
|
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _boom)
|
||||||
result = await pve.run_verb(bad)
|
result = await pve.run_verb(bad)
|
||||||
assert result["rejected"] is True
|
assert result["rejected"] is True
|
||||||
assert result["exit_code"] is None
|
|
||||||
assert called is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_verb_allowed_invokes_ssh_with_bare_verb(monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
class _FakeProc:
|
|
||||||
returncode = 0
|
|
||||||
|
|
||||||
async def communicate(self):
|
|
||||||
return (b"status: running\n", b"")
|
|
||||||
|
|
||||||
async def _fake_exec(*argv, **kwargs):
|
|
||||||
captured["argv"] = argv
|
|
||||||
return _FakeProc()
|
|
||||||
|
|
||||||
monkeypatch.setattr(pve.asyncio, "create_subprocess_exec", _fake_exec)
|
|
||||||
result = await pve.run_verb("status")
|
|
||||||
assert result["rejected"] is False
|
|
||||||
assert result["exit_code"] == 0
|
|
||||||
assert "running" in result["stdout"]
|
|
||||||
# The verb is the LAST argv element, passed as a single token (no shell).
|
|
||||||
assert captured["argv"][-1] == "status"
|
|
||||||
assert captured["argv"][0] == "ssh"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# stream-json -> UI event translation (pure function).
|
# translate_event (pure).
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_translate_init_and_noise_and_blocks():
|
||||||
def test_translate_init_to_session():
|
assert agent_session.translate_event(
|
||||||
ev = agent_session.translate_event(
|
|
||||||
{"type": "system", "subtype": "init", "session_id": "abc"}
|
{"type": "system", "subtype": "init", "session_id": "abc"}
|
||||||
|
) == {"kind": "session", "session_id": "abc"}
|
||||||
|
assert agent_session.translate_event({"type": "system", "subtype": "hook_started"}) is None
|
||||||
|
assert agent_session.translate_event(
|
||||||
|
{"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}}
|
||||||
|
) == {"kind": "text", "text": "hi"}
|
||||||
|
tool = agent_session.translate_event(
|
||||||
|
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {"command": "df -h"}}]}}
|
||||||
)
|
)
|
||||||
assert ev == {"kind": "session", "session_id": "abc"}
|
assert tool["kind"] == "tool" and tool["input"]["command"] == "df -h"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("noise", [
|
|
||||||
{"type": "system", "subtype": "hook_started"},
|
|
||||||
{"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 5},
|
|
||||||
{"type": "user", "message": {"content": []}},
|
|
||||||
{"type": "unknown"},
|
|
||||||
])
|
|
||||||
def test_translate_drops_noise(noise):
|
|
||||||
assert agent_session.translate_event(noise) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_translate_assistant_text():
|
|
||||||
ev = agent_session.translate_event({
|
|
||||||
"type": "assistant",
|
|
||||||
"message": {"content": [{"type": "text", "text": "checking disk"}]},
|
|
||||||
})
|
|
||||||
assert ev == {"kind": "text", "text": "checking disk"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_translate_assistant_tool_use():
|
|
||||||
ev = agent_session.translate_event({
|
|
||||||
"type": "assistant",
|
|
||||||
"message": {"content": [
|
|
||||||
{"type": "tool_use", "name": "Bash", "input": {"command": "df -h"}}
|
|
||||||
]},
|
|
||||||
})
|
|
||||||
assert ev["kind"] == "tool"
|
|
||||||
assert ev["name"] == "Bash"
|
|
||||||
assert ev["input"]["command"] == "df -h"
|
|
||||||
|
|
||||||
|
|
||||||
def test_translate_result():
|
|
||||||
ev = agent_session.translate_event({
|
|
||||||
"type": "result", "is_error": False, "result": "done", "duration_ms": 1234,
|
|
||||||
})
|
|
||||||
assert ev == {"kind": "result", "is_error": False, "result": "done", "duration_ms": 1234}
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Routes + auth.
|
# Routes + auth.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
AUTH = {"Authorization": "Bearer test-token"}
|
AUTH = {"Authorization": "Bearer test-token"}
|
||||||
|
|
||||||
|
|
||||||
def test_health_no_auth():
|
def test_health_no_auth():
|
||||||
r = client.get("/health")
|
assert client.get("/health").json()["service"] == "claude-breakglass"
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["service"] == "claude-breakglass"
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_requires_auth():
|
def test_api_requires_auth():
|
||||||
assert client.post("/api/session").status_code == 401
|
assert client.post("/api/session").status_code == 401
|
||||||
assert client.get("/api/pve/verbs").status_code == 401
|
assert client.get("/api/pve/verbs").status_code == 401
|
||||||
|
assert client.post("/api/session/x/prompt", json={"prompt": "hi"}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_api_accepts_bearer():
|
def test_session_create_and_unknown_session_404():
|
||||||
r = client.post("/api/session", headers=AUTH)
|
r = client.post("/api/session", headers=AUTH)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200 and "session_id" in r.json()
|
||||||
assert "session_id" in r.json()
|
assert client.post("/api/session/nope/prompt", headers=AUTH, json={"prompt": "x"}).status_code == 404
|
||||||
|
assert client.post("/api/session/nope/cancel", headers=AUTH).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_api_accepts_authentik_header():
|
def test_prompt_starts_turn(monkeypatch):
|
||||||
r = client.post("/api/session", headers={"X-authentik-username": "me@viktorbarzin.me"})
|
monkeypatch.setattr(sessionmod.Session, "start_turn", lambda self, *a, **k: True)
|
||||||
assert r.status_code == 200
|
sid = client.post("/api/session", headers=AUTH).json()["session_id"]
|
||||||
|
r = client.post(f"/api/session/{sid}/prompt", headers=AUTH, json={"prompt": "diagnose"})
|
||||||
|
assert r.status_code == 200 and r.json()["status"] == "started"
|
||||||
|
|
||||||
|
|
||||||
def test_pve_verb_route_rejects_unknown():
|
def test_prompt_409_when_turn_active(monkeypatch):
|
||||||
r = client.post("/api/pve/destroy", headers=AUTH)
|
monkeypatch.setattr(sessionmod.Session, "start_turn", lambda self, *a, **k: False)
|
||||||
assert r.status_code == 400
|
sid = client.post("/api/session", headers=AUTH).json()["session_id"]
|
||||||
|
r = client.post(f"/api/session/{sid}/prompt", headers=AUTH, json={"prompt": "x"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_pve_verbs_listing():
|
def test_pve_verbs_listing_and_unknown_rejected():
|
||||||
r = client.get("/api/pve/verbs", headers=AUTH)
|
assert set(client.get("/api/pve/verbs", headers=AUTH).json()["verbs"]) == pve.ALLOWED_VERBS
|
||||||
assert r.status_code == 200
|
assert client.post("/api/pve/destroy", headers=AUTH).status_code == 400
|
||||||
body = r.json()
|
|
||||||
assert set(body["verbs"]) == pve.ALLOWED_VERBS
|
|
||||||
assert set(body["mutating"]) == pve.MUTATING_VERBS
|
|
||||||
|
|
||||||
|
|
||||||
def test_chat_streams_sse(monkeypatch):
|
|
||||||
async def _fake_turn(session_id, prompt, model=None):
|
|
||||||
yield {"kind": "session", "session_id": session_id}
|
|
||||||
yield {"kind": "text", "text": "hello"}
|
|
||||||
yield {"kind": "result", "is_error": False, "result": "ok"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(agent_session, "run_turn", _fake_turn)
|
|
||||||
r = client.post("/api/chat", headers=AUTH,
|
|
||||||
json={"session_id": "s1", "prompt": "diagnose"})
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert "text/event-stream" in r.headers["content-type"]
|
|
||||||
body = r.text
|
|
||||||
assert "hello" in body
|
|
||||||
assert '"kind": "done"' in body # terminal frame always emitted
|
|
||||||
|
|
|
||||||