first commit

This commit is contained in:
iceBear67
2026-08-09 04:00:13 +00:00
commit b3b6bf3f70
46 changed files with 7146 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover so the sticky header sits under the notch rather than beside it. -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<title>grok-glance</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="glance" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#09090b" />
<circle cx="32" cy="32" r="15" fill="none" stroke="#fafafa" stroke-width="3.5" />
<circle cx="32" cy="32" r="5.5" fill="#fafafa" />
<path d="M32 9v5M32 50v5M9 32h5M50 32h5" stroke="#71717a" stroke-width="3.5" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

+19
View File
@@ -0,0 +1,19 @@
{
"name": "grok-glance",
"short_name": "glance",
"description": "Glance at what Grok Build is doing.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { Alert, Button, Card, Spinner, useTheme } from "@heroui/react";
import { api } from "@/lib/api";
import { useGlance, useNow } from "@/lib/useGlance";
import { Gate } from "@/components/Gate";
import { NowCard } from "@/components/NowCard";
import { PendingCard } from "@/components/PendingCard";
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";
export default function App() {
const [gate, setGate] = useState<GateInfo | null>(null);
const [fatal, setFatal] = useState<string | null>(null);
// HeroUI's own hook: it writes the `dark` class and `data-theme` in a layout effect (no
// flash), follows the OS while the intent is "system", and persists an explicit choice.
const { resolvedTheme, setTheme } = useTheme();
const [showSettings, setShowSettings] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const [busyIds, setBusyIds] = useState<string[]>([]);
const refreshGate = useCallback(async () => {
try {
setGate(await api.gate());
setFatal(null);
} catch (err) {
setFatal((err as Error).message);
}
}, []);
useEffect(() => {
void refreshGate();
}, [refreshGate]);
const authenticated = !!gate?.authenticated;
const { snapshot, connection } = useGlance(authenticated);
const now = useNow(1000);
// A dropped stream is usually the network, but it is also how an expired session shows up.
// Re-checking the gate turns the second case back into the unlock screen instead of a spinner.
useEffect(() => {
if (!authenticated || connection !== "offline") return;
const id = window.setTimeout(() => void refreshGate(), 3000);
return () => window.clearTimeout(id);
}, [authenticated, connection, refreshGate]);
async function resolve(id: string, decision: "allow" | "deny") {
setBusyIds((ids) => [...ids, id]);
try {
await api.resolveApproval(id, decision);
} catch {
// Losing the race is normal: it may have timed out, or another device answered first.
} finally {
setBusyIds((ids) => ids.filter((x) => x !== id));
}
}
if (fatal && !gate) {
return (
<Centered>
<Alert status="danger">
<Alert.Content>
<Alert.Title>Cannot reach the daemon</Alert.Title>
<Alert.Description>{fatal}</Alert.Description>
</Alert.Content>
</Alert>
<Button variant="outline" size="md" fullWidth onPress={() => void refreshGate()}>
Try again
</Button>
</Centered>
);
}
if (!gate) {
return (
<Centered>
<Spinner size="lg" color="current" />
</Centered>
);
}
if (!authenticated) {
return <Gate gate={gate} onSignedIn={() => void refreshGate()} />;
}
const sessions = snapshot?.sessions ?? [];
const focus = sessions.find((s) => s.id === selected) ?? sessions[0];
const pending = snapshot?.pending ?? [];
return (
<div className="min-h-dvh bg-background text-foreground">
<header className="sticky top-0 z-10 border-b border-separator bg-background/85 backdrop-blur-md">
<div className="mx-auto flex max-w-md items-center gap-3 px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-3">
<span
className={`h-2 w-2 shrink-0 rounded-full ${
connection === "live"
? "bg-success"
: connection === "connecting"
? "bg-warning"
: "bg-danger"
}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<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"}`
: connection === "connecting"
? "connecting…"
: "offline — retrying"}
</p>
</div>
<Button
size="sm"
variant={showSettings ? "primary" : "ghost"}
isIconOnly
aria-label="Settings"
onPress={() => setShowSettings((value) => !value)}
>
<GearIcon />
</Button>
</div>
</header>
<main className="mx-auto flex max-w-md flex-col gap-3 px-4 py-4 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
{pending.map((approval) => (
<PendingCard
key={approval.id}
approval={approval}
now={now}
busy={busyIds.includes(approval.id)}
onResolve={resolve}
/>
))}
{showSettings && snapshot && (
<SettingsPanel
approval={snapshot.approval}
deviceLabel={gate.deviceLabel}
version={snapshot.version}
theme={resolvedTheme === "dark" ? "dark" : "light"}
onToggleTheme={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
onSignedOut={() => void refreshGate()}
/>
)}
{!snapshot ? (
<Centered inline>
<Spinner size="lg" color="current" />
</Centered>
) : sessions.length === 0 ? (
<Card>
<Card.Header>
<Card.Title className="text-base">Nothing to show yet</Card.Title>
<Card.Description>
Start Grok Build in a workspace and this page will fill in as it works.
</Card.Description>
</Card.Header>
</Card>
) : (
<>
{focus && <NowCard session={focus} now={now} />}
{sessions.length > 1 && (
<SessionsCard
sessions={sessions}
selectedId={selected}
onSelect={setSelected}
now={now}
/>
)}
<Timeline events={snapshot.events} sessionId={selected} />
</>
)}
</main>
</div>
);
}
function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) {
return (
<div
className={`mx-auto flex w-full max-w-sm flex-col items-center justify-center gap-4 px-5 ${
inline ? "py-16" : "min-h-dvh"
}`}
>
{children}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from "react";
import { Alert, Button, Card, Input, Spinner } from "@heroui/react";
import { browserSupportsWebAuthn } from "@simplewebauthn/browser";
import { api } from "@/lib/api";
import { FingerprintIcon, LockIcon } from "@/components/icons";
import type { GateInfo } from "@/protocol";
/** A friendly default so most people never touch the label field. */
function guessDeviceName(): string {
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return "iPhone";
if (/iPad/.test(ua)) return "iPad";
if (/Android/.test(ua)) return "Android phone";
if (/Macintosh/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows PC";
return "device";
}
function messageFor(err: unknown): string {
const e = err as { name?: string; message?: string };
if (e?.name === "NotAllowedError") return "Cancelled, or the prompt timed out. Try again.";
if (e?.name === "InvalidStateError") return "This device is already enrolled — just sign in.";
if (e?.name === "SecurityError") {
return "The browser refused this origin. Passkeys need the exact https hostname the daemon was configured with.";
}
return e?.message ?? "Something went wrong.";
}
export function Gate({ gate, onSignedIn }: { gate: GateInfo; onSignedIn: () => void }) {
const wantsEnroll = new URLSearchParams(location.search).has("enroll");
const [enrolling, setEnrolling] = useState(!gate.enrolled || wantsEnroll);
const [code, setCode] = useState("");
const [label, setLabel] = useState(guessDeviceName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const supported = browserSupportsWebAuthn();
// The commonest setup failure by far: the page was opened on a hostname the RP ID does not
// cover, e.g. the LAN IP instead of the tailnet name. Say so before the prompt fails.
const hostMismatch =
!!gate.rpId &&
location.hostname !== gate.rpId &&
!location.hostname.endsWith(`.${gate.rpId}`) &&
location.hostname !== "localhost";
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
onSignedIn();
} catch (err) {
setError(messageFor(err));
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto flex min-h-dvh w-full max-w-sm flex-col justify-center gap-5 px-5 py-10">
<header className="flex flex-col items-center gap-3 text-center">
<span className="rounded-2xl border border-border p-3 text-foreground">
<LockIcon className="h-6 w-6" />
</span>
<div>
<h1 className="text-xl font-semibold tracking-tight">grok-glance</h1>
<p className="mt-1 text-sm text-muted">
Only enrolled devices get past this screen.
</p>
</div>
</header>
{!supported && (
<Alert status="danger">
<Alert.Content>
<Alert.Title>This browser cannot do passkeys</Alert.Title>
<Alert.Description>
Open the dashboard in Safari or Chrome over https.
</Alert.Description>
</Alert.Content>
</Alert>
)}
{hostMismatch && (
<Alert status="warning">
<Alert.Content>
<Alert.Title>Wrong hostname for this passkey</Alert.Title>
<Alert.Description>
You are on {location.hostname}, but the daemon expects {gate.rpId}. Open that
hostname instead, or run{" "}
<code className="font-mono text-xs">glance set-origin</code>.
</Alert.Description>
</Alert.Content>
</Alert>
)}
{error && (
<Alert status="danger">
<Alert.Content>
<Alert.Title>{error}</Alert.Title>
</Alert.Content>
</Alert>
)}
{enrolling ? (
<Card>
<Card.Header>
<Card.Title>Enrol this device</Card.Title>
<Card.Description>
Run <code className="font-mono text-xs">glance enroll</code> on the machine running
Grok Build, then type the code it prints.
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Enrolment code</span>
<Input
value={code}
onChange={(event) => setCode(event.target.value.toUpperCase())}
placeholder="ABCD2345"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
inputMode="text"
maxLength={12}
aria-label="Enrolment code"
className="font-mono tracking-[0.25em]"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Name this device</span>
<Input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="iPhone"
maxLength={40}
aria-label="Device name"
/>
</label>
</Card.Content>
<Card.Footer className="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
fullWidth
isDisabled={busy || !supported || code.trim().length < 4}
onPress={() => run(() => api.enroll(code, label))}
>
{busy ? <Spinner size="sm" color="current" /> : <FingerprintIcon />}
Create passkey
</Button>
{gate.enrolled && (
<Button variant="ghost" size="md" fullWidth onPress={() => setEnrolling(false)}>
I already have a passkey
</Button>
)}
</Card.Footer>
</Card>
) : (
<Card>
<Card.Header>
<Card.Title>Unlock</Card.Title>
<Card.Description>Use the passkey on this device.</Card.Description>
</Card.Header>
<Card.Footer className="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
fullWidth
isDisabled={busy || !supported}
onPress={() => run(() => api.signIn())}
>
{busy ? <Spinner size="sm" color="current" /> : <FingerprintIcon />}
Unlock with passkey
</Button>
<Button variant="ghost" size="md" fullWidth onPress={() => setEnrolling(true)}>
Enrol a new device
</Button>
</Card.Footer>
</Card>
)}
<p className="text-center text-xs text-muted">
grok-glance {gate.version}
{gate.rpId ? ` · ${gate.rpId}` : ""}
</p>
</main>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { Card, Spinner } from "@heroui/react";
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;
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.Description className="truncate text-xs">{session.cwd}</Card.Description>
</div>
<StateChip state={session.state} />
</div>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
{session.lastPrompt && (
<div>
<p className="text-[11px] font-medium tracking-wide text-muted uppercase">
Last asked
</p>
<p className="mt-0.5 text-sm leading-snug break-words">{session.lastPrompt}</p>
</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>
</div>
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
</div>
</div>
) : (
<p className="text-sm text-muted">
Nothing running · last activity {relTime(session.lastActivity, now)}
</p>
)}
</Card.Content>
<Card.Footer>
<dl className="grid w-full grid-cols-3 gap-2 text-center">
<Stat label="tools" value={session.counts.tools} />
<Stat label="failed" value={session.counts.failures} tone={session.counts.failures > 0} />
<Stat label="denied" value={session.counts.denials} tone={session.counts.denials > 0} />
</dl>
</Card.Footer>
</Card>
);
}
function Stat({ label, value, tone }: { label: string; value: number; tone?: boolean }) {
return (
<div className="rounded-lg bg-surface-secondary py-2">
<dd className={`text-lg leading-none font-semibold tabular-nums ${tone ? "text-danger" : ""}`}>
{value}
</dd>
<dt className="mt-1 text-[11px] tracking-wide text-muted uppercase">{label}</dt>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Button, Card } from "@heroui/react";
import { BanIcon, CheckIcon } from "@/components/icons";
import { ToolChip } from "@/components/StatusChip";
import { secondsLeft } from "@/lib/format";
import type { PendingApproval } from "@/protocol";
export function PendingCard({
approval,
now,
busy,
onResolve,
}: {
approval: PendingApproval;
now: number;
busy: boolean;
onResolve: (id: string, decision: "allow" | "deny") => void;
}) {
const left = secondsLeft(approval.expiresAt, now);
const total = Math.max(1, approval.expiresAt - approval.createdAt);
const remaining = Math.max(0, Math.min(1, (approval.expiresAt - now) / total));
return (
<Card className="border-warning/60">
<Card.Header>
<div className="flex items-center justify-between gap-2">
<Card.Title className="text-base">Waiting on you</Card.Title>
<span className="text-xs tabular-nums text-muted">{left}s</span>
</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>
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-2">
<p className="text-sm leading-snug break-words">{approval.title}</p>
{approval.detail && (
<pre className="max-h-32 overflow-auto rounded-lg bg-surface-secondary p-2.5 font-mono text-xs leading-relaxed whitespace-pre-wrap break-all text-surface-secondary-foreground">
{approval.detail}
</pre>
)}
{/* A bar rather than only a number: you can see at a glance how much time is left. */}
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-secondary">
<div
className="h-full rounded-full bg-warning transition-[width] duration-1000 ease-linear"
style={{ width: `${remaining * 100}%` }}
/>
</div>
</Card.Content>
<Card.Footer className="grid grid-cols-2 gap-2">
<Button
variant="danger-soft"
size="lg"
isDisabled={busy}
onPress={() => onResolve(approval.id, "deny")}
>
<BanIcon />
Deny
</Button>
<Button
variant="primary"
size="lg"
isDisabled={busy}
onPress={() => onResolve(approval.id, "allow")}
>
<CheckIcon />
Approve
</Button>
</Card.Footer>
</Card>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { ReactNode } from "react";
import { Card } from "@heroui/react";
import { StateChip } from "@/components/StatusChip";
import { 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.
*/
export function SessionsCard({
sessions,
selectedId,
onSelect,
now,
}: {
sessions: SessionView[];
selectedId: string | null;
onSelect: (id: string | null) => void;
now: number;
}) {
return (
<Card>
<Card.Header>
<Card.Title className="text-base">Sessions</Card.Title>
<Card.Description className="text-xs">
Tap one to filter the activity list.
</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="text-xs text-muted">{sessions.length}</span>
</Row>
</li>
{sessions.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} />
</Row>
</li>
))}
</ul>
</Card.Content>
</Card>
);
}
function Row({
active,
onPress,
children,
}: {
active: boolean;
onPress: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onPress}
aria-pressed={active}
className={`flex w-full items-center gap-2 px-4 py-3 text-left transition-colors ${
active ? "bg-surface-secondary" : "hover:bg-surface-hover"
}`}
>
{children}
</button>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useState } from "react";
import { Alert, Button, Card } from "@heroui/react";
import { api } from "@/lib/api";
import { MoonIcon, SunIcon } from "@/components/icons";
import type { ApprovalMode, ApprovalSettings, DeviceInfo } from "@/protocol";
type Patch = Parameters<typeof api.setApproval>[0];
const MODES: { value: ApprovalMode; label: string; hint: string }[] = [
{ value: "off", label: "Off", hint: "Grok Build never waits for you." },
{ value: "risky", label: "Risky", hint: "Shell commands and file writes need a tap." },
{ value: "all", label: "All", hint: "Every tool call needs a tap. Noisy." },
];
export function SettingsPanel({
approval,
deviceLabel,
version,
theme,
onToggleTheme,
onSignedOut,
}: {
approval: ApprovalSettings;
deviceLabel?: string;
version: string;
theme: "light" | "dark";
onToggleTheme: () => void;
onSignedOut: () => void;
}) {
const [local, setLocal] = useState(approval);
const [error, setError] = useState<string | null>(null);
const [devices, setDevices] = useState<DeviceInfo[] | null>(null);
const [currentId, setCurrentId] = useState<string>("");
// Keep in step with the stream: another device may have changed the policy.
useEffect(() => setLocal(approval), [approval]);
useEffect(() => {
api.devices().then(
(out) => {
setDevices(out.devices);
setCurrentId(out.current);
},
() => setDevices([]),
);
}, []);
async function apply(patch: Patch) {
setError(null);
const previous = local;
setLocal({ ...local, ...patch });
try {
setLocal(await api.setApproval(patch));
} catch (err) {
setLocal(previous);
setError((err as Error).message);
}
}
const hint = MODES.find((m) => m.value === local.mode)?.hint;
return (
<div className="flex flex-col gap-3">
{error && (
<Alert status="danger">
<Alert.Content>
<Alert.Title>{error}</Alert.Title>
</Alert.Content>
</Alert>
)}
<Card>
<Card.Header>
<Card.Title className="text-base">Remote approval</Card.Title>
<Card.Description className="text-xs">
Which tool calls should pause and wait for a tap on this phone.
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2">
{MODES.map((mode) => (
<Button
key={mode.value}
size="md"
variant={local.mode === mode.value ? "primary" : "outline"}
onPress={() => apply({ mode: mode.value })}
>
{mode.label}
</Button>
))}
</div>
{hint && <p className="text-xs text-muted">{hint}</p>}
<Toggle
label="Only when a phone is watching"
hint="Off means a tool call can wait even with nobody looking at this page."
value={local.requireWatcher}
onChange={(value) => apply({ requireWatcher: value })}
/>
<Toggle
label="Deny if nobody answers"
hint={`Otherwise it is allowed after ${Math.round(local.timeoutMs / 1000)}s.`}
value={local.onTimeout === "deny"}
onChange={(value) => apply({ onTimeout: value ? "deny" : "allow" })}
/>
{local.mode !== "off" && (
<p className="font-mono text-[11px] break-all text-muted">
risky pattern: {local.riskyPattern}
</p>
)}
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title className="text-base">Devices</Card.Title>
<Card.Description className="text-xs">
Revoke from the terminal with <code className="font-mono">glance revoke &lt;id&gt;</code>
.
</Card.Description>
</Card.Header>
<Card.Content className="px-0">
{devices === null ? (
<p className="px-4 text-sm text-muted">Loading</p>
) : (
<ul className="flex flex-col">
{devices.map((device) => (
<li
key={device.id}
className="flex items-center justify-between gap-2 border-t border-separator px-4 py-2.5 first:border-t-0"
>
<div className="min-w-0">
<p className="truncate text-sm">
{device.label}
{device.id === currentId && (
<span className="ml-1.5 text-[11px] text-muted">(this one)</span>
)}
</p>
<p className="font-mono text-[11px] text-muted">{device.id.slice(0, 16)}</p>
</div>
<span className="shrink-0 text-[11px] text-muted">
{new Date(device.createdAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
</Card.Content>
</Card>
<Card>
<Card.Content className="flex flex-col gap-2">
<Button variant="outline" size="md" fullWidth onPress={onToggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</Button>
<Button
variant="danger-soft"
size="md"
fullWidth
onPress={() => api.logout().then(onSignedOut, onSignedOut)}
>
Sign out {deviceLabel ? `(${deviceLabel})` : ""}
</Button>
<p className="pt-1 text-center text-xs text-muted">grok-glance {version}</p>
</Card.Content>
</Card>
</div>
);
}
/**
* A two-state row built from a Button rather than a switch: the whole row is a large tap
* target, which matters more on a phone than the affordance of a sliding thumb.
*/
function Toggle({
label,
hint,
value,
onChange,
}: {
label: string;
hint: string;
value: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm">{label}</p>
<p className="text-xs text-muted">{hint}</p>
</div>
<Button
size="sm"
variant={value ? "primary" : "outline"}
onPress={() => onChange(!value)}
aria-pressed={value}
>
{value ? "On" : "Off"}
</Button>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Chip } from "@heroui/react";
import type { SessionState } from "@/protocol";
type ChipColor = "accent" | "danger" | "default" | "success" | "warning";
const STATE: Record<SessionState, { color: ChipColor; label: string }> = {
working: { color: "accent", label: "working" },
waiting: { color: "warning", label: "waiting on you" },
idle: { color: "success", label: "idle" },
error: { color: "danger", label: "error" },
ended: { color: "default", label: "ended" },
};
export function StateChip({ state, size = "sm" }: { state: SessionState; size?: "sm" | "md" }) {
const { color, label } = STATE[state];
return (
<Chip color={color} size={size} variant="soft">
<Chip.Label>{label}</Chip.Label>
</Chip>
);
}
export function ToolChip({ tool }: { tool: string }) {
return (
<Chip color="default" size="sm" variant="tertiary">
<Chip.Label>{tool}</Chip.Label>
</Chip>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import { Button, Card } from "@heroui/react";
import { clockTime, duration } from "@/lib/format";
import type { EventKind, GlanceEvent } from "@/protocol";
const DOT: Record<EventKind, string> = {
session_start: "bg-muted",
session_end: "bg-muted",
prompt: "bg-accent",
tool_start: "bg-muted",
tool_end: "bg-success",
tool_fail: "bg-danger",
permission_denied: "bg-danger",
turn_end: "bg-accent",
turn_error: "bg-danger",
notification: "bg-warning",
subagent_start: "bg-muted",
subagent_end: "bg-muted",
compact: "bg-muted",
approval_request: "bg-warning",
approval_allowed: "bg-success",
approval_denied: "bg-danger",
approval_expired: "bg-warning",
};
const PAGE = 40;
/**
* PreToolUse rows are hidden: PostToolUse reports the same call with a duration, and the
* running one is already the headline of the Now card. Showing both doubles every line.
*/
function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[] {
return events.filter(
(event) =>
event.kind !== "tool_start" && (sessionId === null || event.sessionId === sessionId),
);
}
export function Timeline({
events,
sessionId,
}: {
events: GlanceEvent[];
sessionId: string | null;
}) {
const [limit, setLimit] = useState(PAGE);
const rows = visible(events, sessionId);
const shown = rows.slice(0, limit);
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`}
</Card.Description>
</Card.Header>
<Card.Content className="px-0">
<ol className="flex flex-col">
{shown.map((event) => (
<li
key={event.id}
className="flex gap-2.5 border-t border-separator px-4 py-2.5 first:border-t-0"
>
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT[event.kind] ?? "bg-muted"}`}
aria-hidden="true"
/>
<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">
{clockTime(event.ts)}
</span>
</div>
{event.detail && (
<p className="mt-0.5 font-mono text-[11px] leading-relaxed break-all text-muted">
{event.detail}
</p>
)}
{event.durationMs !== undefined && (
<p className="mt-0.5 text-[11px] tabular-nums text-muted">
took {duration(event.durationMs)}
</p>
)}
</div>
</li>
))}
</ol>
</Card.Content>
{rows.length > shown.length && (
<Card.Footer>
<Button variant="ghost" size="sm" fullWidth onPress={() => setLimit(limit + PAGE)}>
Show {Math.min(PAGE, rows.length - shown.length)} older
</Button>
</Card.Footer>
)}
</Card>
);
}
+130
View File
@@ -0,0 +1,130 @@
/** Hand-rolled icons: no icon package, so the bundle stays small and offline-safe. */
interface IconProps {
className?: string;
}
const base = "h-4 w-4 shrink-0";
export function LockIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<rect x="4" y="10.5" width="16" height="10.5" rx="2.5" />
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</svg>
);
}
export function FingerprintIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 3a9 9 0 0 0-9 9" />
<path d="M21 12a9 9 0 0 0-9-9" />
<path d="M12 7a5 5 0 0 0-5 5v3" />
<path d="M17 12a5 5 0 0 0-5-5" />
<path d="M12 11a1.5 1.5 0 0 0-1.5 1.5V19" />
<path d="M13.5 12.5A1.5 1.5 0 0 0 12 11" />
<path d="M16.5 15.5V12" />
<path d="M7 19.5v-1" />
</svg>
);
}
export function GearIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 3v2.2M12 18.8V21M4.2 7.5l1.9 1.1M17.9 15.4l1.9 1.1M4.2 16.5l1.9-1.1M17.9 8.6l1.9-1.1" />
</svg>
);
}
export function CheckIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
}
export function BanIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.5" />
<path d="M6.2 17.8 17.8 6.2" />
</svg>
);
}
export function SunIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
export function MoonIcon({ className }: IconProps) {
return (
<svg
className={className ?? base}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a7 7 0 1 0 10.5 10.5Z" />
</svg>
);
}
+98
View File
@@ -0,0 +1,98 @@
import type {
ApprovalMode,
ApprovalSettings,
DeviceInfo,
GateInfo,
Snapshot,
} from "@/protocol";
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from "@simplewebauthn/browser";
import { startAuthentication, startRegistration } from "@simplewebauthn/browser";
/**
* The daemon rejects any POST without this header. A cross-origin page cannot set it without
* a CORS preflight that we never answer, so it is a second barrier behind the SameSite cookie.
*/
const POST_HEADERS = {
"content-type": "application/json",
"x-glance-csrf": "1",
};
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: "same-origin", ...init });
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = null;
}
if (!res.ok) {
const message =
(body as { error?: string } | null)?.error ?? `${res.status} ${res.statusText}`;
throw new ApiError(message, res.status);
}
return body as T;
}
function post<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: "POST",
headers: POST_HEADERS,
body: JSON.stringify(body ?? {}),
});
}
export const api = {
gate: () => request<GateInfo>("/api/gate"),
snapshot: () => request<Snapshot>("/api/snapshot"),
devices: () => request<{ devices: DeviceInfo[]; current: string }>("/api/devices"),
logout: () => post<{ ok: true }>("/api/auth/logout"),
resolveApproval: (id: string, decision: "allow" | "deny") =>
post<{ ok: true }>("/api/approvals/resolve", { id, decision }),
setApproval: (patch: {
mode?: ApprovalMode;
requireWatcher?: boolean;
onTimeout?: "allow" | "deny";
}) => post<ApprovalSettings>("/api/approval", patch),
/**
* Sign in with an already-enrolled passkey. The device proves itself with a biometric or
* PIN; we never see or store anything the phone could not re-derive.
*/
async signIn(): Promise<string | undefined> {
const optionsJSON = await post<PublicKeyCredentialRequestOptionsJSON>(
"/api/auth/login/options",
);
const response = await startAuthentication({ optionsJSON });
const out = await post<{ ok: true; label?: string }>("/api/auth/login/verify", { response });
return out.label;
},
/**
* Enrol this device using a one-time code from `glance enroll`. The code is checked twice —
* once to get options, once to accept the attestation — and only consumed on success.
*/
async enroll(code: string, label: string): Promise<void> {
const optionsJSON = await post<PublicKeyCredentialCreationOptionsJSON>(
"/api/auth/register/options",
{ code },
);
const response = await startRegistration({ optionsJSON });
await post<{ ok: true }>("/api/auth/register/verify", { code, label, response });
},
};
+30
View File
@@ -0,0 +1,30 @@
/** Time formatting for a screen you look at for three seconds. */
export function relTime(ts: number, now: number): string {
const delta = Math.max(0, now - ts);
const s = Math.round(delta / 1000);
if (s < 5) return "now";
if (s < 60) return `${s}s ago`;
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.round(h / 24)}d ago`;
}
export function clockTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function duration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}
/** Seconds left, floored at zero, for an approval countdown. */
export function secondsLeft(expiresAt: number, now: number): number {
return Math.max(0, Math.ceil((expiresAt - now) / 1000));
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import type { Snapshot } from "@/protocol";
export type Connection = "connecting" | "live" | "offline";
/** Backoff caps out quickly: a phone coming out of sleep should reconnect, not sulk. */
const RETRY_MS = [500, 1000, 2000, 4000, 8000, 15_000];
/**
* Subscribes to the daemon's event stream and keeps the latest full snapshot.
*
* The server sends whole snapshots rather than deltas, so a phone that slept through twenty
* events still lands on the truth with no reconciliation logic here.
*/
export function useGlance(enabled: boolean) {
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [connection, setConnection] = useState<Connection>("connecting");
const attempt = useRef(0);
useEffect(() => {
if (!enabled) {
setSnapshot(null);
setConnection("connecting");
return;
}
let stopped = false;
let source: EventSource | null = null;
let timer: number | undefined;
const schedule = () => {
if (stopped) return;
const wait = RETRY_MS[Math.min(attempt.current, RETRY_MS.length - 1)];
attempt.current += 1;
timer = window.setTimeout(open, wait);
};
const open = () => {
if (stopped) return;
// Fetch once alongside the stream so the first paint does not wait on the SSE handshake.
api.snapshot().then(
(snap) => {
if (!stopped) setSnapshot(snap);
},
() => {
/* the stream will report the real problem */
},
);
source = new EventSource("/events", { withCredentials: true });
source.addEventListener("open", () => {
if (stopped) return;
attempt.current = 0;
setConnection("live");
});
source.addEventListener("snapshot", (event) => {
if (stopped) return;
try {
setSnapshot(JSON.parse((event as MessageEvent<string>).data) as Snapshot);
setConnection("live");
} catch {
/* ignore a malformed frame rather than tearing down the stream */
}
});
// The daemon says goodbye on shutdown; reconnecting will pick it up when it returns.
source.addEventListener("bye", () => {
source?.close();
setConnection("offline");
schedule();
});
source.addEventListener("error", () => {
source?.close();
source = null;
if (stopped) return;
setConnection("offline");
schedule();
});
};
open();
// iOS suspends the stream in the background; nudge it the moment the app is looked at.
const onVisible = () => {
if (document.visibilityState !== "visible") return;
if (source && source.readyState === EventSource.OPEN) {
api.snapshot().then(setSnapshot, () => {});
return;
}
source?.close();
source = null;
attempt.current = 0;
if (timer) window.clearTimeout(timer);
open();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
stopped = true;
document.removeEventListener("visibilitychange", onVisible);
if (timer) window.clearTimeout(timer);
source?.close();
};
}, [enabled]);
return { snapshot, connection };
}
/** A ticking clock, for countdowns and "3s ago" labels. */
export function useNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(id);
}, [intervalMs]);
return now;
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "@/App";
import "@/styles/globals.css";
// HeroUI v3 needs no provider — its components carry their own state. Theme handling lives in
// the library's own `useTheme` hook, which App calls.
const host = document.getElementById("root");
if (!host) throw new Error("missing #root");
createRoot(host).render(
<StrictMode>
<App />
</StrictMode>,
);
+107
View File
@@ -0,0 +1,107 @@
/**
* 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.
*/
export type EventKind =
| "session_start"
| "session_end"
| "prompt"
| "tool_start"
| "tool_end"
| "tool_fail"
| "permission_denied"
| "turn_end"
| "turn_error"
| "notification"
| "subagent_start"
| "subagent_end"
| "compact"
| "approval_request"
| "approval_allowed"
| "approval_denied"
| "approval_expired";
export type SessionState = "working" | "idle" | "waiting" | "error" | "ended";
export interface GlanceEvent {
id: number;
ts: number;
sessionId: string;
kind: EventKind;
/** Tool name, for tool-shaped events. */
tool?: string;
/** One-line human summary, already truncated and redacted. */
title: string;
/** Optional second line, e.g. a file path or an error message. */
detail?: string;
durationMs?: number;
}
export interface SessionView {
id: string;
/** Basename of the workspace root — what you actually recognise on a phone. */
label: string;
cwd: string;
state: SessionState;
startedAt: number;
lastActivity: number;
lastPrompt?: string;
currentTool?: { name: string; title: string; startedAt: number };
counts: { tools: number; failures: number; denials: number };
}
export interface PendingApproval {
id: string;
sessionId: string;
sessionLabel: string;
tool: string;
title: string;
detail?: string;
createdAt: number;
expiresAt: number;
}
export type ApprovalMode = "off" | "risky" | "all";
export interface ApprovalSettings {
mode: ApprovalMode;
riskyPattern: string;
timeoutMs: number;
/** Skip gating entirely when no browser is streaming, so an unwatched agent never stalls. */
requireWatcher: boolean;
/** What to do when nobody answers in time. Allow keeps the agent moving; deny is stricter. */
onTimeout: "allow" | "deny";
}
export interface Snapshot {
now: number;
version: string;
sessions: SessionView[];
events: GlanceEvent[];
pending: PendingApproval[];
approval: ApprovalSettings;
}
export interface DeviceInfo {
id: string;
label: string;
createdAt: number;
lastUsedAt?: number;
}
/** Everything the app needs before it knows whether you are signed in. */
export interface GateInfo {
authenticated: boolean;
/** False when no passkey has been enrolled yet — the app then asks for an enrolment code. */
enrolled: boolean;
/** True while a one-time enrolment code minted by `glance enroll` is still valid. */
enrollmentOpen: boolean;
version: string;
deviceLabel?: string;
/** The WebAuthn RP ID in force. Shown so a hostname mismatch is diagnosable from the phone. */
rpId?: string;
}
+44
View File
@@ -0,0 +1,44 @@
@import "tailwindcss";
@import "@heroui/styles";
@custom-variant dark (&:is(.dark *));
/* A dashboard you read one-handed: no rubber-band scroll surprises, no text inflation. */
html {
-webkit-text-size-adjust: 100%;
/* useTheme sets the class and data-theme but not color-scheme, and without it the phone
paints native scrollbars and form controls light on a dark page. */
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
min-height: 100dvh;
overscroll-behavior-y: none;
}
/* Keep the timeline scrollable without a visible scrollbar eating width on mobile. */
.glance-scroll {
scrollbar-width: thin;
}
.glance-scroll::-webkit-scrollbar {
width: 6px;
}
.glance-scroll::-webkit-scrollbar-thumb {
border-radius: 3px;
background: color-mix(in oklab, currentColor 20%, transparent);
}
/* Respect a user who has asked the OS to calm things down. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />