feat(robomp): replace status tables with lifecycle cards

This commit is contained in:
lycaon
2026-06-17 21:23:23 -06:00
parent 159b8a1c63
commit 107302adcf
33 changed files with 4528 additions and 620 deletions
+4 -13
View File
@@ -124,7 +124,7 @@ export function Browse(): JSX.Element {
</div>
<Show when={errorMessage()}>
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.05)]">
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft tint-err">
{errorMessage()}
</div>
</Show>
@@ -132,7 +132,7 @@ export function Browse(): JSX.Element {
<Show when={data().errors.length}>
<For each={data().errors}>
{(err) => (
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.04)]">
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft tint-err">
<code>{err.repo}</code> <span class="text-ink-300">{err.error}</span>
</div>
)}
@@ -155,7 +155,7 @@ export function Browse(): JSX.Element {
<For each={filtered()}>
{(issue) => (
<div
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-white/[0.025] transition-colors"
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-surface-2 transition-colors"
style={{
"grid-template-columns": "1fr auto",
opacity: issue.processed ? 0.55 : 1,
@@ -178,16 +178,7 @@ export function Browse(): JSX.Element {
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
<Pill state={issue.state}>{issue.state}</Pill>
<Show when={issue.processed}>
<span
class="pill"
style={{
color: "#9ec9ff",
"border-color": "rgba(100,175,255,0.32)",
"background-color": "rgba(10,132,255,0.10)",
}}
>
processed
</span>
<Pill state="processed">processed</Pill>
</Show>
<span>by {issue.author || "—"}</span>
<span>updated {fmtAge(issue.updated_at)}</span>
@@ -14,7 +14,7 @@ export interface GlassCardProps {
// the inset content padding so tables/log lists can reach the edge.
export function GlassCard(props: GlassCardProps): JSX.Element {
const cls = (): string => {
const base = "glass glass-rise rounded-[22px] overflow-hidden";
const base = "glass glass-rise rounded-lg overflow-hidden";
return props.class ? `${base} ${props.class}` : base;
};
return (
-106
View File
@@ -1,106 +0,0 @@
import { type JSX, Show } from "solid-js";
import { CONFIG } from "../config";
import { fmtDuration } from "../format";
import { isFetching, lastTickAt, lastTickError, statusResource } from "../state";
import type { RuntimeInfo } from "../types";
function relativeAgo(ms: number): string {
const seconds = Math.max(0, (Date.now() - ms) / 1000);
if (seconds < 5) return "just now";
return `${fmtDuration(seconds)} ago`;
}
export function Header(): JSX.Element {
const runtime = (): RuntimeInfo | undefined => statusResource()?.runtime;
return (
<header class="px-6 lg:px-10 pt-8 pb-6 flex flex-col gap-5">
<div class="flex flex-wrap items-end gap-x-6 gap-y-3 justify-between">
<div class="flex items-baseline gap-3">
<h1 class="text-[26px] font-semibold tracking-tight leading-none">
<span class="text-ink-50">robomp</span>
<span class="inline-block ml-2 size-[7px] rounded-full align-middle bg-accent shadow-[0_0_10px_rgba(10,132,255,0.7)]" />
</h1>
<span class="eyebrow">triage · fix · ship</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-ink-300">
<Show
when={lastTickError()}
fallback={
<span class="flex items-center gap-2">
<span
class={`inline-block size-[7px] rounded-full ${
isFetching() ? "bg-warn" : "bg-ok"
}`}
style={{
"box-shadow": isFetching()
? "0 0 8px rgba(255,214,10,0.6)"
: "0 0 8px rgba(48,209,88,0.6)",
}}
/>
{isFetching() ? "syncing…" : `synced ${relativeAgo(lastTickAt())}`}
</span>
}
>
<span class="flex items-center gap-2 text-err">
<span class="inline-block size-[7px] rounded-full bg-err" />
{lastTickError()}
</span>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-7 gap-y-2 text-[12px] text-ink-300 tabular">
<Meta label="bot" value={runtime()?.bot_login} mono />
<Meta
label="model"
value={runtime()?.model}
mono
title={runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined}
/>
<Meta
label="concurrency"
value={
runtime()?.max_concurrency != null ? String(runtime()?.max_concurrency) : undefined
}
/>
<Meta
label="uptime"
value={
runtime()?.uptime_seconds != null ? fmtDuration(runtime()?.uptime_seconds) : undefined
}
/>
<Meta
label="allowlist"
value={
runtime()?.repo_allowlist?.length ? runtime()!.repo_allowlist.join(", ") : "(none)"
}
mono
/>
<Show when={!CONFIG.replayEnabled}>
<span class="pill skipped">read-only · trigger disabled</span>
</Show>
</div>
</header>
);
}
interface MetaProps {
label: string;
value?: string;
mono?: boolean;
title?: string;
}
function Meta(props: MetaProps): JSX.Element {
return (
<span class="inline-flex items-baseline gap-1.5" title={props.title}>
<span class="text-ink-500 uppercase tracking-[0.14em] text-[10px]">{props.label}</span>
<span class={props.mono ? "font-mono text-[12px] text-ink-100" : "text-ink-100"}>
{props.value ?? "…"}
</span>
</span>
);
}
@@ -0,0 +1,162 @@
import { type JSX, Show } from "solid-js";
import { CONFIG } from "../config";
import { fmtAge, fmtDuration, shortDelivery, shortText } from "../format";
import { runCancel, runTrigger } from "../state";
import type { WorkItem } from "../work-items";
import { IssueLink, PrLink } from "./IssueLink";
import { LifecycleStepper } from "./LifecycleStepper";
import { Pill } from "./Pill";
export interface IssueCardProps {
item: WorkItem;
}
// Verbatim cancel-confirm copy retained from the previous running table so the
// operator-facing wording does not drift.
const CANCEL_CONFIRM =
"Kill this running task? The omp subprocess dies and the row lands in 'failed'.";
// One issue = one card. Full shape when the item has an issue ref (the
// common case); compact shape for orphan deliveries with no bound issue.
// State tint is carried by `data-bucket` + CSS — no side-stripe, no shadow.
export function IssueCard(props: IssueCardProps): JSX.Element {
const item = (): WorkItem => props.item;
const ref = () => item().ref;
const full = (): boolean => ref() !== null;
return (
<article class="rmp-card" data-bucket={item().bucket}>
<Show when={full()} fallback={<CompactHead item={item()} />}>
<div class="rmp-card-head">
<div class="rmp-card-id">
<Pill>{item().classification ?? "issue"}</Pill>
<IssueLink repo={ref()!.repo} number={String(ref()!.number)} />
</div>
<ActivityPill item={item()} />
</div>
<LifecycleStepper
state={item().issueState}
classification={item().classification}
failed={item().bucket === "failed"}
live={item().bucket === "running"}
/>
</Show>
<MetaRow item={item()} />
<Show when={item().bucket === "failed" && item().error}>
<div class="rmp-card-error err-cell" title={item().error ?? ""}>
{shortText(item().error, 200)}
</div>
</Show>
<Show
when={
CONFIG.replayEnabled &&
(item().bucket === "failed" ||
(item().bucket === "running" && !item().inflightOnly))
}
>
<div class="rmp-card-actions">
<Show when={item().bucket === "failed" && item().deliveryId}>
<button
class="tiny"
onClick={() => void runTrigger({ mode: "retry", delivery_id: item().deliveryId })}
>
retry
</button>
</Show>
<Show when={item().bucket === "running" && item().deliveryId}>
<button class="tiny danger" onClick={() => void cancel(item().deliveryId)}>
cancel
</button>
</Show>
</div>
</Show>
</article>
);
}
function CompactHead(props: { item: WorkItem }): JSX.Element {
return (
<div class="rmp-card-head">
<div class="rmp-card-id">
<code>{shortDelivery(props.item.deliveryId)}</code>
</div>
<ActivityPill item={props.item} />
</div>
);
}
function ActivityPill(props: { item: WorkItem }): JSX.Element {
const state = (): string | undefined =>
props.item.latestEvent?.state ?? (props.item.bucket === "running" ? "running" : undefined);
const label = (): string =>
props.item.latestEvent?.state ??
(props.item.bucket === "running" ? (props.item.inflightOnly ? "inflight" : "running") : "—");
return (
<Pill state={state()} dot={props.item.bucket === "running"}>
{label()}
</Pill>
);
}
function MetaRow(props: { item: WorkItem }): JSX.Element {
const item = (): WorkItem => props.item;
const live = () => item().live;
// Only the applicable fragments render; the CSS layer draws a · separator
// before every non-first child, so we never emit leading/trailing dots.
return (
<div class="rmp-card-meta">
<Show when={live()}>
<Show when={live()!.model}>
<code title={live()!.model ?? ""}>{live()!.model}</code>
</Show>
<Show when={live()!.last_tool}>
<span>
last action <code>{live()!.last_tool}</code>{" "}
<span class="rmp-card-meta-dim">{fmtAge(live()!.last_tool_ts)}</span>
</span>
</Show>
<span>
elapsed <span class="tabular">{liveElapsed(live()!.started_at)}</span>
</span>
<span>attempt #{live()!.attempts}</span>
</Show>
<Show when={!live() && item().inflightOnly}>
<span>held by pool</span>
</Show>
<Show when={item().branch}>
<code>{item().branch}</code>
</Show>
<Show when={item().ref && item().prNumber != null}>
<PrLink repo={item().ref!.repo} number={item().prNumber} />
</Show>
<Show
when={
item().bucket !== "running" &&
item().bucket !== "failed" &&
item().latestEvent
}
>
<span>
{item().latestEvent!.event_type} · attempt #{item().latestEvent!.attempts} ·{" "}
{fmtAge(item().latestEvent!.received_at)}
</span>
</Show>
</div>
);
}
async function cancel(deliveryId: string): Promise<void> {
if (!window.confirm(CANCEL_CONFIRM)) return;
await runCancel(deliveryId);
}
// Elapsed for a live run. Guards against invalid/unparseable timestamps so we
// never render a NaN-duration — falls back to "—".
function liveElapsed(startedAt: string | null): string {
if (!startedAt) return "—";
const t = Date.parse(startedAt);
if (Number.isNaN(t)) return "—";
return fmtDuration((Date.now() - t) / 1000);
}
-117
View File
@@ -1,117 +0,0 @@
import { For, type JSX, Show } from "solid-js";
import { CONFIG } from "../config";
import { fmtAge, shortText } from "../format";
import { statusResource } from "../state";
import { type IssueRow, type LatestEvent, TERMINAL_ISSUE_STATES } from "../types";
import { GlassCard } from "./GlassCard";
import { IssueLink, PrLink } from "./IssueLink";
import { Pill } from "./Pill";
export interface IssuesProps {
onRetry: (deliveryId: string) => void;
}
export function Issues(props: IssuesProps): JSX.Element {
const active = (): IssueRow[] => {
const s = statusResource();
if (!s) return [];
return s.issues.filter((i) => !TERMINAL_ISSUE_STATES.has(i.state));
};
return (
<GlassCard heading="active issues" accessory={<span class="tabular">{active().length}</span>}>
<Show when={active().length} fallback={<div class="empty">no active issues</div>}>
<div class="overflow-x-auto scrollable">
<table class="t">
<thead>
<tr>
<th>issue</th>
<th>state</th>
<th>last event</th>
<th>class</th>
<th>branch</th>
<th>pr</th>
<th>error</th>
<th />
</tr>
</thead>
<tbody>
<For each={active()}>
{(issue) => <IssueRowView issue={issue} onRetry={props.onRetry} />}
</For>
</tbody>
</table>
</div>
</Show>
</GlassCard>
);
}
interface RowProps {
issue: IssueRow;
onRetry: (deliveryId: string) => void;
}
function IssueRowView(props: RowProps): JSX.Element {
const ev = (): LatestEvent | null => props.issue.latest_event;
return (
<tr>
<td>
<IssueLink repo={props.issue.repo} number={props.issue.number} />
</td>
<td>
<Pill>{props.issue.state}</Pill>
</td>
<td>
<Show when={ev()} fallback={<span class="text-ink-400">—</span>}>
{(latest) => (
<>
<Pill state={latest().state}>{latest().state}</Pill>
<span class="meta-line">
{latest().event_type} · attempt #{latest().attempts} ·{" "}
{fmtAge(latest().received_at)}
</span>
</>
)}
</Show>
</td>
<td class="text-ink-300">{props.issue.classification ?? ""}</td>
<td>
{props.issue.branch ? (
<code>{props.issue.branch}</code>
) : (
<span class="text-ink-400">—</span>
)}
</td>
<td>
<PrLink repo={props.issue.repo} number={props.issue.pr_number} />
</td>
<td class="err-cell">
<Show
when={ev()?.state === "failed" && ev()?.last_error}
fallback={<span class="text-ink-400">—</span>}
>
<span title={ev()?.last_error ?? ""}>{shortText(ev()?.last_error)}</span>
</Show>
</td>
<td>
<Show
when={CONFIG.replayEnabled && ev()?.state === "failed"}
fallback={<span class="text-ink-400">—</span>}
>
<button
class="tiny"
onClick={() => {
const latest = ev();
if (latest) props.onRetry(latest.delivery_id);
}}
>
retry
</button>
</Show>
</td>
</tr>
);
}
@@ -0,0 +1,62 @@
import { For, type JSX, Show } from "solid-js";
import {
CODE_STAGES,
SIMPLE_CLASSIFICATIONS,
SIMPLE_STAGES,
stageOrdinal,
} from "../work-items";
export interface LifecycleStepperProps {
state: string | null;
classification: string | null;
failed: boolean;
live: boolean;
}
type NodeState = "complete" | "current" | "failed" | "pending";
// Horizontal lifecycle track. Picks the 2-node "simple" path for non-code
// classifications (questions, enhancements, proposals) and the 5-node code
// path otherwise. State is carried entirely by data attributes so the CSS
// layer owns colour + the running pulse; this component stays dumb.
//
// Layout: equal-width cells (flex:1) so dot centres are evenly spaced; the
// connector for cell i is absolutely positioned from this dot's centre to the
// next dot's centre (one cell width), and the dot paints over it.
export function LifecycleStepper(props: LifecycleStepperProps): JSX.Element {
const stages = (): readonly string[] =>
props.classification != null && SIMPLE_CLASSIFICATIONS.has(props.classification)
? SIMPLE_STAGES
: CODE_STAGES;
const ord = (): number => stageOrdinal(props.state);
const nodeState = (i: number): NodeState => {
const o = ord();
if (i < o) return "complete";
if (i === o) return props.failed ? "failed" : "current";
return "pending";
};
return (
<div class="rmp-step">
<For each={stages()}>
{(stage, i) => (
<div class="rmp-step-cell">
<Show when={i() < stages().length - 1}>
<div class="rmp-step-seg" data-state={i() < ord() ? "complete" : "pending"} />
</Show>
<div
class="rmp-step-node"
data-state={nodeState(i())}
data-live={nodeState(i()) === "current" && props.live}
/>
<span class="rmp-step-label" data-reached={i() <= ord()}>
{stage}
</span>
</div>
)}
</For>
</div>
);
}
@@ -0,0 +1,57 @@
import { For, type JSX, Show } from "solid-js";
import { statusResource } from "../state";
import { buildWorkItems, type WorkItem } from "../work-items";
import { IssueCard } from "./IssueCard";
// The unified Operations surface: every active work item as one lifecycle
// card. Subsumes the old running, failed, and active issue tables. Failed
// items sort first; running items show live agent activity. No GlassCard
// wrapper — the cards ARE the card layer (no nested cards).
export function Pipeline(): JSX.Element {
const items = (): WorkItem[] => {
const s = statusResource();
return s ? buildWorkItems(s) : [];
};
return (
<Show when={statusResource() !== undefined} fallback={<PipelineSkeleton />}>
<Show when={items().length > 0} fallback={<PipelineEmpty />}>
<div class="rmp-pipeline-head">
<span class="rmp-pipeline-head-label">pipeline</span>
<span class="rmp-pipeline-head-count tabular">{items().length}</span>
</div>
<div class="rmp-card-grid">
<For each={items()}>{(item) => <IssueCard item={item} />}</For>
</div>
</Show>
</Show>
);
}
// First-load placeholder — skeleton, not spinner. Two cards so the grid shape
// is legible before data arrives.
function PipelineSkeleton(): JSX.Element {
return (
<div class="rmp-card-grid">
<div class="rmp-card-skeleton" />
<div class="rmp-card-skeleton" />
</div>
);
}
// Teaching empty state: names what the surface does and what it's waiting on,
// plus the watched repos so an idle console still explains itself.
function PipelineEmpty(): JSX.Element {
const allowlist = (): string[] => statusResource()?.runtime.repo_allowlist ?? [];
return (
<div class="rmp-pipeline-empty">
<div class="rmp-pipeline-empty-title">
No active work — robomp is idle, waiting for GitHub webhook events
</div>
<Show when={allowlist().length > 0}>
<div class="rmp-pipeline-empty-sub">watching {allowlist().join(", ")}</div>
</Show>
</div>
);
}
@@ -1,45 +0,0 @@
import { For, type JSX } from "solid-js";
import { statusResource } from "../state";
import { EVENT_STATE_ORDER, type EventState } from "../types";
const ACCENT: Record<EventState, string> = {
queued: "text-[#9ec9ff]",
running: "text-[#ffe26b]",
done: "text-[#7fe5a3]",
failed: "text-[#ff8e85]",
skipped: "text-ink-300",
};
export function Stats(): JSX.Element {
const counts = (): Record<EventState, number> => {
const status = statusResource();
if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
return status.issue_event_counts ?? status.event_counts;
};
return (
<section
class="glass glass-rise rounded-[22px] grid gap-px overflow-hidden"
style={{
"grid-template-columns": "repeat(5, minmax(0, 1fr))",
"background-color": "rgba(255, 255, 255, 0.05)",
}}
title="newest non-skipped event per issue"
>
<For each={EVENT_STATE_ORDER}>
{(state) => (
<div
class="px-5 py-5 flex flex-col gap-1.5"
style={{ "background-color": "rgba(8, 11, 16, 0.55)" }}
>
<span class="eyebrow">{state}</span>
<span class={`text-[34px] leading-none font-semibold tabular ${ACCENT[state]}`}>
{counts()[state] ?? 0}
</span>
</div>
)}
</For>
</section>
);
}
@@ -0,0 +1,113 @@
import type { JSX } from "solid-js";
import {
type ThemePreference,
preference,
setPreference,
} from "../theme";
// 3-way system → light → dark → system toggle. robomp has no icon library, so
// each preference renders an inline 16px SVG (monitor / sun / moon) styled with
// the existing `.btn.ghost.tiny` chrome.
const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
system: "light",
light: "dark",
dark: "system",
};
const PREFERENCE_LABEL: Record<ThemePreference, string> = {
system: "System theme",
light: "Light theme",
dark: "Dark theme",
};
function MonitorIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</svg>
);
}
function SunIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
}
function MoonIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
}
export function ThemeToggle(): JSX.Element {
const icon = (): JSX.Element => {
const p = preference();
if (p === "light") return <SunIcon />;
if (p === "dark") return <MoonIcon />;
return <MonitorIcon />;
};
const label = (): string => `${PREFERENCE_LABEL[preference()]} — click to switch`;
return (
<button
type="button"
class="btn ghost tiny"
onClick={() => setPreference(NEXT_PREFERENCE[preference()])}
aria-label={label()}
title={label()}
>
{icon()}
</button>
);
}
+76 -10
View File
@@ -7,11 +7,22 @@ import { GlassCard } from "./GlassCard";
const STATUS_TONE = {
idle: "text-ink-400",
pending: "text-ink-200",
ok: "text-[#7fe5a3]",
err: "text-[#ff8e85]",
ok: "text-ok",
err: "text-err",
} as const;
export function Trigger(): JSX.Element {
export interface TriggerProps {
/**
* `bar` — slim one-line command row for the Operations landing view.
* `panel` — fuller form (current look) for the Triage view.
* Both gate on CONFIG.replayEnabled; read-only degrades to a slim inline
* chip (bar) or the first-run explainer panel (panel, handled by Triage).
*/
variant?: "bar" | "panel";
}
export function Trigger(props: TriggerProps): JSX.Element {
const variant = (): "bar" | "panel" => props.variant ?? "panel";
const [issue, setIssue] = createSignal<string>("");
const validate = (): string | null => {
@@ -32,8 +43,63 @@ export function Trigger(): JSX.Element {
void runTrigger({ mode: "retry", issue: value });
};
// ── bar variant: slim command row (Operations). Read-only → inline chip. ──
return (
<GlassCard heading="trigger" accessory={<span class="text-ink-400">owner/repo#NN or issue url</span>}>
<Show when={variant() === "bar"} fallback={<PanelBody issue={issue} setIssue={setIssue} handleTriage={handleTriage} handleRetry={handleRetry} validate={validate} />}>
<Show
when={CONFIG.replayEnabled}
fallback={
<div class="rmp-trigger-bar">
<span class="rmp-readonly-chip">trigger disabled · read-only</span>
<span class="rmp-trigger-bar-status">set ROBOMP_REPLAY_TOKEN to enable</span>
</div>
}
>
<div class="rmp-trigger-bar">
<input
type="text"
spellcheck={false}
placeholder="owner/repo#NN or issue url"
autocomplete="off"
value={issue()}
onInput={(ev) => setIssue(ev.currentTarget.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") handleTriage();
}}
class="flex-1 min-w-[200px] font-mono"
/>
<button class="primary tiny" onClick={handleTriage}>
triage
</button>
<button class="tiny" onClick={handleRetry}>
retry latest
</button>
<Show when={triggerStatus().text}>
<span class={`rmp-trigger-bar-status ${STATUS_TONE[triggerStatus().kind]}`}>
{triggerStatus().text}
</span>
</Show>
</div>
</Show>
</Show>
);
}
interface PanelBodyProps {
issue: () => string;
setIssue: (v: string) => void;
handleTriage: () => void;
handleRetry: () => void;
validate: () => string | null;
}
// ── panel variant: fuller form (Triage). Read-only handled by the Triage
// view's first-run panel; if replay is on, this is the active form. ──
function PanelBody(p: PanelBodyProps): JSX.Element {
return (
<GlassCard
heading="trigger"
>
<Show
when={CONFIG.replayEnabled}
fallback={
@@ -50,22 +116,22 @@ export function Trigger(): JSX.Element {
spellcheck={false}
placeholder="octo/widget#42 or https://github.com/owner/repo/issues/42"
autocomplete="off"
value={issue()}
onInput={(ev) => setIssue(ev.currentTarget.value)}
value={p.issue()}
onInput={(ev) => p.setIssue(ev.currentTarget.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") handleTriage();
if (ev.key === "Enter") p.handleTriage();
}}
class="flex-1 min-w-[220px] font-mono"
/>
<button class="primary" onClick={handleTriage}>
<button class="primary" onClick={p.handleTriage}>
fetch &amp; triage
</button>
<button onClick={handleRetry}>retry latest run</button>
<button onClick={p.handleRetry}>retry latest run</button>
</div>
<Show
when={triggerStatus().text}
fallback={
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{validate() ?? "ready"}</span>
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{p.validate() ?? "ready"}</span>
}
>
<span class={`text-[12px] ${STATUS_TONE[triggerStatus().kind]}`}>
@@ -1,160 +0,0 @@
import { For, type JSX, Show } from "solid-js";
import { CONFIG } from "../config";
import { fmtAge, fmtDuration, shortDelivery, splitIssueKey } from "../format";
import { runCancel, statusResource } from "../state";
import type { RunningEvent } from "../types";
import { GlassCard } from "./GlassCard";
import { IssueLink } from "./IssueLink";
import { Pill } from "./Pill";
interface Row {
key: string;
delivery_id: string;
issue_key: string | null;
event_type: string;
attempts: number;
model: string | null;
last_tool: string | null;
last_tool_ts: string | null;
started_at: string | null;
inflight_only: boolean;
}
function rowsFor(running: RunningEvent[], inflight: string[]): Row[] {
const out: Row[] = [];
const seen = new Set<string>();
for (const e of running) {
const key = e.issue_key ?? e.delivery_id;
seen.add(key);
out.push({
key,
delivery_id: e.delivery_id,
issue_key: e.issue_key,
event_type: e.event_type,
attempts: e.attempts,
model: e.model,
last_tool: e.last_tool,
last_tool_ts: e.last_tool_ts,
started_at: e.started_at ?? e.received_at,
inflight_only: false,
});
}
for (const key of inflight) {
if (seen.has(key)) continue;
out.push({
key,
delivery_id: "",
issue_key: key,
event_type: "",
attempts: 0,
model: null,
last_tool: null,
last_tool_ts: null,
started_at: null,
inflight_only: true,
});
}
return out;
}
async function cancelDelivery(deliveryId: string): Promise<void> {
if (
!window.confirm(
"Kill this running task? The omp subprocess dies and the row lands in 'failed'.",
)
) {
return;
}
await runCancel(deliveryId);
}
function elapsed(startedAt: string | null): string {
if (!startedAt) return "—";
const t = Date.parse(startedAt);
if (Number.isNaN(t)) return "—";
return fmtDuration((Date.now() - t) / 1000);
}
export function Working(): JSX.Element {
const rows = (): Row[] => {
const s = statusResource();
return s ? rowsFor(s.running_events, s.inflight) : [];
};
return (
<GlassCard heading="currently working" accessory={<span class="tabular">{rows().length}</span>}>
<Show when={rows().length} fallback={<div class="empty">idle — waiting for events</div>}>
<div class="overflow-x-auto scrollable">
<table class="t">
<thead>
<tr>
<th>issue</th>
<th>event</th>
<th>state</th>
<th>elapsed</th>
<th>model</th>
<th>last action</th>
<th>attempt</th>
<th />
</tr>
</thead>
<tbody>
<For each={rows()}>{(r) => <WorkingRow row={r} />}</For>
</tbody>
</table>
</div>
</Show>
</GlassCard>
);
}
function WorkingRow(props: { row: Row }): JSX.Element {
const ref = (): { repo: string; number: string } => splitIssueKey(props.row.issue_key);
return (
<tr>
<td>
<Show when={ref().number} fallback={<code>{shortDelivery(props.row.delivery_id)}</code>}>
<IssueLink repo={ref().repo} number={ref().number} />
</Show>
</td>
<td class="text-ink-300">{props.row.event_type || "—"}</td>
<td>
<Pill state="running" dot>
{props.row.inflight_only ? "inflight" : "running"}
</Pill>
</td>
<td class="tabular">{elapsed(props.row.started_at)}</td>
<td>
{props.row.model ? (
<code title={props.row.model}>{props.row.model}</code>
) : (
<span class="text-ink-400">—</span>
)}
</td>
<td>
{props.row.last_tool ? (
<span>
<code>{props.row.last_tool}</code>
<span class="text-ink-400 ml-2">{fmtAge(props.row.last_tool_ts)}</span>
</span>
) : (
<span class="text-ink-400">{props.row.inflight_only ? "held by pool" : "—"}</span>
)}
</td>
<td class="text-ink-300 tabular">
{props.row.inflight_only ? "—" : `#${props.row.attempts}`}
</td>
<td>
<Show
when={CONFIG.replayEnabled && !props.row.inflight_only}
fallback={<span class="text-ink-400">—</span>}
>
<button class="tiny danger" onClick={() => cancelDelivery(props.row.delivery_id)}>
cancel
</button>
</Show>
</td>
</tr>
);
}
@@ -0,0 +1,104 @@
import { type JSX, Show } from "solid-js";
import { CONFIG } from "../../config";
import { activeView, setActiveView, type View } from "../../view";
import { ThemeToggle } from "../ThemeToggle";
import { Vitals } from "./Vitals";
// Inline nav glyphs — robomp has no icon library.
function ActivityIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
}
function OpsIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="3" width="20" height="6" rx="1.5" />
<rect x="2" y="13" width="14" height="8" rx="1.5" />
<path d="M20 17h2" />
</svg>
);
}
function TriageIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m14 4 6 6-10 10H4v-6L14 4Z" />
<path d="m13 5 6 6" />
</svg>
);
}
function LockIcon(): JSX.Element {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 1 1 8 0v4" />
</svg>
);
}
interface NavDef {
id: View;
label: string;
icon: () => JSX.Element;
locked: boolean;
}
const NAV: NavDef[] = [
{ id: "operations", label: "Operations", icon: OpsIcon, locked: false },
{ id: "activity", label: "Activity", icon: ActivityIcon, locked: false },
{ id: "triage", label: "Triage", icon: TriageIcon, locked: !CONFIG.replayEnabled },
];
export function Rail(): JSX.Element {
return (
<aside class="rmp-rail">
{/* Brand lockup: gradient mark + wordmark + tag */}
<div class="rmp-rail-brand">
<span class="rmp-rail-mark" aria-hidden="true" />
<span class="rmp-rail-wordmark">
<span class="rmp-rail-name">robomp</span>
<span class="rmp-rail-tag">triage · fix · ship</span>
</span>
</div>
{/* Always-on vitals: health/sync + running# + failed# + 5 counts + runtime meta */}
<Vitals />
{/* Nav: Operations / Activity / Triage */}
<nav class="rmp-nav">
{NAV.map((item) => (
<button
type="button"
class="rmp-nav-item"
data-active={activeView() === item.id ? "true" : "false"}
data-locked={item.locked ? "true" : "false"}
aria-current={activeView() === item.id ? "page" : undefined}
title={item.locked ? "Triage — locked (set ROBOMP_REPLAY_TOKEN to enable)" : item.label}
onClick={() => setActiveView(item.id)}
>
<span class="rmp-nav-item-icon">{item.icon()}</span>
<span class="rmp-nav-item-label">{item.label}</span>
<Show when={item.locked}>
<span class="rmp-nav-item-lock" title="read-only">
<LockIcon />
</span>
</Show>
</button>
))}
</nav>
{/* Footer: theme toggle + polling tag */}
<div class="rmp-rail-footer">
<ThemeToggle />
<span class="rmp-rail-footer-tag">polling 3s</span>
</div>
</aside>
);
}
@@ -0,0 +1,72 @@
import { createEffect, createSignal, type JSX, Show } from "solid-js";
import { activeView } from "../../view";
import { Activity } from "../views/Activity";
import { Operations } from "../views/Operations";
import { Triage } from "../views/Triage";
import { Rail } from "./Rail";
import { TopBar } from "./TopBar";
export function Shell(): JSX.Element {
const [drawerOpen, setDrawerOpen] = createSignal(false);
// Close the mobile drawer whenever the active view changes (nav tap).
createEffect(() => {
activeView();
setDrawerOpen(false);
});
const viewDisplay = (v: string): string => (activeView() === v ? "" : "none");
return (
<div class="rmp-app">
{/* Desktop rail (hidden <1024px) */}
<div class="rmp-desktop-nav">
<Rail />
</div>
{/* Mobile drawer */}
<Show when={drawerOpen()}>
<div
class="rmp-mobile-drawer-overlay"
onClick={() => setDrawerOpen(false)}
role="presentation"
>
<div
class="rmp-mobile-drawer"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
>
<Rail />
</div>
</div>
</Show>
{/* Main pane */}
<div class="rmp-main">
<TopBar onMenuToggle={() => setDrawerOpen(true)} />
<main class="rmp-content scrollable">
<div class="rmp-content-inner">
{/* All 3 views stay mounted; visibility toggled by activeView so
view-local UI state (Logs/Browse filters) persists and Browse
does not refetch on view switches. The enter animation
(rmp-view-enter) restarts automatically when a view flips
none→flex, firing on first paint + view-switch ONLY, never on
the 3s data poll (polls never change display). */}
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("operations") }}>
<Operations />
</div>
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("activity") }}>
<Activity />
</div>
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("triage") }}>
<Triage />
</div>
</div>
</main>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { type JSX, Show } from "solid-js";
import { CONFIG } from "../../config";
import { activeView, type View } from "../../view";
interface TopBarProps {
onMenuToggle: () => void;
}
const TITLES: Record<View, string> = {
operations: "Operations",
activity: "Activity",
triage: "Triage",
};
function MenuIcon(): JSX.Element {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
);
}
export function TopBar(props: TopBarProps): JSX.Element {
return (
<header class="rmp-topbar">
<div class="rmp-topbar-left">
<button
type="button"
class="rmp-mobile-menu-btn"
aria-label="Open navigation menu"
onClick={() => props.onMenuToggle()}
>
<MenuIcon />
</button>
<h1 class="rmp-page-title">{TITLES[activeView()]}</h1>
</div>
<div class="rmp-topbar-right">
{/* View-contextual slot. Operations → read-only/replay chip. Sync + vitals live in the rail. */}
<Show when={activeView() === "operations"}>
<Show
when={CONFIG.replayEnabled}
fallback={<span class="rmp-readonly-chip">read-only · replay off</span>}
>
<span class="rmp-readonly-chip">replay on</span>
</Show>
</Show>
</div>
</header>
);
}
@@ -0,0 +1,168 @@
import { type JSX, Show } from "solid-js";
import { fmtDuration } from "../../format";
import { isFetching, lastTickAt, lastTickError, statusResource } from "../../state";
import { EVENT_STATE_ORDER } from "../../types";
// Ported verbatim from Header.tsx (dissolved).
function relativeAgo(ms: number): string {
const seconds = Math.max(0, (Date.now() - ms) / 1000);
if (seconds < 5) return "just now";
return `${fmtDuration(seconds)} ago`;
}
const STATE_TONE: Record<string, string> = {
queued: "var(--color-info)",
running: "var(--color-warn)",
done: "var(--color-ok)",
failed: "var(--color-err)",
skipped: "var(--color-ink-300)",
};
export function Vitals(): JSX.Element {
const runtime = () => statusResource()?.runtime;
// issue_event_counts ?? event_counts — preserves the Stats.tsx accessor + title.
const counts = (): Record<string, number> => {
const status = statusResource();
if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
return status.issue_event_counts ?? status.event_counts;
};
// Match the pipeline derivation: running_events and the inflight snapshot overlap
// (the worker pool keys inflight by `issue_key || delivery_id`), so union by
// that same key instead of summing lengths — otherwise one live job counts as two.
const runningTotal = (): number => {
const s = statusResource();
if (!s) return 0;
const keys = new Set(s.running_events.map((e) => e.issue_key ?? e.delivery_id));
for (const key of s.inflight) keys.add(key);
return keys.size;
};
const failedCount = (): number => counts().failed ?? 0;
const allowlist = (): string => {
const list = runtime()?.repo_allowlist;
return list && list.length ? list.join(", ") : "(none)";
};
const syncTone = (): string => {
if (lastTickError()) return "err";
if (isFetching()) return "warn";
return "ok";
};
const syncText = (): string => {
if (lastTickError()) return lastTickError()!;
if (isFetching()) return "syncing…";
return `synced ${relativeAgo(lastTickAt())}`;
};
return (
<div class="rmp-vitals">
{/* health / sync */}
<div class="rmp-vital-sync" title={lastTickError() ?? undefined}>
<span
class={`rmp-vital-dot ${syncTone()}${isFetching() ? " pulse" : ""}`}
/>
<Show
when={lastTickError()}
fallback={<span>{syncText()}</span>}
>
<span class="text-err truncate">{syncText()}</span>
</Show>
</div>
{/* big running / failed */}
<div class="rmp-vital-big">
<div class="rmp-vital-big-cell">
<span class="rmp-vital-big-label">running</span>
<span
class={`rmp-vital-big-num${
runningTotal() > 0 ? " running-nonzero" : ""
}`}
>
{runningTotal()}
</span>
</div>
<div class="rmp-vital-big-cell">
<span class="rmp-vital-big-label">failed</span>
<span
class={`rmp-vital-big-num${
failedCount() > 0 ? " failed-nonzero" : ""
}`}
>
{failedCount()}
</span>
</div>
</div>
{/* 5 state counts (compact 2-col) */}
<div class="rmp-vital-counts" title="newest non-skipped event per issue">
{EVENT_STATE_ORDER.map((state) => (
<div class="rmp-vital-count">
<span class="rmp-vital-count-label">{state}</span>
<span
class="rmp-vital-count-num"
style={{ color: STATE_TONE[state] ?? "var(--color-ink-200)" }}
>
{counts()[state] ?? 0}
</span>
</div>
))}
</div>
{/* runtime meta */}
<div class="rmp-vital-runtime">
<RuntimeRow label="bot" value={runtime()?.bot_login} mono />
<RuntimeRow
label="model"
value={runtime()?.model}
mono
title={
runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined
}
/>
<RuntimeRow
label="conc"
value={
runtime()?.max_concurrency != null
? String(runtime()?.max_concurrency)
: undefined
}
/>
<RuntimeRow
label="up"
value={
runtime()?.uptime_seconds != null
? fmtDuration(runtime()?.uptime_seconds)
: undefined
}
/>
<RuntimeRow label="repos" value={allowlist()} mono title={allowlist()} />
</div>
</div>
);
}
interface RuntimeRowProps {
label: string;
value?: string;
mono?: boolean;
title?: string;
}
function RuntimeRow(props: RuntimeRowProps): JSX.Element {
return (
<div class="rmp-vital-runtime-row" title={props.title}>
<span class="rmp-vital-runtime-label">{props.label}</span>
<span
class={`rmp-vital-runtime-value${props.mono ? " mono" : ""}`}
title={props.value}
>
{props.value ?? "—"}
</span>
</div>
);
}
@@ -0,0 +1,20 @@
import { type JSX } from "solid-js";
import { runTrigger } from "../../state";
import { Events } from "../Events";
import { Logs } from "../Logs";
// Activity (investigate mode). Full history table + full-height log stream.
// Logs finally get real vertical room.
export function Activity(): JSX.Element {
const handleRetry = (deliveryId: string): void => {
void runTrigger({ mode: "retry", delivery_id: deliveryId });
};
return (
<>
<Events onRetry={handleRetry} />
<Logs />
</>
);
}
@@ -0,0 +1,19 @@
import { type JSX } from "solid-js";
import { Pipeline } from "../Pipeline";
import { Trigger } from "../Trigger";
// Operations (default landing view). A single unified pipeline: a slim
// trigger command row on top, then every active work item drawn as a
// lifecycle card (failed first, running live). Replaces the old three-table
// layout (working / needs-attention / active-issues) — Pipeline derives all
// three states from one status poll. Read-only collapses the trigger to a
// chip; the cards lose their retry/cancel actions.
export function Operations(): JSX.Element {
return (
<>
<Trigger variant="bar" />
<Pipeline />
</>
);
}
@@ -0,0 +1,50 @@
import { type JSX, Show } from "solid-js";
import { CONFIG } from "../../config";
import { Browse } from "../Browse";
import { GlassCard } from "../GlassCard";
import { Trigger } from "../Trigger";
// Triage (act mode). Full trigger form + issue browser. When replay is off,
// renders ONE composed first-run panel (env + what unlocks) instead of two
// large disabled cards. The rail marks this nav item locked + dimmed.
export function Triage(): JSX.Element {
return (
<Show
when={CONFIG.replayEnabled}
fallback={<FirstRunPanel />}
>
<Trigger variant="panel" />
<Browse />
</Show>
);
}
function FirstRunPanel(): JSX.Element {
return (
<GlassCard heading="triage" accessory={<span class="rmp-readonly-chip">locked</span>}>
<div class="rmp-firstrun">
<div class="rmp-firstrun-head">
<span class="rmp-readonly-chip">read-only</span>
<h3 class="rmp-firstrun-title">manual actions are disabled</h3>
</div>
<p class="rmp-firstrun-body">
Triage and retry let you drive the bot by hand — fetch a fresh issue, re-run a failed
delivery, or browse the open backlog. These actions are gated behind a server-side
replay token so the dashboard is safe to expose read-only.
</p>
<ul class="rmp-firstrun-steps">
<li>
set <code>ROBOMP_REPLAY_TOKEN</code> in the robomp server environment
</li>
<li>restart the server so the token is loaded</li>
<li>reload — the Operations trigger bar and this Triage view unlock</li>
</ul>
<p class="rmp-firstrun-note">
Monitoring stays fully live: Operations and Activity read the same 3s poll regardless of
this setting.
</p>
</div>
</GlassCard>
);
}