first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <id></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user