Skip to content

Commit d07c758

Browse files
rsamfclaude
andcommitted
feat(ui): dedicated mobile experience — run list, DAG ⇄ Feed, heat-strip tracker
Implements the consolidated mobile redesign (design doc 2a): the <768px branch of App.tsx now renders a touch-first experience under ui/src/components/mobile/ instead of the shrunk desktop layout. Desktop is unchanged. Screens & sheets: - MobileRunList: search, group cards (one level of inline expansion, deeper subgroups drill behind a breadcrumb), run cards with live-pulse dot, short id · recency, and a line-metric sparkline. - MobileGroupPage: group docs (README first) + member runs. - MobileRunView: header with group crumb, title → run-info sheet (nb.md notes, config chips, copyable identifiers dialog), bell → alerts sheet (severity filter, tap jumps to the node sheet), gear → view-settings sheet (smoothing / point opacity / point size / image label opacity). - MobileDagCanvas: custom dagre canvas — drag pan, pinch + button zoom, node cards with exec count, sparkline preview and progress bar; tap opens the node sheet. No explicit setPointerCapture (it retargets the derived click and swallows node taps). - MobileFeed: pipeline stage rail + All/Metrics/Media/Logs filter; metric cards expand to full charts (SingleRunChart reuse), media strips with step badges + labeled lightbox, per-node log tails with level chips. Timeline playhead narrows media/logs like desktop. - MobileTracker: persistent bottom bar with DAG ⇄ Feed toggle and an event-density heat strip; expands to a sheet with per-stream dot rows, modality chips, step/time scrubber and prev/next. - MobileNodeSheet: wraps LoggableTabContainer for full tab parity. Supporting changes: - daemon: GET /runs/{id}/alerts lists a run's fired alerts (accessor existed; only the /wait endpoint was exposed). Test added. - store: per-run alerts slice (REST hydration + WS alert events), and the run-list auto-select now fires once per session so navigating back to the list isn't undone by the 5 s poll. - useStreams: select per-field refs instead of the run object — REST hydration mutates runs in place, so the run reference alone never triggered a re-render (stale tracker on cold-loaded runs). - NodeMetrics: export SingleRunChart for the mobile feed. - Removed MobileNav (replaced by MobileRunView's header). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 97a2ff8 commit d07c758

25 files changed

Lines changed: 2393 additions & 105 deletions

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ Smoothed values are rendered, not persisted: raw entries in the store remain unt
381381

382382
### Alerts (code-fired + condition rules)
383383

384-
`nb.alert(title, text, level)` fires a webhook (if configured) and emits an `alert` wire event stamped `triggered_by: "code"`; the daemon appends it to `run.alerts`. **Alert rules** are set without code changes (`nebo alerts set --condition "train/loss > 5"`, MCP `nebo_set_alert`): they live in `DaemonState.alert_rules` (in-memory) and are evaluated in `_process_event`'s metric branch (`_evaluate_alert_rules`) against numeric metric values only. A rule fires at most once per run; the fired alert lands in `run.alerts` with `triggered_by: "cli"` plus a `condition` display string (and is cache-persisted via `_fire_rule_alert`), so `/runs/{id}/alerts/wait` (`nebo runs wait`, `nebo_wait_for_alert`) wakes on it with no extra wiring. Rule CRUD: `GET/POST /alerts`, `GET/DELETE /alerts/{id}`; condition strings (`<metric> <op> <number>`) are parsed by `nebo/client.py:parse_condition`. The metric name `last_event` is **reserved for heartbeat rules** (`"last_event > 60"` = fire once the run has been idle 60 s — nebo's run-completion signal): evaluated by an always-on ~1 s lifespan task (`evaluate_heartbeat_rules`, `HEARTBEAT_TICK_S`) that notifies `_event_notify` itself (a quiet run has no ingest to wake waiters); ops `>`/`>=` only, no `loggable_id`; run-scoped rules fall back to the cache for RAM-evicted runs and fire immediately for already-idle runs, global rules skip runs whose last activity predates the rule. File-mode `alert` entries are intentionally **not** in `ENTRY_TYPES` (byte 255); the watcher's payload-type recovery ingests them — don't make the type byte authoritative.
384+
`nb.alert(title, text, level)` fires a webhook (if configured) and emits an `alert` wire event stamped `triggered_by: "code"`; the daemon appends it to `run.alerts`. **Alert rules** are set without code changes (`nebo alerts set --condition "train/loss > 5"`, MCP `nebo_set_alert`): they live in `DaemonState.alert_rules` (in-memory) and are evaluated in `_process_event`'s metric branch (`_evaluate_alert_rules`) against numeric metric values only. A rule fires at most once per run; the fired alert lands in `run.alerts` with `triggered_by: "cli"` plus a `condition` display string (and is cache-persisted via `_fire_rule_alert`), so `/runs/{id}/alerts/wait` (`nebo runs wait`, `nebo_wait_for_alert`) wakes on it with no extra wiring. Rule CRUD: `GET/POST /alerts`, `GET/DELETE /alerts/{id}`; condition strings (`<metric> <op> <number>`) are parsed by `nebo/client.py:parse_condition`. `GET /runs/{id}/alerts` lists one run's fired alerts (code- and rule-fired) — the mobile alerts sheet hydrates from it, and live `alert` WS events append to the store's per-run `alerts` slice. The metric name `last_event` is **reserved for heartbeat rules** (`"last_event > 60"` = fire once the run has been idle 60 s — nebo's run-completion signal): evaluated by an always-on ~1 s lifespan task (`evaluate_heartbeat_rules`, `HEARTBEAT_TICK_S`) that notifies `_event_notify` itself (a quiet run has no ingest to wake waiters); ops `>`/`>=` only, no `loggable_id`; run-scoped rules fall back to the cache for RAM-evicted runs and fire immediately for already-idle runs, global rules skip runs whose last activity predates the rule. File-mode `alert` entries are intentionally **not** in `ENTRY_TYPES` (byte 255); the watcher's payload-type recovery ingests them — don't make the type byte authoritative.
385385

386386
### Global state singleton
387387

@@ -405,6 +405,8 @@ Smoothed values are rendered, not persisted: raw entries in the store remain unt
405405

406406
React 19 + Vite 7 + TypeScript + Tailwind v4 + shadcn-style components. State via `zustand` (`src/store/index.ts`). WebSocket handled in `src/hooks/useWebSocket.ts`, connecting to the daemon's `/stream` endpoint. Graph rendering uses `@xyflow/react` with `@dagrejs/dagre` layout (`src/components/graph/DagGraph.tsx`). Metrics charts use **Chart.js 4** (registered in `src/components/charts/registerChartJs.ts`) with `chartjs-plugin-zoom` for pan/zoom; the shared lifecycle hook is `src/components/charts/useChartJs.ts`. The bottom panel is the **Tracker** (`src/components/timeline/`); see "Tracker" under the Metrics model section. The default view is "Flat" (store key `'flat'`, wire value `nb.ui(view="flat")`); the DAG view is opt-in. The `@/` import alias maps to `ui/src/`. shadcn registry is configured via `.mcp.json` (the `shadcn` MCP server).
407407

408+
**Mobile experience** (`src/components/mobile/`, <768px via `useIsDesktop`): a dedicated touch UI rendered by `MobileApp` from App.tsx's mobile branch — the desktop layout is untouched. Screens: `MobileRunList` (group cards expand one level inline; deeper subgroups become drill cards behind a breadcrumb) → `MobileRunView` (header: group crumb / title → `MobileRunInfoSheet` with notes + config + copyable identifiers; bell → `MobileAlertsSheet`, severity-filterable, tap jumps to the node sheet; gear → `MobileSettingsSheet` sliders over the shared `Settings` keys). Body toggles DAG ⇄ Feed via the shared `viewMode` store key ('graph'/'flat', so `nb.ui(view=)` still applies): `MobileDagCanvas` is a custom dagre + pan/pinch canvas (not ReactFlow; no explicit `setPointerCapture` — it would retarget the derived click and swallow node taps), `MobileFeed` is a stage-rail + type-filter card feed whose expanded charts reuse `SingleRunChart` (exported from `NodeMetrics`). `MobileTracker` renders the persistent heat-strip bar + scrub sheet over `useStreams`. Node taps and alerts open `MobileNodeSheet`, which wraps the desktop `LoggableTabContainer` for full tab parity. Store subscription rule for mobile components: subscribe to the `s.runs` **map** (or leaf field refs), never `s.runs.get(id)` — REST hydration mutates the run object in place, so the run reference alone never fires a re-render (same reason `useStreams` selects per-field refs).
409+
408410
### Tests
409411

410412
Plain `pytest` + `pytest-asyncio`. Tests are self-contained and exercise the public surface (`test_decorators.py`, `test_client.py`, `test_daemon.py`, `test_mcp_tools.py`, `test_fileformat.py`, …). There is no separate lint/type-check step in CI — only the pytest matrix in `.github/workflows/ci.yml`.

nebo/server/daemon.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,6 +1881,16 @@ async def get_run_audio(run_id: str):
18811881
return JSONResponse(status_code=404, content={"error": f"Run '{run_id}' not found"})
18821882
return {"audio": audio}
18831883

1884+
@app.get("/runs/{run_id}/alerts")
1885+
async def get_run_alerts(run_id: str):
1886+
"""Fired alerts for one run — both code-fired (`nb.alert`) and
1887+
rule-fired (`triggered_by: "cli"`) entries, in firing order."""
1888+
await state.ensure_deep(run_id)
1889+
alerts = state.run_alerts(run_id)
1890+
if alerts is None:
1891+
return JSONResponse(status_code=404, content={"error": f"Run '{run_id}' not found"})
1892+
return {"alerts": alerts}
1893+
18841894
@app.get("/runs/{run_id}/media/{media_id}")
18851895
async def get_media(run_id: str, media_id: str, request: Request):
18861896
await state.ensure_deep(run_id)

tests/test_daemon.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,16 @@ def test_alert_event_is_appended_to_run():
805805
assert run.alerts[0]["level_name"] == "WARN"
806806
assert run.alerts[0]["loggable_id"] == "node_a"
807807

808+
# The per-run listing endpoint returns the same fired alerts.
809+
resp = client.get("/runs/r_alert_1/alerts")
810+
assert resp.status_code == 200
811+
alerts = resp.json()["alerts"]
812+
assert len(alerts) == 1
813+
assert alerts[0]["title"] == "Loss went up"
814+
815+
resp = client.get("/runs/no_such_run/alerts")
816+
assert resp.status_code == 404
817+
808818

809819
def test_alerts_wait_returns_alert():
810820
"""Wait endpoint should unblock and return alert when one arrives at or above min_level."""

ui/src/App.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import { useIsDesktop } from '@/hooks/useMediaQuery'
44
import { useStore } from '@/store'
55
import { ErrorBoundary } from '@/components/ErrorBoundary'
66
import { Sidebar } from '@/components/layout/Sidebar'
7-
import { MobileNav } from '@/components/layout/MobileNav'
7+
import { MobileApp } from '@/components/mobile/MobileApp'
88
import { RunDetailView } from '@/components/layout/RunDetailView'
99
import { GroupPage } from '@/components/layout/GroupPage'
1010
import { RightPanel } from '@/components/layout/RightPanel'
11-
import { RunList } from '@/components/runs/RunList'
1211
import { Notice } from '@/components/shared/Notice'
1312
import { TooltipProvider } from '@/components/ui/tooltip'
1413
import { useEmbeddedView } from '@/hooks/useEmbeddedView'
@@ -94,18 +93,11 @@ export default function App() {
9493
)}
9594
</div>
9695
) : (
97-
/* Mobile: full-screen switching, tracker pinned at the bottom of detail */
98-
<>
99-
<MobileNav />
100-
<div className="flex-1 overflow-hidden flex flex-col">
101-
<ErrorBoundary label="MainContent">
102-
{selectedGroup ? <GroupPage path={selectedGroup} /> : selectedRunId ? <RunDetailView /> : <RunList />}
103-
</ErrorBoundary>
104-
</div>
105-
{selectedRunId && (
106-
<ErrorBoundary label="Tracker"><Tracker runId={selectedRunId} /></ErrorBoundary>
107-
)}
108-
</>
96+
/* Mobile: the dedicated touch experience (run list → run view
97+
with DAG ⇄ Feed toggle, heat-strip tracker, bottom sheets). */
98+
<div className="flex-1 overflow-hidden flex flex-col">
99+
<MobileApp />
100+
</div>
109101
)}
110102
</div>
111103
<ChartTooltip />

ui/src/components/layout/MobileNav.tsx

Lines changed: 0 additions & 77 deletions
This file was deleted.

ui/src/components/layout/RunDetailView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export function RunDetailView() {
7272

7373
return (
7474
<div className="flex flex-col h-full">
75-
{/* Run header (desktop only - mobile uses MobileNav) */}
75+
{/* Run header (desktop only - mobile has its own MobileRunView) */}
7676
{isDesktop && (
7777
<div className="border-b border-border shrink-0">
7878
<div className="flex items-center gap-3 px-4 py-2">
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { useEffect, useMemo, useState } from 'react'
2+
import { useStore } from '@/store'
3+
import { api } from '@/lib/api'
4+
import { MobileSheet } from './MobileSheet'
5+
import {
6+
ALERT_SEVERITY_COLOR, alertSeverity, loggableDisplayName, timeAgo,
7+
} from './util'
8+
import { ChevronRight } from 'lucide-react'
9+
import { cn } from '@/lib/utils'
10+
11+
const SEVERITY_FILTERS = ['All', 'Info', 'Warn', 'Error'] as const
12+
type SeverityFilter = (typeof SEVERITY_FILTERS)[number]
13+
14+
// Severity-filterable list of this run's fired alerts. Hydrated from
15+
// GET /runs/{id}/alerts on open; live alert events append via the store.
16+
export function MobileAlertsSheet({
17+
runId,
18+
onClose,
19+
onOpenNode,
20+
}: {
21+
runId: string
22+
onClose: () => void
23+
onOpenNode: (loggableId: string) => void
24+
}) {
25+
const run = useStore(s => s.runs).get(runId)
26+
const setRunAlerts = useStore(s => s.setRunAlerts)
27+
const [filter, setFilter] = useState<SeverityFilter>('All')
28+
const [loaded, setLoaded] = useState(false)
29+
30+
useEffect(() => {
31+
let cancelled = false
32+
api
33+
.getRunAlerts(runId)
34+
.then(d => {
35+
if (cancelled) return
36+
setRunAlerts(runId, d.alerts)
37+
setLoaded(true)
38+
})
39+
.catch(() => setLoaded(true))
40+
return () => {
41+
cancelled = true
42+
}
43+
}, [runId, setRunAlerts])
44+
45+
const alerts = useMemo(() => {
46+
const all = run?.alerts ?? []
47+
const filtered =
48+
filter === 'All' ? all : all.filter(a => alertSeverity(a.level) === filter.toLowerCase())
49+
// Newest first.
50+
return filtered.slice().sort((a, b) => b.timestamp - a.timestamp)
51+
}, [run?.alerts, filter])
52+
53+
return (
54+
<MobileSheet open onClose={onClose} heightClass="h-[66vh]">
55+
<div className="flex shrink-0 items-center gap-2 px-4 pb-2.5">
56+
<span className="flex-1 text-base font-semibold">Alerts</span>
57+
<span className="text-xs text-muted-foreground">this run</span>
58+
</div>
59+
<div className="flex shrink-0 gap-1.5 px-4 pb-2.5">
60+
{SEVERITY_FILTERS.map(f => (
61+
<button
62+
key={f}
63+
onClick={() => setFilter(f)}
64+
className={cn(
65+
'rounded-full border px-2.5 py-0.5 text-[10.5px] font-medium',
66+
filter === f
67+
? 'border-primary/40 bg-primary/15 text-foreground'
68+
: 'border-border text-muted-foreground',
69+
)}
70+
>
71+
{f}
72+
</button>
73+
))}
74+
</div>
75+
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 pb-10">
76+
{alerts.length === 0 && (
77+
<div className="py-8 text-center text-xs text-muted-foreground">
78+
{loaded ? 'No alerts fired on this run' : 'Loading…'}
79+
</div>
80+
)}
81+
{alerts.map((a, i) => {
82+
const severity = alertSeverity(a.level)
83+
const dot = ALERT_SEVERITY_COLOR[severity]
84+
const metaParts = [
85+
a.level_name || severity.toUpperCase(),
86+
timeAgo(a.timestamp),
87+
]
88+
if (a.condition) metaParts.push(a.condition)
89+
const target = a.loggable_id
90+
return (
91+
<button
92+
key={`${a.timestamp}-${i}`}
93+
onClick={() => {
94+
if (target) {
95+
onClose()
96+
onOpenNode(target)
97+
}
98+
}}
99+
className={cn(
100+
'flex gap-3 rounded-xl border bg-card px-3.5 py-3 text-left',
101+
severity === 'error' ? 'border-red-500/40' : 'border-border',
102+
!target && 'cursor-default',
103+
)}
104+
>
105+
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full" style={{ background: dot }} />
106+
<div className="min-w-0 flex-1">
107+
<div className="text-[13px] font-medium leading-snug">{a.title}</div>
108+
{a.text && (
109+
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{a.text}</div>
110+
)}
111+
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
112+
{metaParts.join(' · ')}
113+
{target ? ` · ${loggableDisplayName(run, target)}` : ''}
114+
</div>
115+
</div>
116+
{target && <ChevronRight className="mt-1 h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
117+
</button>
118+
)
119+
})}
120+
</div>
121+
</MobileSheet>
122+
)
123+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useStore } from '@/store'
2+
import { ErrorBoundary } from '@/components/ErrorBoundary'
3+
import { MobileRunList } from './MobileRunList'
4+
import { MobileGroupPage } from './MobileGroupPage'
5+
import { MobileRunView } from './MobileRunView'
6+
7+
// Mobile shell: full-screen switching between the run list, a group
8+
// page and the run view. Comparison groups are a desktop feature — a
9+
// selected `cmp:` id falls back to the list.
10+
export function MobileApp() {
11+
const selectedRunId = useStore(s => s.selectedRunId)
12+
const selectedGroup = useStore(s => s.selectedGroup)
13+
14+
if (selectedGroup) {
15+
return (
16+
<ErrorBoundary label="MobileGroupPage">
17+
<MobileGroupPage path={selectedGroup} />
18+
</ErrorBoundary>
19+
)
20+
}
21+
if (selectedRunId && !selectedRunId.startsWith('cmp:')) {
22+
return (
23+
<ErrorBoundary label="MobileRunView">
24+
<MobileRunView runId={selectedRunId} />
25+
</ErrorBoundary>
26+
)
27+
}
28+
return (
29+
<ErrorBoundary label="MobileRunList">
30+
<MobileRunList />
31+
</ErrorBoundary>
32+
)
33+
}

0 commit comments

Comments
 (0)