grok-glance: web control plane for grok's /rc remote control
A single Go binary that grok dials out to over a WebSocket, and a HeroUI web UI for driving the session it is attached to. The roles are inverted relative to the terminal: over the /rc link grok is the ACP Agent and glance is the Client. That makes glance a stock ACP client and the web Stop button a real session/cancel rather than a bespoke control message. Both notification rails are mirrored. The stable session/update rail carries correctness; x.ai/session_notification is presentation only and degrades rather than erroring, because its ~60 variants are grok internal and drift with every upstream sync. _meta is forwarded byte for byte so viewers can dedup and order. Permissions race: the terminal and any browser may answer, first responder wins, and the loser's UI retracts by itself. All three interaction methods go through that path, not just permissions. Auth is TOTP only, with no accounts to have. A bootstrap token printed at first start gates /setup, which is a 404 without it; state lives in one 0600 JSON file and history in an in-memory ring, so there is no database and no recovery story beyond deleting the file. ARCHITECTURE.md covers the topology and the limits of that auth model; CLAUDE.md covers building, the fakeagent loop, and the end-to-end checklist that unit tests cannot replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Vendored
@@ -0,0 +1,33 @@
|
||||
// Package web carries the built frontend into the binary.
|
||||
//
|
||||
// The embed directive points at `dist/`, which is Vite's output. That directory
|
||||
// is checked in with only a `.gitkeep` so a clean clone still compiles: Go
|
||||
// resolves `//go:embed` at build time and would fail outright on a missing path,
|
||||
// which would mean `go build ./...` could not run until someone had installed
|
||||
// npm. Assets returns an error in that state instead, and the server falls back
|
||||
// to a placeholder page telling the operator to run `make web`.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embedded embed.FS
|
||||
|
||||
// ErrNotBuilt means the binary was built without running the frontend build.
|
||||
var ErrNotBuilt = errors.New("web UI not built; run `make web`")
|
||||
|
||||
// Assets returns the frontend rooted at index.html.
|
||||
func Assets() (fs.FS, error) {
|
||||
dist, err := fs.Sub(embedded, "dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fs.Stat(dist, "index.html"); err != nil {
|
||||
return nil, ErrNotBuilt
|
||||
}
|
||||
return dist, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!--
|
||||
No favicon link: the server serves only what Vite emits, and a missing
|
||||
/favicon.ico would fall through to index.html and be logged as a failed
|
||||
image decode on every load.
|
||||
-->
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>grok-glance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2440
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "grok-glance-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "3.2.4",
|
||||
"@react-aria/i18n": "^3.13.1",
|
||||
"@react-aria/ssr": "^3.10.1",
|
||||
"@react-aria/utils": "^3.34.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-aria": "^3.51.0",
|
||||
"react-aria-components": "^1.20.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, Spinner, useTheme } from "@heroui/react";
|
||||
import { ApiError, api, type AgentSummary, type Status } from "./lib/api";
|
||||
import {
|
||||
applyFrame,
|
||||
applyFrames,
|
||||
appendNotice,
|
||||
emptyTranscript,
|
||||
type Transcript,
|
||||
} from "./lib/acp";
|
||||
import {
|
||||
GlanceSocket,
|
||||
interactionKey,
|
||||
type ConnectionState,
|
||||
type Interaction,
|
||||
type ServerEvent,
|
||||
} from "./lib/ws";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Session } from "./pages/Session";
|
||||
import { Sessions } from "./pages/Sessions";
|
||||
import { Setup } from "./pages/Setup";
|
||||
|
||||
/** `#/a/<agent-id>` selects a session; anything else is the list. */
|
||||
function agentFromHash(): string | null {
|
||||
const match = /^#\/a\/(.+)$/.exec(window.location.hash);
|
||||
const id = match?.[1];
|
||||
return id ? decodeURIComponent(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in application: one socket, one agent list, one open session.
|
||||
*
|
||||
* The socket is owned here rather than in the session page so that navigating
|
||||
* between the list and a session neither drops the connection nor re-requests a
|
||||
* snapshot — and so the agent list keeps updating while a session is open.
|
||||
*/
|
||||
function Console({ onSignedOut }: { onSignedOut: () => void }) {
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(() => agentFromHash());
|
||||
const [transcript, setTranscript] = useState<Transcript>(() => emptyTranscript());
|
||||
const [interactions, setInteractions] = useState<Interaction[]>([]);
|
||||
|
||||
const socket = useRef<GlanceSocket | null>(null);
|
||||
|
||||
// Latest-value ref: the socket's handlers are installed once, but they have to
|
||||
// test events against whichever session is open *now*.
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setSelected(agentFromHash());
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onEvent = (event: ServerEvent) => {
|
||||
switch (event.type) {
|
||||
case "agents":
|
||||
setAgents(event.agents ?? []);
|
||||
break;
|
||||
|
||||
case "snapshot": {
|
||||
if (event.agents) setAgents(event.agents);
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
// A snapshot is authoritative: it replaces local state rather than
|
||||
// merging into it, which is what makes a reload or a reconnect land
|
||||
// on exactly the server's view instead of a half-stale one.
|
||||
setTranscript(
|
||||
applyFrames(
|
||||
emptyTranscript(event.dropped ?? 0, event.turnActive ?? false),
|
||||
event.frames ?? [],
|
||||
),
|
||||
);
|
||||
setInteractions(event.open ?? []);
|
||||
break;
|
||||
}
|
||||
|
||||
case "frame":
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
setTranscript((current) => applyFrame(current, event.frame));
|
||||
break;
|
||||
|
||||
case "interaction": {
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
const key = interactionKey(event.interaction.id);
|
||||
setInteractions((current) =>
|
||||
current.some((item) => interactionKey(item.id) === key)
|
||||
? current
|
||||
: [...current, event.interaction],
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "interaction_resolved": {
|
||||
if (event.agent !== selectedRef.current) break;
|
||||
const resolved = event.id;
|
||||
setInteractions((current) =>
|
||||
current.filter((item) => interactionKey(item.id) !== resolved),
|
||||
);
|
||||
// Losing the race is normal, not an error — but it must be visible,
|
||||
// or a card vanishing under your finger looks like a bug.
|
||||
const message = event.message;
|
||||
if (event.by === "elsewhere" && message) {
|
||||
setTranscript((current) => appendNotice(current, message));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "notice":
|
||||
if (event.agent && event.agent !== selectedRef.current) break;
|
||||
setTranscript((current) => appendNotice(current, event.message));
|
||||
break;
|
||||
|
||||
case "error":
|
||||
setTranscript((current) => appendNotice(current, event.message));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onState = (state: ConnectionState) => {
|
||||
setConnection(state);
|
||||
if (state !== "closed") return;
|
||||
// A rejected upgrade and an unreachable server close identically, so the
|
||||
// only honest way to tell an expired session from a restart is to ask.
|
||||
api
|
||||
.status()
|
||||
.then((status) => {
|
||||
if (!status.authenticated) onSignedOut();
|
||||
})
|
||||
.catch(() => {
|
||||
/* server is down; the socket's own backoff handles it */
|
||||
});
|
||||
};
|
||||
|
||||
const instance = new GlanceSocket({ onEvent, onState });
|
||||
socket.current = instance;
|
||||
instance.start();
|
||||
return () => {
|
||||
instance.stop();
|
||||
socket.current = null;
|
||||
};
|
||||
}, [onSignedOut]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
socket.current?.clearSubscription();
|
||||
return;
|
||||
}
|
||||
setTranscript(emptyTranscript());
|
||||
setInteractions([]);
|
||||
socket.current?.subscribe(selected);
|
||||
}, [selected]);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
void api.logout().finally(onSignedOut);
|
||||
}, [onSignedOut]);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<Sessions
|
||||
agents={agents}
|
||||
connection={connection}
|
||||
onOpen={(id) => {
|
||||
window.location.hash = `#/a/${encodeURIComponent(id)}`;
|
||||
}}
|
||||
onSignOut={signOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const agent = agents.find((candidate) => candidate.id === selected);
|
||||
|
||||
return (
|
||||
<Session
|
||||
agent={agent}
|
||||
transcript={transcript}
|
||||
interactions={interactions}
|
||||
connection={connection}
|
||||
onBack={() => {
|
||||
window.location.hash = "#/";
|
||||
}}
|
||||
onPrompt={(text) => socket.current?.send({ type: "prompt", agent: selected, text })}
|
||||
onCancel={() => socket.current?.send({ type: "cancel", agent: selected })}
|
||||
onAnswer={(interaction, result) =>
|
||||
socket.current?.send({
|
||||
type: "answer",
|
||||
agent: selected,
|
||||
id: interactionKey(interaction.id),
|
||||
result,
|
||||
})
|
||||
}
|
||||
onDecline={(interaction, reason) =>
|
||||
socket.current?.send({
|
||||
type: "decline",
|
||||
agent: selected,
|
||||
id: interactionKey(interaction.id),
|
||||
reason,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// Called once, at the root: each call owns its own state, so a second call
|
||||
// elsewhere would give a toggle that only half the app agrees with. "system"
|
||||
// means the page follows the OS, which is the right default for something
|
||||
// read on a phone at night.
|
||||
useTheme("system");
|
||||
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [fatal, setFatal] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
api
|
||||
.status()
|
||||
.then((next) => {
|
||||
setStatus(next);
|
||||
setFatal(null);
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
setFatal(cause instanceof ApiError ? cause.message : "could not reach the glance server");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(refresh, [refresh]);
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center p-4">
|
||||
<div className="max-w-sm w-full space-y-3">
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Title>Cannot reach glance</Alert.Title>
|
||||
<Alert.Description>{fatal}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
<Button fullWidth variant="outline" onPress={refresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
<Spinner color="accent" aria-label="loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status.enrolled) return <Setup onDone={refresh} />;
|
||||
if (!status.authenticated) return <Login onDone={refresh} />;
|
||||
return <Console onSignedOut={refresh} />;
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* The three interactions grok can block a turn on, rendered for the browser.
|
||||
*
|
||||
* These are *cards*, not modals. An interaction can be answered in the terminal
|
||||
* at any moment and will then vanish from here mid-read; a modal that steals
|
||||
* focus and then closes itself is far more jarring than a card that quietly
|
||||
* disappears from a tray. Cards also stack, which matters because more than one
|
||||
* can be open at once.
|
||||
*
|
||||
* Every response shape below is verbatim from grok's own wire types — the
|
||||
* agent deserializes into a typed struct and a near-miss is a hard error, so
|
||||
* these are not places to improvise:
|
||||
*
|
||||
* session/request_permission -> acp::RequestPermissionResponse
|
||||
* x.ai/ask_user_question -> AskUserQuestionExtResponse (tagged "outcome")
|
||||
* x.ai/exit_plan_mode -> ExitPlanModeExtResponse ({outcome, feedback?})
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Description,
|
||||
Label,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
TextArea,
|
||||
TextField,
|
||||
} from "@heroui/react";
|
||||
import {
|
||||
METHOD_ASK_USER_QUESTION,
|
||||
METHOD_EXIT_PLAN_MODE,
|
||||
METHOD_REQUEST_PERMISSION,
|
||||
type AskUserQuestionParams,
|
||||
type ExitPlanModeParams,
|
||||
type Question,
|
||||
type RequestPermissionParams,
|
||||
} from "../lib/acp";
|
||||
import type { Interaction } from "../lib/ws";
|
||||
import { ToolCall } from "./ToolCall";
|
||||
|
||||
const OPTION_VARIANT: Record<string, "primary" | "secondary" | "danger" | "danger-soft"> = {
|
||||
allow_once: "primary",
|
||||
allow_always: "secondary",
|
||||
reject_once: "danger-soft",
|
||||
reject_always: "danger",
|
||||
};
|
||||
|
||||
function isMulti(question: Question): boolean {
|
||||
return question.multiSelect ?? question.multi_select ?? false;
|
||||
}
|
||||
|
||||
function Shell({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children?: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border border-warning">
|
||||
<Card.Header>
|
||||
<Card.Title className="text-sm">{title}</Card.Title>
|
||||
{hint ? <Card.Description className="text-xs">{hint}</Card.Description> : null}
|
||||
</Card.Header>
|
||||
{children ? <Card.Content className="space-y-3">{children}</Card.Content> : null}
|
||||
<Card.Footer className="flex flex-wrap gap-2 justify-end">{footer}</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// session/request_permission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PermissionRequest({
|
||||
params,
|
||||
onAnswer,
|
||||
disabled,
|
||||
}: {
|
||||
params: RequestPermissionParams;
|
||||
onAnswer: (result: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const options = params.options ?? [];
|
||||
return (
|
||||
<Shell
|
||||
title="Permission needed"
|
||||
hint="answering here also closes the prompt in the terminal"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={disabled}
|
||||
onPress={() => onAnswer({ outcome: { outcome: "cancelled" } })}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.optionId}
|
||||
size="sm"
|
||||
variant={OPTION_VARIANT[option.kind ?? ""] ?? "outline"}
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
onAnswer({ outcome: { outcome: "selected", optionId: option.optionId } })
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{params.toolCall ? <ToolCall call={params.toolCall} /> : null}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// x.ai/exit_plan_mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ExitPlanMode({
|
||||
params,
|
||||
onAnswer,
|
||||
disabled,
|
||||
}: {
|
||||
params: ExitPlanModeParams;
|
||||
onAnswer: (result: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const trimmed = feedback.trim();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Plan ready for approval"
|
||||
hint="approve to let the agent start work, or send it back with notes"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
isDisabled={disabled}
|
||||
onPress={() => onAnswer({ outcome: "abandoned" })}
|
||||
>
|
||||
Abandon
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
// `feedback` is only meaningful on "cancelled" — that is the one
|
||||
// path where the plan comes back for another round.
|
||||
onAnswer(trimmed ? { outcome: "cancelled", feedback: trimmed } : { outcome: "cancelled" })
|
||||
}
|
||||
>
|
||||
Keep planning
|
||||
</Button>
|
||||
<Button size="sm" isDisabled={disabled} onPress={() => onAnswer({ outcome: "approved" })}>
|
||||
Approve
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{params.planContent ? (
|
||||
// The plan is markdown. Rendering it properly would mean shipping a
|
||||
// markdown parser and sanitiser for text an agent wrote; showing the
|
||||
// source keeps it faithful and keeps the attack surface at zero.
|
||||
<div className="glance-prose text-sm max-h-96 overflow-auto rounded-md bg-surface-secondary p-3">
|
||||
{params.planContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">the agent sent no plan text</div>
|
||||
)}
|
||||
|
||||
<TextField value={feedback} onChange={setFeedback} isDisabled={disabled} aria-label="feedback">
|
||||
<TextArea rows={2} placeholder="feedback (sent with “Keep planning”)" fullWidth />
|
||||
</TextField>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// x.ai/ask_user_question
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QuestionState {
|
||||
labels: string[];
|
||||
other: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
const EMPTY_ANSWER: QuestionState = { labels: [], other: false, notes: "" };
|
||||
|
||||
/**
|
||||
* Build the `accepted` payload exactly as the TUI does
|
||||
* (xai-grok-pager/src/views/question_view.rs).
|
||||
*
|
||||
* The rules that are easy to get wrong and that grok's formatter depends on:
|
||||
* unanswered questions are *omitted* rather than sent empty; the map is keyed by
|
||||
* the question text, in the original order; a freeform-only answer is the literal
|
||||
* `["Other"]` with the typed text in `annotations[q].notes`; and `preview` is
|
||||
* carried only for single-select questions.
|
||||
*/
|
||||
function buildAccepted(questions: Question[], states: QuestionState[]) {
|
||||
const answers: Record<string, string[]> = {};
|
||||
const annotations: Record<string, { preview?: string; notes?: string }> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const state = states[index] ?? EMPTY_ANSWER;
|
||||
const notes = state.other ? state.notes.trim() : "";
|
||||
const hasFreeform = state.other && notes !== "";
|
||||
if (state.labels.length === 0 && !hasFreeform) return;
|
||||
|
||||
answers[question.question] = state.labels.length > 0 ? state.labels : ["Other"];
|
||||
|
||||
const single = !isMulti(question);
|
||||
const selected = state.labels[0];
|
||||
const preview =
|
||||
single && state.labels.length === 1
|
||||
? question.options.find((option) => option.label === selected)?.preview
|
||||
: undefined;
|
||||
|
||||
if (preview || hasFreeform) {
|
||||
annotations[question.question] = {
|
||||
...(preview ? { preview } : {}),
|
||||
...(hasFreeform ? { notes } : {}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(annotations).length > 0
|
||||
? { outcome: "accepted", answers, annotations }
|
||||
: { outcome: "accepted", answers };
|
||||
}
|
||||
|
||||
/** Plan-mode paths carry label-only partials; notes are dropped by design. */
|
||||
function buildPartial(questions: Question[], states: QuestionState[]) {
|
||||
const partial: Record<string, string> = {};
|
||||
questions.forEach((question, index) => {
|
||||
const state = states[index] ?? EMPTY_ANSWER;
|
||||
const first = state.labels[0];
|
||||
if (first) partial[question.question] = first;
|
||||
else if (state.other && state.notes.trim() !== "") partial[question.question] = "Other";
|
||||
});
|
||||
return partial;
|
||||
}
|
||||
|
||||
function QuestionCard({
|
||||
question,
|
||||
state,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
question: Question;
|
||||
state: QuestionState;
|
||||
onChange: (next: QuestionState) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const multi = isMulti(question);
|
||||
const preview =
|
||||
!multi && state.labels.length === 1
|
||||
? question.options.find((option) => option.label === state.labels[0])?.preview
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{question.question}</div>
|
||||
|
||||
{multi ? (
|
||||
<CheckboxGroup
|
||||
aria-label={question.question}
|
||||
value={state.labels}
|
||||
onChange={(labels) => onChange({ ...state, labels })}
|
||||
isDisabled={disabled}
|
||||
className="gap-1"
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<Checkbox key={option.label} value={option.label}>
|
||||
<Checkbox.Content>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Label>{option.label}</Label>
|
||||
</Checkbox.Content>
|
||||
<Description>{option.description}</Description>
|
||||
</Checkbox>
|
||||
))}
|
||||
</CheckboxGroup>
|
||||
) : (
|
||||
<RadioGroup
|
||||
aria-label={question.question}
|
||||
value={state.labels[0] ?? ""}
|
||||
onChange={(label) => onChange({ ...state, labels: [label], other: false })}
|
||||
isDisabled={disabled}
|
||||
className="gap-1"
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<Radio key={option.label} value={option.label}>
|
||||
<Radio.Content>
|
||||
<Radio.Control>
|
||||
<Radio.Indicator />
|
||||
</Radio.Control>
|
||||
<Label>{option.label}</Label>
|
||||
</Radio.Content>
|
||||
<Description>{option.description}</Description>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{preview ? <pre className="glance-pre p-3 rounded-md bg-surface-secondary overflow-auto max-h-64">{preview}</pre> : null}
|
||||
|
||||
{/* "Other" is not one of the model's options — it is the escape hatch the
|
||||
TUI also offers, and grok understands it by that exact spelling. */}
|
||||
<div className="space-y-1">
|
||||
<Checkbox
|
||||
isSelected={state.other}
|
||||
onChange={(other) =>
|
||||
onChange(multi ? { ...state, other } : { ...state, other, labels: other ? [] : state.labels })
|
||||
}
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<Checkbox.Content>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Label>Other</Label>
|
||||
</Checkbox.Content>
|
||||
</Checkbox>
|
||||
|
||||
{state.other ? (
|
||||
<TextField
|
||||
value={state.notes}
|
||||
onChange={(notes) => onChange({ ...state, notes })}
|
||||
isDisabled={disabled}
|
||||
aria-label="other"
|
||||
fullWidth
|
||||
>
|
||||
<TextArea rows={2} placeholder="your answer…" fullWidth />
|
||||
</TextField>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AskUserQuestion({
|
||||
params,
|
||||
onAnswer,
|
||||
disabled,
|
||||
}: {
|
||||
params: AskUserQuestionParams;
|
||||
onAnswer: (result: unknown) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const questions = params.questions ?? [];
|
||||
const [states, setStates] = useState<QuestionState[]>(() => questions.map(() => EMPTY_ANSWER));
|
||||
const planMode = params.mode === "plan";
|
||||
|
||||
const answered = states.some(
|
||||
(state) => state.labels.length > 0 || (state.other && state.notes.trim() !== ""),
|
||||
);
|
||||
|
||||
const update = (index: number, next: QuestionState) =>
|
||||
setStates((current) => current.map((state, i) => (i === index ? next : state)));
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title={questions.length > 1 ? `${questions.length} questions` : "A question for you"}
|
||||
hint="the terminal is showing this too — whoever answers first wins"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={disabled}
|
||||
onPress={() => onAnswer({ outcome: "cancelled" })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{planMode ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
onAnswer({
|
||||
outcome: "skip_interview",
|
||||
partial_answers: buildPartial(questions, states),
|
||||
})
|
||||
}
|
||||
>
|
||||
Skip & plan
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
onAnswer({
|
||||
outcome: "chat_about_this",
|
||||
partial_answers: buildPartial(questions, states),
|
||||
})
|
||||
}
|
||||
>
|
||||
Chat about this
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
isDisabled={disabled || !answered}
|
||||
onPress={() => onAnswer(buildAccepted(questions, states))}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{questions.map((question, index) => (
|
||||
<QuestionCard
|
||||
key={question.id ?? question.question}
|
||||
question={question}
|
||||
state={states[index] ?? EMPTY_ANSWER}
|
||||
onChange={(next) => update(index, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PermissionDialog({
|
||||
interaction,
|
||||
onAnswer,
|
||||
onDecline,
|
||||
}: {
|
||||
interaction: Interaction;
|
||||
onAnswer: (result: unknown) => void;
|
||||
/** For requests this build cannot render — replies with a JSON-RPC error. */
|
||||
onDecline: (reason: string) => void;
|
||||
}) {
|
||||
// One answer per card. The card normally disappears when the server confirms,
|
||||
// but a lost race can take a moment, and a double-answer would be sent as a
|
||||
// second reply to a JSON-RPC id that is already resolved.
|
||||
const [sent, setSent] = useState(false);
|
||||
const answer = (result: unknown) => {
|
||||
if (sent) return;
|
||||
setSent(true);
|
||||
onAnswer(result);
|
||||
};
|
||||
|
||||
switch (interaction.method) {
|
||||
case METHOD_REQUEST_PERMISSION:
|
||||
return (
|
||||
<PermissionRequest
|
||||
params={(interaction.params ?? {}) as RequestPermissionParams}
|
||||
onAnswer={answer}
|
||||
disabled={sent}
|
||||
/>
|
||||
);
|
||||
|
||||
case METHOD_EXIT_PLAN_MODE:
|
||||
return (
|
||||
<ExitPlanMode
|
||||
params={(interaction.params ?? {}) as ExitPlanModeParams}
|
||||
onAnswer={answer}
|
||||
disabled={sent}
|
||||
/>
|
||||
);
|
||||
|
||||
case METHOD_ASK_USER_QUESTION:
|
||||
return (
|
||||
<AskUserQuestion
|
||||
params={(interaction.params ?? {}) as AskUserQuestionParams}
|
||||
onAnswer={answer}
|
||||
disabled={sent}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// grok's extension surface grows upstream. Guessing a response shape for
|
||||
// an unknown method would deserialize into garbage or hang the turn, so
|
||||
// this says so plainly and lets the terminal handle it.
|
||||
return (
|
||||
<Shell
|
||||
title={`Unsupported request: ${interaction.method}`}
|
||||
hint="answer this one in the terminal — this build does not know its response shape"
|
||||
footer={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={sent}
|
||||
onPress={() => {
|
||||
if (sent) return;
|
||||
setSent(true);
|
||||
onDecline("no browser UI for " + interaction.method);
|
||||
}}
|
||||
>
|
||||
Decline here
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<pre className="glance-pre p-3 rounded-md bg-surface-secondary overflow-auto max-h-64">
|
||||
{JSON.stringify(interaction.params, null, 2)}
|
||||
</pre>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { Button, TextArea, TextField } from "@heroui/react";
|
||||
|
||||
/**
|
||||
* Prompt entry and the Stop button.
|
||||
*
|
||||
* Stop is deliberately always available while a turn runs, and is not merged
|
||||
* into the send control: interrupting is the one action a remote viewer most
|
||||
* urgently needs, and hunting for it inside a disabled composer is the wrong
|
||||
* experience at exactly the wrong moment.
|
||||
*/
|
||||
export function PromptBox({
|
||||
disabled,
|
||||
turnActive,
|
||||
onSend,
|
||||
onStop,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
turnActive: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const send = () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-background">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 flex items-end gap-2">
|
||||
<TextField
|
||||
className="grow"
|
||||
value={text}
|
||||
onChange={setText}
|
||||
isDisabled={disabled}
|
||||
aria-label="prompt"
|
||||
>
|
||||
<TextArea
|
||||
rows={1}
|
||||
placeholder={disabled ? "not connected" : "send a prompt…"}
|
||||
className="max-h-40 resize-none"
|
||||
onKeyDown={(event) => {
|
||||
// Enter sends, Shift+Enter breaks the line. `isComposing` guards
|
||||
// IME input: without it, committing a Chinese or Japanese
|
||||
// candidate with Enter would fire the prompt half-typed.
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
{turnActive ? (
|
||||
<Button variant="danger-soft" onPress={onStop} isDisabled={disabled}>
|
||||
Stop
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button onPress={send} isDisabled={disabled || text.trim() === ""}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Chip, Disclosure, Spinner } from "@heroui/react";
|
||||
import type { ToolCallContent, ToolCallPayload, ToolCallStatus } from "../lib/acp";
|
||||
|
||||
const STATUS_COLOR: Record<string, "default" | "accent" | "success" | "warning" | "danger"> = {
|
||||
pending: "default",
|
||||
in_progress: "accent",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool kinds get a glyph rather than a colour: status already owns colour in
|
||||
* this row, and two colour dimensions on one line stop reading as either.
|
||||
*/
|
||||
const KIND_GLYPH: Record<string, string> = {
|
||||
read: "▤",
|
||||
edit: "✎",
|
||||
delete: "␡",
|
||||
move: "→",
|
||||
search: "⌕",
|
||||
execute: "❯",
|
||||
think: "◇",
|
||||
fetch: "↓",
|
||||
switch_mode: "⇄",
|
||||
other: "•",
|
||||
};
|
||||
|
||||
function DiffBlock({ item }: { item: ToolCallContent }) {
|
||||
const oldText = item.oldText ?? "";
|
||||
const newText = item.newText ?? "";
|
||||
return (
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<div className="px-3 py-1.5 text-xs font-medium bg-surface-secondary truncate">
|
||||
{item.path ?? "(unnamed file)"}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-border">
|
||||
{oldText ? (
|
||||
<pre className="glance-pre p-3 overflow-x-auto bg-danger-soft text-danger-soft-foreground">
|
||||
{oldText}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted">new file</div>
|
||||
)}
|
||||
<pre className="glance-pre p-3 overflow-x-auto bg-success-soft text-success-soft-foreground">
|
||||
{newText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentItem({ item }: { item: ToolCallContent }) {
|
||||
if (item.type === "diff") return <DiffBlock item={item} />;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
// Terminal content is a live handle, not data: the bridge does not mirror
|
||||
// the pty, so claiming to show output would be a lie.
|
||||
return (
|
||||
<div className="text-xs text-muted italic">
|
||||
live terminal {item.terminalId ?? ""} — output stays in the session
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = item.content;
|
||||
const text =
|
||||
block?.text ??
|
||||
block?.resource?.text ??
|
||||
(block?.type === "image"
|
||||
? "[image]"
|
||||
: block?.type === "resource_link"
|
||||
? `[${block.name ?? block.uri ?? "resource"}]`
|
||||
: "");
|
||||
if (!text) return null;
|
||||
return (
|
||||
<pre className="glance-pre p-3 rounded-md bg-surface-secondary overflow-auto max-h-96">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCall({ call }: { call: ToolCallPayload }) {
|
||||
const status: ToolCallStatus = call.status ?? "pending";
|
||||
const running = status === "pending" || status === "in_progress";
|
||||
const content = call.content ?? [];
|
||||
const glyph = KIND_GLYPH[call.kind ?? "other"] ?? KIND_GLYPH.other;
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center gap-2 min-w-0 w-full text-left">
|
||||
<span aria-hidden className="text-muted shrink-0 w-4 text-center">
|
||||
{glyph}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium">{call.title ?? call.kind ?? "tool call"}</span>
|
||||
<span className="grow" />
|
||||
{running ? (
|
||||
<Spinner size="sm" color="accent" aria-label="running" />
|
||||
) : (
|
||||
<Chip size="sm" color={STATUS_COLOR[status] ?? "default"} variant="soft">
|
||||
<Chip.Label>{status.replace("_", " ")}</Chip.Label>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasDetail = content.length > 0 || (call.locations?.length ?? 0) > 0;
|
||||
|
||||
// A tool call with nothing to show should not pretend to be expandable: an
|
||||
// empty disclosure opening onto blank space reads as a loading bug.
|
||||
if (!hasDetail) {
|
||||
return (
|
||||
<div className="px-3 py-2 rounded-lg border border-border bg-surface">{header}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Disclosure className="rounded-lg border border-border bg-surface">
|
||||
<Disclosure.Heading>
|
||||
<Disclosure.Trigger className="px-3 py-2 w-full flex items-center gap-2">
|
||||
{header}
|
||||
<Disclosure.Indicator className="shrink-0" />
|
||||
</Disclosure.Trigger>
|
||||
</Disclosure.Heading>
|
||||
<Disclosure.Content>
|
||||
<Disclosure.Body className="px-3 pb-3 space-y-2">
|
||||
{call.locations?.length ? (
|
||||
<div className="text-xs text-muted truncate">
|
||||
{call.locations.map((l) => (l.line ? `${l.path}:${l.line}` : l.path)).join(" · ")}
|
||||
</div>
|
||||
) : null}
|
||||
{content.map((item, i) => (
|
||||
<ContentItem key={i} item={item} />
|
||||
))}
|
||||
</Disclosure.Body>
|
||||
</Disclosure.Content>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Alert } from "@heroui/react";
|
||||
import type { PlanEntry, Transcript as TranscriptModel, TranscriptItem } from "../lib/acp";
|
||||
import { ToolCall } from "./ToolCall";
|
||||
|
||||
const PLAN_GLYPH: Record<string, string> = {
|
||||
pending: "○",
|
||||
in_progress: "◐",
|
||||
completed: "●",
|
||||
};
|
||||
|
||||
function PlanList({ entries }: { entries: PlanEntry[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="text-xs font-medium text-muted mb-2">plan</div>
|
||||
<ul className="space-y-1">
|
||||
{entries.map((entry, i) => {
|
||||
const done = entry.status === "completed";
|
||||
return (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span aria-hidden className="text-muted shrink-0">
|
||||
{PLAN_GLYPH[entry.status ?? "pending"] ?? "○"}
|
||||
</span>
|
||||
<span className={done ? "line-through text-muted" : undefined}>{entry.content}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ item }: { item: TranscriptItem }) {
|
||||
switch (item.kind) {
|
||||
case "user":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-lg bg-accent-soft text-accent-soft-foreground px-3 py-2 glance-prose text-sm">
|
||||
{item.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "assistant":
|
||||
return <div className="glance-prose text-sm">{item.text}</div>;
|
||||
|
||||
case "thought":
|
||||
// Thinking is dimmed rather than hidden: on a phone it is often the only
|
||||
// sign of life during a long tool-free stretch, but it must never compete
|
||||
// with the answer for attention.
|
||||
return (
|
||||
<div className="border-l-2 border-border pl-3 text-sm text-muted glance-prose italic">
|
||||
{item.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tool":
|
||||
return <ToolCall call={item.call} />;
|
||||
|
||||
case "plan":
|
||||
return <PlanList entries={item.entries} />;
|
||||
|
||||
case "notice":
|
||||
return (
|
||||
<Alert status="default" className="text-sm">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{item.text}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case "turn-end":
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1" aria-label="turn complete">
|
||||
<span className="h-px grow bg-separator" />
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-muted">turn complete</span>
|
||||
<span className="h-px grow bg-separator" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mirrored session, rendered.
|
||||
*
|
||||
* Scrolling sticks to the bottom only while the reader is already there. The
|
||||
* alternative — always scrolling — yanks the view away mid-sentence every time
|
||||
* a chunk lands, which makes reading back through a long turn impossible on the
|
||||
* device this is most likely to be read on.
|
||||
*/
|
||||
export function Transcript({
|
||||
transcript,
|
||||
className,
|
||||
}: {
|
||||
transcript: TranscriptModel;
|
||||
className?: string;
|
||||
}) {
|
||||
const viewport = useRef<HTMLDivElement | null>(null);
|
||||
const pinned = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (el && pinned.current) el.scrollTop = el.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={viewport} onScroll={onScroll} className={className}>
|
||||
<div className="mx-auto max-w-3xl px-4 py-4 space-y-3">
|
||||
{transcript.dropped > 0 ? (
|
||||
<div className="text-xs text-muted text-center">
|
||||
{transcript.dropped} earlier {transcript.dropped === 1 ? "event" : "events"} fell out of
|
||||
the buffer
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{transcript.items.length === 0 ? (
|
||||
<div className="text-sm text-muted text-center py-12">
|
||||
nothing mirrored yet — this fills in as the session runs
|
||||
</div>
|
||||
) : (
|
||||
transcript.items.map((item) => <Item key={item.id} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Chip, Spinner } from "@heroui/react";
|
||||
import type { ConnectionState } from "../lib/ws";
|
||||
|
||||
const CONNECTION: Record<
|
||||
ConnectionState,
|
||||
{ label: string; color: "default" | "accent" | "success" | "warning" | "danger" }
|
||||
> = {
|
||||
connecting: { label: "connecting", color: "warning" },
|
||||
open: { label: "live", color: "success" },
|
||||
closed: { label: "offline", color: "danger" },
|
||||
};
|
||||
|
||||
/**
|
||||
* The two facts a remote viewer needs before trusting anything on screen: is
|
||||
* this stream still live, and is the agent working right now.
|
||||
*
|
||||
* They are shown together because a stalled transcript is ambiguous otherwise —
|
||||
* "no new output" looks identical whether the agent is thinking or the socket
|
||||
* died ten minutes ago.
|
||||
*/
|
||||
export function TurnStatus({
|
||||
connection,
|
||||
turnActive,
|
||||
pending = 0,
|
||||
}: {
|
||||
connection: ConnectionState;
|
||||
turnActive: boolean;
|
||||
pending?: number;
|
||||
}) {
|
||||
const conn = CONNECTION[connection];
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip size="sm" color={conn.color} variant="soft">
|
||||
<Chip.Label>{conn.label}</Chip.Label>
|
||||
</Chip>
|
||||
|
||||
{turnActive ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Spinner size="sm" color="accent" aria-label="turn in progress" />
|
||||
working
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">idle</span>
|
||||
)}
|
||||
|
||||
{pending > 0 ? (
|
||||
<Chip size="sm" color="warning" variant="soft">
|
||||
<Chip.Label>
|
||||
{pending} waiting on you
|
||||
</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* HeroUI v3 brings its own `@import "tailwindcss"`, the default theme's CSS
|
||||
* variables and the `dark` custom variant, so importing it is the whole Tailwind
|
||||
* setup — there is no tailwind.config.js in a v4 CSS-first project.
|
||||
*/
|
||||
@import "@heroui/react/styles";
|
||||
|
||||
/*
|
||||
* Tailwind v4 discovers utility classes by scanning from the CSS file that
|
||||
* imports it. That file lives in node_modules here, so automatic detection would
|
||||
* scan the wrong tree and emit a stylesheet with none of this app's utilities.
|
||||
* Pointing @source at src/ is what makes the build correct rather than empty.
|
||||
*/
|
||||
@source "./";
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
/* iOS zooms the page when a focused input is under 16px; the prompt box is
|
||||
the one control a phone user reaches for most. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* Transcript text is model output and command output: it must wrap, preserve
|
||||
* its own whitespace, and never widen the page. `overflow-wrap: anywhere` is
|
||||
* what keeps a 200-character path or a base64 blob from introducing a
|
||||
* horizontal scrollbar on the whole layout.
|
||||
*/
|
||||
.glance-pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.glance-prose {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.65;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* ACP frame shapes and the reducer that turns a stream of them into a
|
||||
* transcript.
|
||||
*
|
||||
* Two rails arrive here and both are handled by the same code:
|
||||
*
|
||||
* - `session/update` — the stable ACP rail. Correctness is keyed off this.
|
||||
* - `x.ai/session_notification` — grok's own rail, ~60 internal variants
|
||||
* carrying the streaming deltas that make a transcript readable.
|
||||
*
|
||||
* They share the `{sessionId, update: {sessionUpdate, ...}}` envelope, which is
|
||||
* why one reducer covers them. The xAI rail is *not* a stable interface: its
|
||||
* variants drift with every upstream sync, so an unrecognised `sessionUpdate`
|
||||
* is dropped from the rendering rather than treated as an error. Nothing
|
||||
* load-bearing is keyed off it.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Frame {
|
||||
jsonrpc?: string;
|
||||
id?: unknown;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
data?: string;
|
||||
annotations?: unknown;
|
||||
resource?: { text?: string; uri?: string; mimeType?: string };
|
||||
}
|
||||
|
||||
export interface ToolCallContent {
|
||||
type: "content" | "diff" | "terminal" | string;
|
||||
content?: ContentBlock;
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
terminalId?: string;
|
||||
}
|
||||
|
||||
export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||
|
||||
export interface ToolCallLocation {
|
||||
path: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
export interface ToolCallPayload {
|
||||
toolCallId?: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
status?: ToolCallStatus;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
rawInput?: unknown;
|
||||
rawOutput?: unknown;
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
content: string;
|
||||
priority?: string;
|
||||
status?: "pending" | "in_progress" | "completed" | string;
|
||||
}
|
||||
|
||||
/** The `{sessionId, update}` envelope both rails share. */
|
||||
export interface UpdateParams {
|
||||
sessionId?: string;
|
||||
update?: Record<string, unknown>;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interaction methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const METHOD_REQUEST_PERMISSION = "session/request_permission";
|
||||
export const METHOD_ASK_USER_QUESTION = "x.ai/ask_user_question";
|
||||
export const METHOD_EXIT_PLAN_MODE = "x.ai/exit_plan_mode";
|
||||
|
||||
export interface PermissionOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
/** `allow_once` | `allow_always` | `reject_once` | `reject_always` */
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface RequestPermissionParams {
|
||||
sessionId?: string;
|
||||
toolCall?: ToolCallPayload;
|
||||
options?: PermissionOption[];
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description: string;
|
||||
preview?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
question: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
multi_select?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface AskUserQuestionParams {
|
||||
sessionId?: string;
|
||||
toolCallId?: string;
|
||||
questions?: Question[];
|
||||
/** `default` | `plan` — plan mode unlocks two extra outcomes. */
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface ExitPlanModeParams {
|
||||
sessionId?: string;
|
||||
toolCallId?: string;
|
||||
planContent?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether an update starts, continues or ends a turn.
|
||||
*
|
||||
* This mirrors `classifyUpdate` in internal/hub/agent.go deliberately. The
|
||||
* server sends `turnActive` only in the snapshot, so a browser that did not
|
||||
* derive it from the frame stream would show a stale spinner for the rest of
|
||||
* the session. Keeping the two in step matters: if upstream renames
|
||||
* `turn_completed`, both sides go wrong the same way rather than disagreeing.
|
||||
*/
|
||||
export function turnEffect(kind: string | undefined): "start" | "end" | null {
|
||||
switch (kind) {
|
||||
case "turn_completed":
|
||||
return "end";
|
||||
case "agent_message_chunk":
|
||||
case "agent_thought_chunk":
|
||||
case "tool_call":
|
||||
case "tool_call_update":
|
||||
case "user_message_chunk":
|
||||
case "plan":
|
||||
case "pending_interaction":
|
||||
return "start";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptItem =
|
||||
| { kind: "user"; id: string; text: string }
|
||||
| { kind: "assistant"; id: string; text: string }
|
||||
| { kind: "thought"; id: string; text: string }
|
||||
| { kind: "tool"; id: string; call: ToolCallPayload }
|
||||
| { kind: "plan"; id: string; entries: PlanEntry[] }
|
||||
| { kind: "notice"; id: string; text: string }
|
||||
| { kind: "turn-end"; id: string };
|
||||
|
||||
export interface Transcript {
|
||||
items: TranscriptItem[];
|
||||
/** Frames the server's ring buffer discarded before this browser attached. */
|
||||
dropped: number;
|
||||
turnActive: boolean;
|
||||
}
|
||||
|
||||
export function emptyTranscript(dropped = 0, turnActive = false): Transcript {
|
||||
return { items: [], dropped, turnActive };
|
||||
}
|
||||
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!content || typeof content !== "object") return "";
|
||||
if (Array.isArray(content)) return content.map(textOf).join("");
|
||||
const block = content as ContentBlock;
|
||||
if (typeof block.text === "string") return block.text;
|
||||
if (block.resource && typeof block.resource.text === "string") return block.resource.text;
|
||||
// Images and audio have no textual form; naming them beats rendering nothing
|
||||
// and leaving a silent gap in the transcript.
|
||||
if (block.type === "image") return "[image]";
|
||||
if (block.type === "audio") return "[audio]";
|
||||
if (block.type === "resource_link") return `[${block.name ?? block.uri ?? "resource"}]`;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one tool-call update into an existing call.
|
||||
*
|
||||
* `tool_call_update` is a partial: fields it omits keep their previous value,
|
||||
* and `content` arrives as replacement snapshots rather than appends. Treating
|
||||
* an update as a whole new call — the obvious shortcut — makes a long edit
|
||||
* flicker between titled and untitled as deltas arrive.
|
||||
*/
|
||||
function mergeToolCall(previous: ToolCallPayload, next: ToolCallPayload): ToolCallPayload {
|
||||
return {
|
||||
...previous,
|
||||
...Object.fromEntries(Object.entries(next).filter(([, v]) => v !== undefined && v !== null)),
|
||||
content: next.content ?? previous.content,
|
||||
};
|
||||
}
|
||||
|
||||
let syntheticId = 0;
|
||||
|
||||
/**
|
||||
* Fold one mirrored frame into the transcript, returning a new value.
|
||||
*
|
||||
* Unknown methods and unknown update kinds are no-ops: this is the code path
|
||||
* that has to survive an upstream sync it was not written against.
|
||||
*/
|
||||
export function applyFrame(transcript: Transcript, raw: unknown): Transcript {
|
||||
const frame = raw as Frame;
|
||||
if (!frame || typeof frame !== "object" || typeof frame.method !== "string") {
|
||||
return transcript;
|
||||
}
|
||||
|
||||
const params = frame.params as UpdateParams | undefined;
|
||||
const update = params?.update;
|
||||
if (!update || typeof update !== "object") return transcript;
|
||||
|
||||
const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : undefined;
|
||||
const effect = turnEffect(kind);
|
||||
const turnActive =
|
||||
effect === "start" ? true : effect === "end" ? false : transcript.turnActive;
|
||||
|
||||
const items = transcript.items;
|
||||
const last = items[items.length - 1];
|
||||
|
||||
switch (kind) {
|
||||
case "user_message_chunk": {
|
||||
const text = textOf(update.content);
|
||||
if (!text) break;
|
||||
// Chunks of the same kind coalesce into one bubble; without this a
|
||||
// streamed reply renders as one paragraph per token.
|
||||
if (last?.kind === "user") {
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items.slice(0, -1), { ...last, text: last.text + text }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "user", id: `u${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_message_chunk": {
|
||||
const text = textOf(update.content);
|
||||
if (!text) break;
|
||||
if (last?.kind === "assistant") {
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items.slice(0, -1), { ...last, text: last.text + text }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "assistant", id: `a${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_thought_chunk": {
|
||||
const text = textOf(update.content);
|
||||
if (!text) break;
|
||||
if (last?.kind === "thought") {
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items.slice(0, -1), { ...last, text: last.text + text }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "thought", id: `t${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
|
||||
case "tool_call":
|
||||
case "tool_call_update": {
|
||||
const call = update as ToolCallPayload;
|
||||
const id = call.toolCallId;
|
||||
if (!id) break;
|
||||
const index = items.findIndex((item) => item.kind === "tool" && item.call.toolCallId === id);
|
||||
if (index === -1) {
|
||||
// An update for a call whose `tool_call` was dropped from the ring is
|
||||
// normal on reattach, so it opens a new entry rather than being lost.
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "tool", id: `tc:${id}`, call }],
|
||||
};
|
||||
}
|
||||
const existing = items[index] as Extract<TranscriptItem, { kind: "tool" }>;
|
||||
const merged = [...items];
|
||||
merged[index] = { ...existing, call: mergeToolCall(existing.call, call) };
|
||||
return { ...transcript, turnActive, items: merged };
|
||||
}
|
||||
|
||||
case "plan": {
|
||||
const entries = (update.entries ?? update.plan) as PlanEntry[] | undefined;
|
||||
if (!Array.isArray(entries)) break;
|
||||
// The plan is a running snapshot, not an append: replacing the previous
|
||||
// one keeps the checklist a checklist instead of a pile of revisions.
|
||||
const index = items.findIndex((item) => item.kind === "plan");
|
||||
const entry: TranscriptItem = { kind: "plan", id: "plan", entries };
|
||||
if (index === -1) return { ...transcript, turnActive, items: [...items, entry] };
|
||||
const merged = [...items];
|
||||
merged[index] = entry;
|
||||
return { ...transcript, turnActive, items: merged };
|
||||
}
|
||||
|
||||
case "turn_completed": {
|
||||
if (last?.kind === "turn-end") return { ...transcript, turnActive };
|
||||
return {
|
||||
...transcript,
|
||||
turnActive,
|
||||
items: [...items, { kind: "turn-end", id: `end${syntheticId++}` }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
// An unrecognised variant still counts toward turn state if
|
||||
// `turnEffect` claimed it; otherwise it is deliberately invisible.
|
||||
break;
|
||||
}
|
||||
|
||||
return turnActive === transcript.turnActive ? transcript : { ...transcript, turnActive };
|
||||
}
|
||||
|
||||
/** Replay a snapshot's worth of frames in one pass. */
|
||||
export function applyFrames(transcript: Transcript, frames: unknown[]): Transcript {
|
||||
return frames.reduce<Transcript>((acc, frame) => applyFrame(acc, frame), transcript);
|
||||
}
|
||||
|
||||
export function appendNotice(transcript: Transcript, text: string): Transcript {
|
||||
return {
|
||||
...transcript,
|
||||
items: [...transcript.items, { kind: "notice", id: `n${syntheticId++}`, text }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* REST client for the glance API.
|
||||
*
|
||||
* Every call is same-origin and relies on the `__Host-` session cookie, so
|
||||
* nothing here handles tokens — except the bootstrap token, which is a
|
||||
* query-string credential by design (it has to work before any cookie exists).
|
||||
*/
|
||||
|
||||
export interface Status {
|
||||
enrolled: boolean;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
/** Base32 TOTP secret, shown so an authenticator can be set up by hand. */
|
||||
secret: string;
|
||||
/** `otpauth://` URI to render as a QR code. */
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface SessionMeta {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
title?: string;
|
||||
model?: string;
|
||||
hostname?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
id: string;
|
||||
keyName: string;
|
||||
session: SessionMeta;
|
||||
label: string;
|
||||
connectedAt: string;
|
||||
lastActivity: string;
|
||||
turnActive: boolean;
|
||||
pending: number;
|
||||
frames: number;
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An API call that failed with a status the caller may want to branch on.
|
||||
*
|
||||
* The status matters more than the message here: 404 from the setup endpoints
|
||||
* means "the bootstrap gate rejected you" (deliberately indistinguishable from
|
||||
* "already enrolled"), and 429 from login means rate-limited rather than wrong.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
// The cookie is same-origin anyway, but being explicit means a future
|
||||
// change to the dev proxy cannot silently drop credentials.
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch (cause) {
|
||||
// fetch only rejects on network-level failure, which here means the server
|
||||
// is down or the dev proxy has no target. Saying so beats "Failed to fetch".
|
||||
throw new ApiError(0, `cannot reach the glance server (${String(cause)})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
// A non-JSON body from an API path means something upstream of the
|
||||
// handler answered — a proxy error page, most likely.
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `request failed with ${response.status}`;
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status: () => request<Status>("/api/status"),
|
||||
|
||||
/**
|
||||
* Both setup calls carry the bootstrap token in the query string, matching
|
||||
* `auth.BootstrapToken`, which reads `?token=` before the header.
|
||||
*/
|
||||
setupBegin: (token: string) =>
|
||||
request<Enrollment>(`/api/setup/begin?token=${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
setupComplete: (token: string, secret: string, code: string) =>
|
||||
request<{ ok: boolean }>(`/api/setup/complete?token=${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret, code }),
|
||||
}),
|
||||
|
||||
login: (code: string) =>
|
||||
request<{ ok: boolean }>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
|
||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST" }),
|
||||
|
||||
agents: () => request<{ agents: AgentSummary[] | null }>("/api/agents"),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Coarse relative time, for "last seen" style labels.
|
||||
*
|
||||
* Deliberately low-resolution: these timestamps come from a server whose clock
|
||||
* may differ from the browser's by seconds, so "12s ago" would imply a precision
|
||||
* that does not exist. Anything under a minute is just "now".
|
||||
*/
|
||||
export function ago(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return "";
|
||||
|
||||
const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Shorten a path for a header line, keeping the end — the part that identifies it. */
|
||||
export function shortPath(path: string | undefined, keep = 3): string {
|
||||
if (!path) return "";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length <= keep) return path;
|
||||
return "…/" + parts.slice(-keep).join("/");
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* The browser's socket to glance.
|
||||
*
|
||||
* This is deliberately *not* an ACP peer. glance translates the ACP link into a
|
||||
* small envelope so the frontend never has to correlate JSON-RPC ids — with one
|
||||
* exception: an interaction's `id` is the agent's own JSON-RPC id, echoed back
|
||||
* verbatim, because that is what the answer has to be addressed to.
|
||||
*/
|
||||
|
||||
import type { AgentSummary, SessionMeta } from "./api";
|
||||
|
||||
export interface Interaction {
|
||||
/** grok's JSON-RPC id, as a JSON value. Echoed back untouched when answering. */
|
||||
id: unknown;
|
||||
method: string;
|
||||
params: unknown;
|
||||
toolCallId?: string;
|
||||
openedAt: string;
|
||||
}
|
||||
|
||||
export type ServerEvent =
|
||||
| {
|
||||
type: "snapshot";
|
||||
agent: string;
|
||||
agents?: AgentSummary[];
|
||||
frames?: unknown[];
|
||||
dropped?: number;
|
||||
open?: Interaction[];
|
||||
session?: SessionMeta;
|
||||
turnActive?: boolean;
|
||||
}
|
||||
| { type: "agents"; agents?: AgentSummary[] }
|
||||
| { type: "frame"; agent: string; frame: unknown }
|
||||
| { type: "interaction"; agent: string; interaction: Interaction }
|
||||
| {
|
||||
type: "interaction_resolved";
|
||||
agent: string;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
by?: string;
|
||||
message?: string;
|
||||
}
|
||||
| { type: "notice"; agent?: string; message: string }
|
||||
| { type: "error"; agent?: string; message: string };
|
||||
|
||||
export type ClientCommand =
|
||||
| { type: "list" }
|
||||
| { type: "subscribe"; agent: string }
|
||||
| { type: "prompt"; agent: string; text: string }
|
||||
| { type: "cancel"; agent: string }
|
||||
| { type: "answer"; agent: string; id: string; result: unknown }
|
||||
| { type: "decline"; agent: string; id: string; reason?: string };
|
||||
|
||||
export type ConnectionState = "connecting" | "open" | "closed";
|
||||
|
||||
/**
|
||||
* `Interaction.id` arrives as a parsed JSON value but is *keyed* server-side by
|
||||
* its raw JSON text (`string(frame.ID)` in internal/hub/agent.go). Re-encoding
|
||||
* it is what makes an answer land on the right pending entry: an id of `7`
|
||||
* becomes `"7"` and an id of `"abc"` becomes `"\"abc\""`, matching Go's
|
||||
* `json.RawMessage` bytes on both sides.
|
||||
*/
|
||||
export function interactionKey(id: unknown): string {
|
||||
return JSON.stringify(id ?? null);
|
||||
}
|
||||
|
||||
export interface GlanceSocketHandlers {
|
||||
onEvent: (event: ServerEvent) => void;
|
||||
/**
|
||||
* Connection state changes.
|
||||
*
|
||||
* A rejected upgrade (401, cookie expired) and an unreachable server both
|
||||
* surface as an abnormal close with no close frame, so this reports "closed"
|
||||
* either way and leaves the app to ask `/api/status` which one it was.
|
||||
* Guessing from the close code would send people to a login page whenever the
|
||||
* server restarted.
|
||||
*/
|
||||
onState: (state: ConnectionState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reconnecting socket.
|
||||
*
|
||||
* Reconnection is not optional here: laptops sleep, phones switch networks, and
|
||||
* a viewer that silently stops updating is worse than one that says it is
|
||||
* offline — it looks like an idle agent.
|
||||
*/
|
||||
export class GlanceSocket {
|
||||
private socket: WebSocket | null = null;
|
||||
private handlers: GlanceSocketHandlers;
|
||||
private attempt = 0;
|
||||
private timer: number | null = null;
|
||||
private stopped = false;
|
||||
/** Re-sent on every reconnect so a resumed socket refills its transcript. */
|
||||
private subscription: string | null = null;
|
||||
|
||||
constructor(handlers: GlanceSocketHandlers) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.open();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
send(command: ClientCommand): boolean {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) return false;
|
||||
this.socket.send(JSON.stringify(command));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Subscribe, and remember it so a reconnect restores the same view. */
|
||||
subscribe(agent: string): void {
|
||||
this.subscription = agent;
|
||||
this.send({ type: "subscribe", agent });
|
||||
}
|
||||
|
||||
clearSubscription(): void {
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
private open(): void {
|
||||
if (this.stopped) return;
|
||||
this.handlers.onState("connecting");
|
||||
|
||||
const url = new URL("/api/ws", window.location.href);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.attempt = 0;
|
||||
this.handlers.onState("open");
|
||||
if (this.subscription) this.send({ type: "subscribe", agent: this.subscription });
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data !== "string") return;
|
||||
let parsed: ServerEvent;
|
||||
try {
|
||||
parsed = JSON.parse(event.data) as ServerEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.handlers.onEvent(parsed);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this.handlers.onState("closed");
|
||||
if (this.stopped) return;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// `onclose` always follows; handling both would double the backoff.
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
// Capped exponential backoff with jitter. The cap is low because this is a
|
||||
// local-network tool and a viewer that takes 30s to come back after a
|
||||
// laptop wakes up reads as broken.
|
||||
const base = Math.min(500 * 2 ** this.attempt, 5000);
|
||||
const delay = base + Math.random() * 250;
|
||||
this.attempt += 1;
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.open();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("#root is missing from index.html");
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, InputOTP } from "@heroui/react";
|
||||
import { ApiError, api } from "../lib/api";
|
||||
import { Centered } from "./Setup";
|
||||
|
||||
export function Login({ onDone }: { onDone: () => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (value: string) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.login(value);
|
||||
onDone();
|
||||
} catch (cause) {
|
||||
setCode("");
|
||||
// 429 is worth naming separately: retrying immediately is exactly the
|
||||
// wrong response to it, and "wrong code" would invite precisely that.
|
||||
setError(
|
||||
cause instanceof ApiError && cause.status === 429
|
||||
? "too many attempts — wait a moment before trying again"
|
||||
: cause instanceof ApiError && cause.status === 401
|
||||
? "that code did not match"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "sign-in failed",
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Centered title="grok glance">
|
||||
{error ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm text-muted">Enter the current code from your authenticator.</p>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={submit}
|
||||
isDisabled={busy}
|
||||
isInvalid={error !== null}
|
||||
pattern="^\d*$"
|
||||
inputMode="numeric"
|
||||
aria-label="verification code"
|
||||
autoFocus
|
||||
className="self-center"
|
||||
>
|
||||
<InputOTP.Group>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTP.Slot key={index} index={index} />
|
||||
))}
|
||||
</InputOTP.Group>
|
||||
</InputOTP>
|
||||
|
||||
<Button fullWidth isDisabled={busy || code.length < 6} onPress={() => void submit(code)}>
|
||||
{busy ? "Checking…" : "Sign in"}
|
||||
</Button>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Alert, Button } from "@heroui/react";
|
||||
import type { Transcript as TranscriptModel } from "../lib/acp";
|
||||
import type { AgentSummary } from "../lib/api";
|
||||
import { shortPath } from "../lib/format";
|
||||
import { interactionKey, type ConnectionState, type Interaction } from "../lib/ws";
|
||||
import { PermissionDialog } from "../components/PermissionDialog";
|
||||
import { PromptBox } from "../components/PromptBox";
|
||||
import { Transcript } from "../components/Transcript";
|
||||
import { TurnStatus } from "../components/TurnStatus";
|
||||
|
||||
export function Session({
|
||||
agent,
|
||||
transcript,
|
||||
interactions,
|
||||
connection,
|
||||
onBack,
|
||||
onPrompt,
|
||||
onCancel,
|
||||
onAnswer,
|
||||
onDecline,
|
||||
}: {
|
||||
/** Undefined once the session disconnects — the transcript stays readable. */
|
||||
agent: AgentSummary | undefined;
|
||||
transcript: TranscriptModel;
|
||||
interactions: Interaction[];
|
||||
connection: ConnectionState;
|
||||
onBack: () => void;
|
||||
onPrompt: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
onAnswer: (interaction: Interaction, result: unknown) => void;
|
||||
onDecline: (interaction: Interaction, reason: string) => void;
|
||||
}) {
|
||||
const meta = agent?.session ?? {};
|
||||
const live = connection === "open" && agent !== undefined;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="border-b border-border shrink-0">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 flex items-center gap-3">
|
||||
<Button size="sm" variant="ghost" onPress={onBack} aria-label="back to sessions">
|
||||
←
|
||||
</Button>
|
||||
<div className="min-w-0 grow">
|
||||
<div className="text-sm font-semibold truncate">{agent?.label ?? "session"}</div>
|
||||
<div className="text-xs text-muted truncate">
|
||||
{[meta.model, meta.hostname, shortPath(meta.cwd)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<TurnStatus
|
||||
connection={connection}
|
||||
turnActive={transcript.turnActive}
|
||||
pending={interactions.length}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{agent === undefined ? (
|
||||
<div className="mx-auto max-w-3xl w-full px-4 pt-3 shrink-0">
|
||||
<Alert status="warning">
|
||||
<Alert.Content>
|
||||
<Alert.Title>This session has disconnected</Alert.Title>
|
||||
<Alert.Description>
|
||||
What was mirrored is still here to read. It will reappear if the session runs
|
||||
<code className="glance-pre"> /rc </code>again.
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Transcript transcript={transcript} className="grow overflow-y-auto" />
|
||||
|
||||
{interactions.length > 0 ? (
|
||||
<div className="shrink-0 max-h-[60vh] overflow-y-auto border-t border-border bg-surface-secondary">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 space-y-3">
|
||||
{interactions.map((interaction) => (
|
||||
<PermissionDialog
|
||||
// Keying on the interaction id is what resets the form state
|
||||
// when one card replaces another: React would otherwise reuse
|
||||
// the mounted component and carry the previous answer over.
|
||||
key={interactionKey(interaction.id)}
|
||||
interaction={interaction}
|
||||
onAnswer={(result) => onAnswer(interaction, result)}
|
||||
onDecline={(reason) => onDecline(interaction, reason)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="shrink-0">
|
||||
<PromptBox
|
||||
disabled={!live}
|
||||
turnActive={transcript.turnActive}
|
||||
onSend={onPrompt}
|
||||
onStop={onCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Button, Card, Chip, EmptyState, Spinner } from "@heroui/react";
|
||||
import type { AgentSummary } from "../lib/api";
|
||||
import { ago, shortPath } from "../lib/format";
|
||||
import type { ConnectionState } from "../lib/ws";
|
||||
|
||||
function AgentCard({ agent, onOpen }: { agent: AgentSummary; onOpen: () => void }) {
|
||||
const meta = agent.session ?? {};
|
||||
return (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<Card.Title className="text-base truncate">{agent.label}</Card.Title>
|
||||
<Card.Description className="text-xs truncate">
|
||||
{[meta.model, meta.hostname, shortPath(meta.cwd)].filter(Boolean).join(" · ")}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content className="flex flex-wrap items-center gap-2">
|
||||
{agent.turnActive ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Spinner size="sm" color="accent" aria-label="turn in progress" />
|
||||
working
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">idle · {ago(agent.lastActivity)}</span>
|
||||
)}
|
||||
|
||||
{agent.pending > 0 ? (
|
||||
<Chip size="sm" color="warning" variant="soft">
|
||||
<Chip.Label>{agent.pending} waiting</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
|
||||
{agent.dropped > 0 ? (
|
||||
<Chip size="sm" color="default" variant="soft">
|
||||
<Chip.Label>{agent.dropped} dropped</Chip.Label>
|
||||
</Chip>
|
||||
) : null}
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted truncate">key: {agent.keyName}</span>
|
||||
<Button size="sm" onPress={onOpen}>
|
||||
Open
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sessions({
|
||||
agents,
|
||||
connection,
|
||||
onOpen,
|
||||
onSignOut,
|
||||
}: {
|
||||
agents: AgentSummary[];
|
||||
connection: ConnectionState;
|
||||
onOpen: (id: string) => void;
|
||||
onSignOut: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<header className="border-b border-border">
|
||||
<div className="mx-auto max-w-3xl px-4 py-3 flex items-center gap-3">
|
||||
<h1 className="text-sm font-semibold grow">grok glance</h1>
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="soft"
|
||||
color={connection === "open" ? "success" : connection === "connecting" ? "warning" : "danger"}
|
||||
>
|
||||
<Chip.Label>{connection === "open" ? "live" : connection}</Chip.Label>
|
||||
</Chip>
|
||||
<Button size="sm" variant="ghost" onPress={onSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-3xl px-4 py-6 space-y-3">
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState className="py-16 text-center">
|
||||
<p className="text-sm font-medium">No sessions connected</p>
|
||||
<p className="text-sm text-muted mt-2">
|
||||
Run <code className="glance-pre">/rc</code> in a grok session to mirror it here.
|
||||
</p>
|
||||
</EmptyState>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} onOpen={() => onOpen(agent.id)} />
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Alert, Button, Card, InputOTP, Spinner } from "@heroui/react";
|
||||
import { ApiError, api, type Enrollment } from "../lib/api";
|
||||
|
||||
/**
|
||||
* First-run TOTP enrolment.
|
||||
*
|
||||
* The bootstrap token in the query string is the whole access-control story
|
||||
* here: without it the server answers 404, which is what closes the window where
|
||||
* anyone who reaches the port first could enrol themselves as the owner. It is
|
||||
* spent the moment enrolment completes.
|
||||
*/
|
||||
export function Setup({ onDone }: { onDone: () => void }) {
|
||||
const token = new URLSearchParams(window.location.search).get("token") ?? "";
|
||||
|
||||
const [enrollment, setEnrollment] = useState<Enrollment | null>(null);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
|
||||
api
|
||||
.setupBegin(token)
|
||||
.then(async (result) => {
|
||||
if (cancelled) return;
|
||||
setEnrollment(result);
|
||||
// A data: URI, not a remote image — the CSP allows `img-src 'self' data:`
|
||||
// precisely so this works without loosening anything for the rest of the
|
||||
// page. The secret never leaves the browser as a URL either way.
|
||||
const url = await QRCode.toDataURL(result.uri, { margin: 1, width: 220 });
|
||||
if (!cancelled) setQr(url);
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(
|
||||
cause instanceof ApiError && cause.status === 404
|
||||
? "that bootstrap token is not valid — it may already have been used"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "enrolment could not be started",
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const submit = async (value: string) => {
|
||||
if (!enrollment || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setupComplete(token, enrollment.secret, value);
|
||||
onDone();
|
||||
} catch (cause) {
|
||||
setCode("");
|
||||
setError(
|
||||
cause instanceof ApiError && cause.status === 401
|
||||
? "that code did not match — check your device's clock and try the next one"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "enrolment failed",
|
||||
);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Centered title="Bootstrap token required">
|
||||
<p className="text-sm text-muted">
|
||||
glance printed a one-time setup link when it first started. Open that link, or read the
|
||||
token from <code className="glance-pre">~/.grok/glance/bootstrap.token</code> and visit{" "}
|
||||
<code className="glance-pre">/?token=…</code>.
|
||||
</p>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Centered title="Set up two-factor sign-in">
|
||||
{error ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!enrollment ? (
|
||||
!error ? <Spinner color="accent" aria-label="preparing enrolment" /> : null
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted">
|
||||
Scan this with your authenticator, then type the six digits it shows.
|
||||
</p>
|
||||
|
||||
{qr ? (
|
||||
<img
|
||||
src={qr}
|
||||
alt="TOTP enrolment QR code"
|
||||
width={220}
|
||||
height={220}
|
||||
className="rounded-md bg-white p-2 self-center"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<details className="text-xs text-muted">
|
||||
<summary className="cursor-pointer">Can't scan it?</summary>
|
||||
<p className="mt-2">Enter this secret by hand:</p>
|
||||
<code className="glance-pre block mt-1 break-all">{enrollment.secret}</code>
|
||||
</details>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={submit}
|
||||
isDisabled={busy}
|
||||
pattern="^\d*$"
|
||||
inputMode="numeric"
|
||||
aria-label="verification code"
|
||||
className="self-center"
|
||||
>
|
||||
<InputOTP.Group>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTP.Slot key={index} index={index} />
|
||||
))}
|
||||
</InputOTP.Group>
|
||||
</InputOTP>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
isDisabled={busy || code.length < 6}
|
||||
onPress={() => void submit(code)}
|
||||
>
|
||||
{busy ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
export function Centered({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-full flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<Card.Header>
|
||||
<Card.Title>{title}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content className="flex flex-col gap-4">{children}</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// The dev server proxies to a `glance serve` running on its default address.
|
||||
//
|
||||
// Going through the proxy rather than pointing the browser straight at :7717
|
||||
// keeps the app same-origin in development, which is not cosmetic: the session
|
||||
// cookie is `__Host-` prefixed and `SameSite=Strict`, so a cross-origin dev
|
||||
// setup would never send it and every authenticated call would 401.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// `ws: true` covers /api/ws and /api/acp/agent; both are upgrades, and a
|
||||
// proxy that only forwards plain HTTP would fail the handshake.
|
||||
"/api": { target: "http://127.0.0.1:7717", ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// Emptying is what keeps a stale bundle from being embedded after a rename;
|
||||
// the Makefile restores web/dist/.gitkeep afterwards so `go build` still has
|
||||
// a directory to embed.
|
||||
emptyOutDir: true,
|
||||
// The server sets `Cache-Control: immutable` on hashed assets only, so
|
||||
// leaving the default hashed names in place is load-bearing.
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user