Supervise several agents at once
One daemon already saw every session; the dashboard only ever showed one of them well. Under four parallel agents it failed in specific ways, each fixed here: - Sessions were identified by workspace basename, so two agents in one repo were indistinguishable. The daemon now hands out a small ordinal badge in arrival order and the web UI colours each agent by it — rows, timeline lines, and approval cards, which previously asked you to approve `rm -rf` "in remote-grok" without saying which one. - `currentTool` held a single call, so parallel tools overwrote each other. It is now a list; durations are matched FIFO per tool name, since hook payloads carry no call id. - One 400-event ring, evicted oldest-first, let a chatty agent blank everyone else's history. Eviction now takes from whichever session holds the most of the ring. - Sessions sorted by recency jumped under a moving thumb. They are ordered waiting -> error -> working -> idle -> ended, ties on badge, so an agent keeps its slot. - Events carry no workspace root, so a restart came back with a full timeline and an empty roster. The session map is persisted to sessions.json (debounced, flushed on shutdown, 12h cutoff on load). In-flight tools are dropped on the way out: they belonged to a process that no longer exists. - Snapshot pushes now back off to 1s once a snapshot exceeds 24KB, since full-snapshot SSE cost scales with agent count. - Pending approvals are ordered by which expires first, not by arrival. `glance status` and /local/status report the roster by state. e2e suite: 220 passed, 0 failed.
This commit is contained in:
+44
-6
@@ -10,7 +10,7 @@ import { SessionsCard } from "@/components/SessionsCard";
|
||||
import { SettingsPanel } from "@/components/SettingsPanel";
|
||||
import { Timeline } from "@/components/Timeline";
|
||||
import { GearIcon } from "@/components/icons";
|
||||
import type { GateInfo } from "@/protocol";
|
||||
import type { GateInfo, SessionState, SessionView } from "@/protocol";
|
||||
|
||||
export default function App() {
|
||||
const [gate, setGate] = useState<GateInfo | null>(null);
|
||||
@@ -87,8 +87,12 @@ export default function App() {
|
||||
}
|
||||
|
||||
const sessions = snapshot?.sessions ?? [];
|
||||
const focus = sessions.find((s) => s.id === selected) ?? sessions[0];
|
||||
const pending = snapshot?.pending ?? [];
|
||||
// With one agent the detail card *is* the dashboard, so focus it and skip the list. With
|
||||
// several, the overview leads and the detail appears only for the one you tapped.
|
||||
const focus =
|
||||
sessions.find((s) => s.id === selected) ?? (sessions.length === 1 ? sessions[0] : undefined);
|
||||
const many = sessions.length > 1;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background text-foreground">
|
||||
@@ -108,7 +112,7 @@ export default function App() {
|
||||
<h1 className="truncate text-sm font-semibold tracking-tight">grok-glance</h1>
|
||||
<p className="text-[11px] text-muted">
|
||||
{connection === "live"
|
||||
? `${sessions.length} session${sessions.length === 1 ? "" : "s"}`
|
||||
? stateSummary(sessions)
|
||||
: connection === "connecting"
|
||||
? "connecting…"
|
||||
: "offline — retrying"}
|
||||
@@ -163,8 +167,14 @@ export default function App() {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{focus && <NowCard session={focus} now={now} />}
|
||||
{sessions.length > 1 && (
|
||||
{focus && (
|
||||
<NowCard
|
||||
session={focus}
|
||||
now={now}
|
||||
onBack={many ? () => setSelected(null) : undefined}
|
||||
/>
|
||||
)}
|
||||
{many && (
|
||||
<SessionsCard
|
||||
sessions={sessions}
|
||||
selectedId={selected}
|
||||
@@ -172,7 +182,12 @@ export default function App() {
|
||||
now={now}
|
||||
/>
|
||||
)}
|
||||
<Timeline events={snapshot.events} sessionId={selected} />
|
||||
<Timeline
|
||||
events={snapshot.events}
|
||||
sessionId={selected}
|
||||
sessions={sessions}
|
||||
onClearFilter={() => setSelected(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
@@ -180,6 +195,29 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
const SUMMARY_ORDER: Array<[SessionState, string]> = [
|
||||
["waiting", "waiting"],
|
||||
["error", "error"],
|
||||
["working", "working"],
|
||||
["idle", "idle"],
|
||||
["ended", "ended"],
|
||||
];
|
||||
|
||||
/**
|
||||
* "2 working · 1 waiting" rather than "3 sessions". When you are supervising several agents,
|
||||
* the count you actually want from the top of the screen is how many of them need you.
|
||||
*/
|
||||
function stateSummary(sessions: SessionView[]): string {
|
||||
if (sessions.length === 0) return "no sessions yet";
|
||||
const counts = new Map<SessionState, number>();
|
||||
for (const session of sessions) {
|
||||
counts.set(session.state, (counts.get(session.state) ?? 0) + 1);
|
||||
}
|
||||
return SUMMARY_ORDER.filter(([state]) => counts.get(state))
|
||||
.map(([state, label]) => `${counts.get(state)} ${label}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { Card, Spinner } from "@heroui/react";
|
||||
import { Button, Card, Spinner } from "@heroui/react";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { StateChip, ToolChip } from "@/components/StatusChip";
|
||||
import { duration, relTime } from "@/lib/format";
|
||||
import type { SessionView } from "@/protocol";
|
||||
|
||||
export function NowCard({ session, now }: { session: SessionView; now: number }) {
|
||||
const tool = session.currentTool;
|
||||
export function NowCard({
|
||||
session,
|
||||
now,
|
||||
onBack,
|
||||
}: {
|
||||
session: SessionView;
|
||||
now: number;
|
||||
/** Only passed when there is more than one agent — otherwise there is nothing to go back to. */
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const running = session.running;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Card.Title className="truncate text-base">{session.label}</Card.Title>
|
||||
<Card.Title className="flex min-w-0 items-center gap-2 text-base">
|
||||
<SessionBadge badge={session.badge} label={session.label} />
|
||||
</Card.Title>
|
||||
<Card.Description className="truncate text-xs">{session.cwd}</Card.Description>
|
||||
</div>
|
||||
<StateChip state={session.state} />
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<StateChip state={session.state} />
|
||||
{onBack && (
|
||||
<Button size="sm" variant="ghost" onPress={onBack}>
|
||||
All agents
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
@@ -28,18 +47,24 @@ export function NowCard({ session, now }: { session: SessionView; now: number })
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tool ? (
|
||||
<div className="flex items-start gap-2.5 rounded-xl bg-surface-secondary p-3">
|
||||
<Spinner size="sm" color="current" className="mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ToolChip tool={tool.name} />
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{duration(Math.max(0, now - tool.startedAt))}
|
||||
</span>
|
||||
{running.length > 0 ? (
|
||||
/* An agent runs tools in parallel, so this is a list — showing only the newest one
|
||||
would keep redrawing the same card with a different tool in it. */
|
||||
<div className="flex flex-col gap-2.5 rounded-xl bg-surface-secondary p-3">
|
||||
{running.map((tool) => (
|
||||
<div key={`${tool.name}-${tool.startedAt}`} className="flex items-start gap-2.5">
|
||||
<Spinner size="sm" color="current" className="mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ToolChip tool={tool.name} />
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{duration(Math.max(0, now - tool.startedAt))}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Card } from "@heroui/react";
|
||||
import { BanIcon, CheckIcon } from "@/components/icons";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { ToolChip } from "@/components/StatusChip";
|
||||
import { secondsLeft } from "@/lib/format";
|
||||
import type { PendingApproval } from "@/protocol";
|
||||
@@ -28,7 +29,14 @@ export function PendingCard({
|
||||
</div>
|
||||
<Card.Description className="flex flex-wrap items-center gap-1.5">
|
||||
<ToolChip tool={approval.tool} />
|
||||
<span className="text-xs text-muted">in {approval.sessionLabel}</span>
|
||||
{/* Which agent is asking. Two of them in one repo would otherwise both read
|
||||
"in remote-grok", and you would be approving a command blind. */}
|
||||
<span className="text-xs text-muted">in</span>
|
||||
<SessionBadge
|
||||
badge={approval.sessionBadge}
|
||||
label={approval.sessionLabel}
|
||||
className="text-xs text-muted"
|
||||
/>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { sessionColor } from "@/lib/sessionColor";
|
||||
|
||||
/**
|
||||
* Colour dot plus #N: the only thing on screen guaranteed to be unique per agent, which
|
||||
* matters the moment two of them are running in the same repo.
|
||||
*/
|
||||
export function SessionBadge({
|
||||
badge,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
badge: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = sessionColor(badge);
|
||||
return (
|
||||
<span className={`inline-flex min-w-0 items-center gap-1.5 ${className}`}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${color.dot}`} aria-hidden="true" />
|
||||
<span className={`shrink-0 text-[11px] font-semibold tabular-nums ${color.text}`}>
|
||||
#{badge}
|
||||
</span>
|
||||
{label !== undefined && <span className="min-w-0 truncate">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@heroui/react";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { StateChip } from "@/components/StatusChip";
|
||||
import { relTime } from "@/lib/format";
|
||||
import { duration, relTime } from "@/lib/format";
|
||||
import type { SessionView } from "@/protocol";
|
||||
|
||||
/**
|
||||
* Only rendered when more than one session is live. With a single workspace the Now card
|
||||
* already says everything, and an extra list is just noise on a small screen.
|
||||
* Every agent at once: what each one is doing right now, not just the one you last tapped.
|
||||
* Rendered whenever more than one session is live — with a single workspace the Now card
|
||||
* already says all of this and a list is just noise on a small screen.
|
||||
*
|
||||
* The server sorts these: whatever needs you first, then a fixed slot per agent. Rows must
|
||||
* not reorder themselves under a thumb that is already moving toward one.
|
||||
*/
|
||||
export function SessionsCard({
|
||||
sessions,
|
||||
@@ -19,42 +25,97 @@ export function SessionsCard({
|
||||
onSelect: (id: string | null) => void;
|
||||
now: number;
|
||||
}) {
|
||||
const [showEnded, setShowEnded] = useState(false);
|
||||
const live = sessions.filter((s) => s.state !== "ended");
|
||||
const ended = sessions.filter((s) => s.state === "ended");
|
||||
const rows = showEnded ? [...live, ...ended] : live;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base">Sessions</Card.Title>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Card.Title className="text-base">Agents</Card.Title>
|
||||
<span className="text-xs text-muted">{live.length} live</span>
|
||||
</div>
|
||||
<Card.Description className="text-xs">
|
||||
Tap one to filter the activity list.
|
||||
Tap one for its detail and its own activity.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content className="px-0">
|
||||
<ul className="flex flex-col">
|
||||
<li className="border-b border-separator">
|
||||
<Row active={selectedId === null} onPress={() => onSelect(null)}>
|
||||
<span className="text-sm">All sessions</span>
|
||||
<span className="flex-1 text-sm">All agents</span>
|
||||
<span className="text-xs text-muted">{sessions.length}</span>
|
||||
</Row>
|
||||
</li>
|
||||
{sessions.map((session) => (
|
||||
{rows.map((session) => (
|
||||
<li key={session.id} className="border-b border-separator last:border-b-0">
|
||||
<Row
|
||||
active={selectedId === session.id}
|
||||
onPress={() => onSelect(session.id === selectedId ? null : session.id)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{session.label}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted">
|
||||
{relTime(session.lastActivity, now)}
|
||||
</span>
|
||||
<StateChip state={session.state} />
|
||||
<AgentRow session={session} now={now} />
|
||||
</Row>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card.Content>
|
||||
|
||||
{ended.length > 0 && (
|
||||
<Card.Footer>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-center text-xs text-muted"
|
||||
onClick={() => setShowEnded((value) => !value)}
|
||||
>
|
||||
{showEnded
|
||||
? "Hide ended"
|
||||
: `Show ${ended.length} ended session${ended.length === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentRow({ session, now }: { session: SessionView; now: number }) {
|
||||
const [head, ...rest] = session.running;
|
||||
const { tools, failures, denials } = session.counts;
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<SessionBadge badge={session.badge} label={session.label} className="flex-1 text-sm" />
|
||||
<StateChip state={session.state} />
|
||||
</span>
|
||||
|
||||
{head ? (
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 text-xs">
|
||||
<span className="shrink-0 font-medium">{head.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted">
|
||||
{duration(Math.max(0, now - head.startedAt))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted">{head.title}</span>
|
||||
{rest.length > 0 && <span className="shrink-0 text-muted">+{rest.length}</span>}
|
||||
</span>
|
||||
) : (
|
||||
session.lastPrompt && (
|
||||
<span className="min-w-0 truncate text-xs text-muted">{session.lastPrompt}</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<span className="flex items-baseline gap-2 text-[11px] text-muted">
|
||||
<span className="tabular-nums">{tools} tools</span>
|
||||
{failures > 0 && <span className="tabular-nums text-danger">{failures} failed</span>}
|
||||
{denials > 0 && <span className="tabular-nums text-danger">{denials} denied</span>}
|
||||
<span className="ml-auto tabular-nums">{relTime(session.lastActivity, now)}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
active,
|
||||
onPress,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card } from "@heroui/react";
|
||||
import { SessionBadge } from "@/components/SessionBadge";
|
||||
import { clockTime, duration } from "@/lib/format";
|
||||
import type { EventKind, GlanceEvent } from "@/protocol";
|
||||
import type { EventKind, GlanceEvent, SessionView } from "@/protocol";
|
||||
|
||||
const DOT: Record<EventKind, string> = {
|
||||
session_start: "bg-muted",
|
||||
@@ -39,20 +40,35 @@ function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[]
|
||||
export function Timeline({
|
||||
events,
|
||||
sessionId,
|
||||
sessions,
|
||||
onClearFilter,
|
||||
}: {
|
||||
events: GlanceEvent[];
|
||||
sessionId: string | null;
|
||||
sessions: SessionView[];
|
||||
onClearFilter?: () => void;
|
||||
}) {
|
||||
const [limit, setLimit] = useState(PAGE);
|
||||
const rows = visible(events, sessionId);
|
||||
const shown = rows.slice(0, limit);
|
||||
const focused = sessionId ? sessions.find((s) => s.id === sessionId) : undefined;
|
||||
// Interleaved lines from four agents are unreadable without saying whose each one is.
|
||||
const badges = sessions.length > 1 && !sessionId ? new Map(sessions.map((s) => [s.id, s])) : null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base">Activity</Card.Title>
|
||||
<Card.Description className="text-xs">
|
||||
{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<Card.Title className="text-base">Activity</Card.Title>
|
||||
{sessionId && onClearFilter && (
|
||||
<button type="button" className="text-xs text-accent" onClick={onClearFilter}>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Card.Description className="flex items-center gap-1.5 text-xs">
|
||||
{focused && <SessionBadge badge={focused.badge} label={focused.label} />}
|
||||
<span>{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}</span>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
@@ -70,7 +86,10 @@ export function Timeline({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="min-w-0 text-sm leading-snug break-words">{event.title}</p>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||
<span className="flex shrink-0 items-baseline gap-1.5 text-[11px] tabular-nums text-muted">
|
||||
{badges?.get(event.sessionId) && (
|
||||
<SessionBadge badge={badges.get(event.sessionId)!.badge} />
|
||||
)}
|
||||
{clockTime(event.ts)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* A fixed colour per agent, keyed on the badge the daemon hands out.
|
||||
*
|
||||
* Labels are workspace basenames, so two agents working in the same repo read identically.
|
||||
* Colour plus #N is what makes a row in the overview, a line in the timeline and an approval
|
||||
* card recognisably the same agent without reading anything.
|
||||
*
|
||||
* Written as whole class names on purpose: Tailwind scans the source text, so a class
|
||||
* assembled from a template string at runtime would not survive the build.
|
||||
*/
|
||||
|
||||
export interface SessionColor {
|
||||
dot: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const PALETTE: SessionColor[] = [
|
||||
{ dot: "bg-sky-500", text: "text-sky-600 dark:text-sky-400" },
|
||||
{ dot: "bg-violet-500", text: "text-violet-600 dark:text-violet-400" },
|
||||
{ dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400" },
|
||||
{ dot: "bg-rose-500", text: "text-rose-600 dark:text-rose-400" },
|
||||
{ dot: "bg-cyan-500", text: "text-cyan-600 dark:text-cyan-400" },
|
||||
{ dot: "bg-fuchsia-500", text: "text-fuchsia-600 dark:text-fuchsia-400" },
|
||||
{ dot: "bg-lime-500", text: "text-lime-600 dark:text-lime-400" },
|
||||
];
|
||||
|
||||
export function sessionColor(badge: number): SessionColor {
|
||||
const index = Math.max(0, Math.floor(badge) - 1) % PALETTE.length;
|
||||
return PALETTE[index];
|
||||
}
|
||||
+19
-4
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Wire protocol shared between the daemon and the web app.
|
||||
*
|
||||
* NOTE: this is a copy of server/src/protocol.ts. Keep the two in sync — they are duplicated
|
||||
* rather than shared because the server compiles under NodeNext while the web app compiles
|
||||
* under a bundler resolution, and a single rootDir cannot span both.
|
||||
* NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are
|
||||
* duplicated rather than shared because the server compiles under NodeNext while the web
|
||||
* app compiles under a bundler resolution, and a single rootDir cannot span both.
|
||||
*/
|
||||
|
||||
export type EventKind =
|
||||
@@ -41,16 +41,29 @@ export interface GlanceEvent {
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface RunningTool {
|
||||
name: string;
|
||||
title: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
id: string;
|
||||
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||
label: string;
|
||||
/**
|
||||
* Small ordinal handed out in arrival order and kept across daemon restarts. Labels are
|
||||
* basenames, so two agents in the same repo look identical; this is what tells them apart,
|
||||
* and the dashboard colours each agent by it.
|
||||
*/
|
||||
badge: number;
|
||||
cwd: string;
|
||||
state: SessionState;
|
||||
startedAt: number;
|
||||
lastActivity: number;
|
||||
lastPrompt?: string;
|
||||
currentTool?: { name: string; title: string; startedAt: number };
|
||||
/** Tool calls in flight, oldest first — an agent can run several at once. */
|
||||
running: RunningTool[];
|
||||
counts: { tools: number; failures: number; denials: number };
|
||||
}
|
||||
|
||||
@@ -58,6 +71,8 @@ export interface PendingApproval {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sessionLabel: string;
|
||||
/** Matches SessionView.badge, so a card says which agent is asking when two share a label. */
|
||||
sessionBadge: number;
|
||||
tool: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
|
||||
Reference in New Issue
Block a user