Files
OpenResearch/ui/src/api.ts
T
Daniel Kim 012672958d Plan-mode chat: AskUserQuestion double-card/hang fix + desktop-parity plan/question cards (v0.1.61) (#84)
* Fix harness prompt bugs: AskUserQuestion double-card + hang, resolved-card rendering

- plan_auto_policy auto-allows AskUserQuestion: the bridge no longer fronts
  the question card with a redundant permission card whose held request also
  deadlocked the end-turn resume (answer rejected as busy until interrupt).
- respond() resolves a card only after its answer is delivered, so a rejected
  send leaves the card actionable instead of resolved-with-the-answer-dropped.
- Resolved cards mirror Claude Code: permission cards vanish, plan/question
  cards collapse to compact inline expandable rows with an answer echo
  (answers/approved/note stamped on resolve, carried through turn flushes).

* Review round 1 fixes: doc split, echo-preserving adopt, neutral outcome, read-only class rename

* Review round 2 fixes: PendingGuard doc, best-effort resolve folded into helper, neutral question echo

* Post-review nits: doc wording, dedupe question text in collapsed body, word-break on collapsed rows

* Plan-mode AskUserQuestion truly blocks: bridge holds the call as the question card

Auto-allowing AskUserQuestion was wrong: headless the tool returns no
answer, so the model guessed and kept planning instead of waiting. The
bridge now surfaces the QUESTION itself as a held mid-turn card (like
ExitPlanMode's plan card); the answer settles the held call as a denial
whose message is the answer, so the model continues the same turn with
the user's actual choice. The turn-status line says 'Waiting for your
input…' instead of 'Working…' while a card holds the turn open.

* Post-review nits: deny-message hardening, question cards don't suppress plan-synth, empty-options fallback, held-card status via nativeId, comment accuracy

* Polish: no tool row for bridge-held calls (deny-with-answer painted them red), plan pane gets padding + plain background

The bridged AskUserQuestion/ExitPlanMode tool_use now renders nothing —
the held card is the representation, and the denial that carries the
user's answer back made the CLI report the tool as errored, painting a
successful Q&A as a red error row. The right-pane plan tab had no CSS
at all: flush text on the panel tint; it's a markdown document, so it
gets a padded, plain reading surface.

* PlanStrip: Claude-desktop shape — title line, real buttons in a row below

The strip's buttons used .btn-primary/.btn-ghost, which are only styled
inside .prompt-actions — in the strip they rendered as bare text crammed
next to the title. The actions row now reuses .prompt-actions (same
buttons as every card) and sits on its own line under the title.

* PlanStrip buttons: monochrome — black text/borders, no brand red

* PlanStrip split button: single internal divider (caret drops its own left border)

* PlanStrip split button: flat inner corners (radius reset must out-rank .prompt-actions shorthand)

* Composer: attention bell instead of Stop while a held card awaits the user

A blocked-on-input turn is busy, so the composer showed the Stop X — a
loading/cancel affordance while the agent is actually waiting on an
answer. When the session is busy AND a held card (nativeId) is
unresolved, show a pulsing bell that scrolls to the pending card; Esc
still interrupts. The transcript status line shares the same
awaitingInput signal.

* Composer: two states only — Send by default, Stop X only while actively streaming

Drop the bell experiment. A turn held on an unanswered card shows the
idle Send button; the card and the status line are the waiting
affordance. Esc still interrupts a held turn.

* Composer answers the pending card (Claude-desktop): typed text = plan revision feedback / custom question answer

- send() routes plain typed text to the newest unresolved plan/question
  card (respond with note) instead of racing the held turn with a new
  message the busy guard would drop; placeholder says so. Questions
  route only on Claude sessions (opencode rejects note-only replies).
- Rail busy-dot stops pulsing when the turn is held waiting on the user
  (steady dot; same awaitingInput signal as the status line).
- Note-only question answers echo the note in the collapsed row.

* PlanStrip: Claude-desktop parity actions + popovers above the strip

Reject / Revise… / Accept / Accept-and-auto-mode (+ bypass in the caret),
Open plan as a title-row link. Reject is a true rejection: the note-less
plan denial now tells the model to stop and wait (was 'keep refining',
which made it improvise revisions/questions); collapsed rows echo
'Rejected' vs 'Revision requested' by note presence. Accept resumes
under accept-edits (guarded), Accept-and-auto under Auto — the primary
is the solid dark button, not brand red. The strip moved inside
.composer so the mode/model picker popovers (z 50 in that stacking
context) layer above it — as a sibling, .composer's z-index:4 capped
them below the strip.

* PlanStrip: single Accept-and-auto-mode primary, drop the plan excerpt

Matches the desktop reference exactly: [Reject] [Revise…] on the left,
one primary button on the right whose caret menu holds the two guarded
tiers (Accept = accept-edits, Accept and bypass all). The separate
outline Accept button and the plan-title excerpt next to the header
are gone — plan.excerpt/plan prop was fully unused after removal.

* PlanStrip: self-contained Revise mode (Claude-desktop parity)

Swaps the strip into its own textarea + [Back] [Revise ↵] footer
instead of routing through the main composer — matches the desktop
reference exactly. Enter submits, Shift+Enter newlines, Esc cancels
back to the action row. The main-composer typed-text routing
(pendingPrompt) is now question-only; plans have their own dedicated
UI and no longer need it.

* PlanStrip: drop the plain accept-edits Accept option

We have no partial-approval story (edits-only, everything else still
gated) — the caret now holds only Accept and bypass all.

* Plan revision: visible feedback + never silently dead-end

- Submitting a revision now swaps the strip to a 'Revising the plan…'
  placeholder until the revised card arrives (or the turn ends) —
  previously the strip snapped back to an identical-looking action row
  and the submit read as a no-op.
- An empty revise note maps to a generic 'Please revise the plan.'
  instead of riding the note-less wire shape, which the backend reads
  as a hard rejection ('stop and wait') — the '(optional)' field was
  accidentally a Reject.
- respond() reconciles messages AND busy state from the server after
  every answer: a stale card (e.g. resolved by the bridge timeout while
  the tab's SSE was dropped) previously stayed actionable forever and
  swallowed every answer.

* PlanStrip: drop the 'Revising the plan…' placeholder

The transcript's Working… spinner already communicates it. The strip
still hides while a submitted revision is in flight (the outgoing card
must not look actionable) and the revised card swaps in on arrival.

* Review round 1 fixes: zombie-card recovery, honest composer button, reject-loop fix

Blockers:
- A held card orphaned by a process restart (PendingGuard never ran)
  permanently captured typed input: the composer routed every message to
  it and errors were swallowed. Now the backend collapses the zombie on
  the first answer attempt, and the composer only routes typed text to a
  question card that is actually answerable (live turn for native cards).
- The Send/Stop swap keyed on awaitingInput while send() could only
  service question cards — a held plan/permission card showed a lying
  Send (silent no-op) with no Stop control. Now keyed on pendingQuestion.

Also: note-less Reject on an end-turn/synthesized plan card resolves
with no resume (resuming just to say 'stop' re-synthesized a card — an
un-dismissable strip); the bridge plan-deny arm reuses synthesize_resume
verbatim; respond() reconciles busy for its own session only and reports
failure so the question route can restore a consumed draft; revising
clear gated to its session; un-docked plan card fallback matches the
strip's semantics; Rejected gets its own outcome class; note-only
answers echo once; split-button CSS consolidated; dead CSS/union
removed; test pins the reject wording.

* Post-clean-round nits: keep optimistic busy on failed reconcile fetch, sweep zombie held cards at claude turn entry

- respond()'s busy reconcile no longer clears busy when the sessions
  fetch fails — during a Handled resume nothing re-asserts busy
  mid-stream, so a transient fetch failure would hide Working…/Stop for
  the rest of the turn.
- claude run_turn sweeps unresolved native_id cards at turn entry
  (resolve_stale_prompts gains a native_only mode; codex keeps sweeping
  everything). A held card can never outlive its process, so one left
  unresolved is a crash artifact — previously it could capture the
  composer's typed-text routing once the new turn made the session busy.

* Bump version to 0.1.61
2026-07-17 00:15:45 -07:00

917 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Typed client for the orx up local HTTP API (/api/*). All wire JSON is camelCase.
export interface Project {
id: string;
name: string;
slug: string;
githubOwner: string;
githubRepo: string;
baselineBranch: string;
repoPath: string;
runCommand?: string | null;
/** arXiv id the project starts from (versionless). */
paperId?: string | null;
createdAt: number;
updatedAt: number;
}
export interface Experiment {
id: string;
projectId: string;
parentExperimentId?: string | null;
slug: string;
branchName: string;
title?: string | null;
description?: string | null;
runCommand: string;
agentStatus: string;
createdAt: number;
updatedAt: number;
}
export type RunStatus = "starting" | "running" | "done" | "failed" | "cancelled";
export interface Run {
id: string;
experimentId: string;
projectId: string;
status: RunStatus;
backend?: Record<string, unknown> | null;
command?: string | null;
commitSha?: string | null;
resultMarkdown?: string | null;
createdAt: number;
updatedAt: number;
endedAt?: number | null;
exitCode?: number | null;
}
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
let message = text;
try {
const parsed = JSON.parse(text) as { error?: string };
if (parsed.error) message = parsed.error;
} catch {
// non-JSON body — show it raw
}
throw new Error(message || `HTTP ${res.status}`);
}
return (await res.json()) as T;
}
const get = <T>(url: string) => fetch(url).then((r) => json<T>(r));
const post = <T>(url: string, body?: unknown) =>
fetch(url, {
method: "POST",
headers: body === undefined ? {} : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}).then((r) => json<T>(r));
const patch = <T>(url: string, body: unknown) =>
fetch(url, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}).then((r) => json<T>(r));
export const listProjects = () =>
get<{ projects: Project[] }>("/api/projects").then((r) => r.projects);
export interface NewProject {
name: string;
githubOwner?: string;
githubRepo?: string;
baselineBranch?: string;
runCommand?: string;
/** arXiv id the project starts from (versionless). */
paperId?: string;
/** Create a blank private repo on the user's GitHub account instead. */
createRepo?: boolean;
/** Fork-by-copy the repo into a fresh `<repo>-<hash>` repo on the user's
* account. Applied automatically when they lack push access. */
forkRepo?: boolean;
}
export const createProject = (body: NewProject) =>
post<{ project: Project }>("/api/projects", body).then((r) => r.project);
export interface PaperHit {
paperId: string;
title: string;
snippet?: string | null;
}
export interface ResolvedPaper {
paperId: string;
title?: string | null;
repoUrl?: string | null;
repoStars?: number | null;
}
export const searchPapers = (q: string) =>
get<{ papers: PaperHit[] }>(`/api/papers/search?q=${encodeURIComponent(q)}`).then(
(r) => r.papers,
);
/** Resolve an arXiv id / URL to title + linked GitHub repo. May take a few
* seconds for papers alphaXiv hasn't indexed yet (it scrapes arXiv on a miss). */
export const resolvePaper = (id: string) =>
get<{ paper: ResolvedPaper }>(`/api/papers/resolve?id=${encodeURIComponent(id)}`).then(
(r) => r.paper,
);
export const updateProject = (projectId: string, body: { runCommand?: string; name?: string }) =>
patch<{ project: Project }>(`/api/projects/${projectId}`, body).then((r) => r.project);
/** Record a visit: bumps the project's updatedAt, which drives the recency sort. */
export const openProject = (projectId: string) =>
post<{ project: Project }>(`/api/projects/${projectId}/open`).then((r) => r.project);
export const deleteProject = (projectId: string) =>
fetch(`/api/projects/${projectId}`, { method: "DELETE" }).then(async (r) => {
if (!r.ok) {
const body = await r.json().catch(() => null);
throw new Error(body?.error ?? `delete failed (${r.status})`);
}
});
export const listExperiments = (projectId: string) =>
get<{ experiments: Experiment[] }>(`/api/projects/${projectId}/experiments`).then(
(r) => r.experiments,
);
export const listRuns = (projectId: string) =>
get<{ runs: Run[] }>(`/api/projects/${projectId}/runs`).then((r) => r.runs);
/** A run viewed as compute: every run across all projects, tagged with the
* name of the project that launched it. `projectName` is enriched only on the
* /api/instances snapshot — it is absent from the `run.updated` SSE payload. */
export interface Instance extends Run {
projectName?: string;
}
export const listInstances = () =>
get<{ instances: Instance[] }>("/api/instances").then((r) => r.instances);
export interface NewExperiment {
/** Omit on an empty project to create the baseline root; once a root
* exists, an omitted parent attaches the node under the oldest root. */
parentExperimentId?: string;
/** Force a new baseline root even when the project already has one. */
baseline?: boolean;
slug?: string;
title?: string;
description?: string;
runCommand?: string;
}
export const createExperiment = (projectId: string, body: NewExperiment) =>
post<{ experiment: Experiment }>(`/api/projects/${projectId}/experiments`, body).then(
(r) => r.experiment,
);
export const startRun = (
experimentId: string,
body: {
/** Omit to launch on the default compute target (Settings → Compute);
* with no default set the server falls back to `hf`. */
backend?: "local" | "hf" | "modal" | "k8s" | "ssh" | "slurm" | "openresearch";
flavor?: string;
manifest?: string;
timeout?: string;
/** ssh host alias, or the Slurm login node; defaults to the slurm settings' host. */
host?: string;
/** Org to bill the box to (openresearch only; omit = the sole org). */
org?: string;
} = {},
) => post<{ run: Run }>(`/api/experiments/${experimentId}/run`, body).then((r) => r.run);
export const cancelRun = (runId: string) => post<{ ok: boolean }>(`/api/runs/${runId}/cancel`);
export interface LogChunk {
dataBase64: string;
nextOffset: number;
eof: boolean;
}
export const fetchLog = (runId: string, offset: number) =>
get<LogChunk>(`/api/runs/${runId}/log?offset=${offset}`);
export interface DiffPayload {
diff: string;
truncated: boolean;
bytesRead: number;
byteLimit: number;
}
export interface CommitInfo {
sha: string;
subject: string;
committedAt: number; // unix seconds
}
export interface WorkingTree {
branch: string | null;
experimentId: string | null;
diff: string;
truncated: boolean;
}
export const getRunDiff = (runId: string) => get<DiffPayload>(`/api/runs/${runId}/diff`);
export const listExperimentCommits = (experimentId: string) =>
get<{ commits: CommitInfo[] }>(`/api/experiments/${experimentId}/commits`).then(
(r) => r.commits,
);
export const getCommitDiff = (experimentId: string, sha: string) =>
get<DiffPayload>(`/api/experiments/${experimentId}/commits/${sha}/diff`);
export const getWorkingTree = (projectId: string) =>
get<WorkingTree>(`/api/projects/${projectId}/working-tree`);
/** Which source answered a checkout read: a session's live worktree, the hub
* clone (also the worktree-pruned fallback), or a branch's committed tree. */
export type CheckoutRoot = "worktree" | "clone" | "branch";
/** Source selector for checkout reads: `ref` picks a branch's committed
* state (sessionId is then ignored server-side); `sessionId` alone picks the
* session's live worktree; neither picks the hub clone. */
export interface CheckoutRef {
sessionId?: string;
ref?: string;
}
const checkoutQuery = (opts: CheckoutRef, params: URLSearchParams = new URLSearchParams()) => {
if (opts.sessionId) params.set("sessionId", opts.sessionId);
if (opts.ref) params.set("ref", opts.ref);
return params;
};
export interface ProjectFile {
path: string;
content: string;
truncated: boolean;
notFound: boolean;
root: CheckoutRoot;
}
/** One file from the project — a branch's committed copy when `ref` is given,
* else a chat session's worktree, else the hub clone — capped server-side
* (~512 KB). */
export const getProjectFile = (projectId: string, path: string, opts: CheckoutRef = {}) =>
get<ProjectFile>(
`/api/projects/${projectId}/file?${checkoutQuery(opts, new URLSearchParams({ path }))}`,
);
export interface CodeTree {
root: CheckoutRoot;
/** The listed branch (`ref` mode), else the checked-out branch, else null
* (detached HEAD). */
branch: string | null;
/** Repo-relative file paths (gitignored trees excluded), sorted. */
entries: string[];
/** True when the listing hit the server-side cap (20,000 entries). */
truncated: boolean;
}
/** Flat file listing of the project — a branch's committed tree when `ref` is
* given, else the hub clone's checkout — plus the branch name. */
export const getCodeTree = (projectId: string, opts: { ref?: string } = {}) => {
const qs = checkoutQuery(opts).toString();
return get<CodeTree>(`/api/projects/${projectId}/code-tree${qs ? `?${qs}` : ""}`);
};
/** A GitHub `tree` URL for a branch. Branch names contain `/` (`orx/<slug>`),
* so encode each path segment — never the whole string, which would escape the
* slashes. Unpushed branches 404 on GitHub, which is acceptable. */
export const githubBranchUrl = (owner: string, repo: string, branch: string) =>
`https://github.com/${owner}/${repo}/tree/${branch
.split("/")
.map(encodeURIComponent)
.join("/")}`;
export type HfTokenSource = "env" | "openresearchEnv" | "hfCache";
export interface HfSettings {
configured: boolean;
source: HfTokenSource | null;
maskedToken: string | null;
valid: boolean;
username: string | null;
jobsWrite: boolean | null;
}
export const getHfSettings = () => get<HfSettings>("/api/settings/hf");
export const saveHfToken = (token: string) => post<HfSettings>("/api/settings/hf", { token });
// --- settings: kubernetes -----------------------------------------------------
export interface K8sPreflight {
kubectlFound: boolean;
reachable: boolean;
canCreateJobs: boolean;
error?: string;
}
export interface K8sSettings {
configured: boolean;
contexts: string[];
currentContext: string | null;
context: string | null;
namespace: string;
preflight: K8sPreflight;
}
export const getK8sSettings = () => get<K8sSettings>("/api/settings/k8s");
export const saveK8sSettings = (body: { context?: string; namespace?: string }) =>
post<K8sSettings>("/api/settings/k8s", body);
// --- settings: modal ----------------------------------------------------------
export type ModalTokenSource = "env" | "syncedEnv" | "modalToml";
export interface ModalSettings {
/** The orx-managed venv exists on disk. */
envProvisioned: boolean;
/** `import modal` succeeds with the resolved interpreter. */
modalImportable: boolean;
tokenConfigured: boolean;
tokenSource: ModalTokenSource | null;
/** modalImportable && tokenConfigured. */
ready: boolean;
error: string | null;
}
export const getModalSettings = () => get<ModalSettings>("/api/settings/modal");
/** Build the orx-managed Modal env (first run downloads the SDK, ~30–60s). */
export const provisionModal = () => post<ModalSettings>("/api/settings/modal/provision");
// --- settings: env vars / git / harnesses ------------------------------------
export interface EnvVar {
key: string;
maskedValue: string;
inProcessEnv: boolean;
}
export const getEnvVars = () =>
get<{ vars: EnvVar[] }>("/api/settings/env").then((r) => r.vars);
export const setEnvVar = (key: string, value: string) =>
post<{ vars: EnvVar[] }>("/api/settings/env", { key, value }).then((r) => r.vars);
export const deleteEnvVar = (key: string) =>
fetch(`/api/settings/env/${encodeURIComponent(key)}`, { method: "DELETE" })
.then((r) => json<{ vars: EnvVar[] }>(r))
.then((r) => r.vars);
/** Where `source` says the resolved data dir came from. `env` means the
* `$ORX_DATA_DIR` override forces it — the UI shows the field read-only. */
export type DataDirSource = "env" | "config" | "xdg" | "default";
export interface DataDirSettings {
current: string;
defaultPath: string;
isDefault: boolean;
source: DataDirSource;
}
export const getDataDir = () => get<DataDirSettings>("/api/settings/data-dir");
export interface DataDirValidation {
ok: boolean;
error?: string;
treeBytes?: number;
freeBytes?: number;
sameFilesystem?: boolean;
}
export const validateDataDir = (path: string) =>
post<DataDirValidation>("/api/settings/data-dir/validate", { path });
/** Set the path without moving (onboarding / already-populated target). */
export const setDataDir = (path: string) =>
post<DataDirSettings>("/api/settings/data-dir", { path });
/** Kick off a relocate. Resolves once the move has *started* (HTTP 202); watch
* `onDataDirMove` (events.ts) for `progress` / `done` / `error`. Throws on the
* 409 in-flight guard with the server's message. */
export const moveDataDir = (path: string) =>
post<{ started: boolean }>("/api/settings/data-dir/move", { path });
export interface SshHost {
host: string;
hostname?: string;
user?: string;
port?: string;
identityFile?: string;
/** Most recent preflight result, persisted across restarts. */
lastTest?: SshPreflight;
}
export const getSshHosts = () =>
get<{ hosts: SshHost[] }>("/api/settings/ssh").then((r) => r.hosts);
export interface SshPreflight {
reachable: boolean;
gitFound: boolean;
error: string | null;
/** Unix millis. */
testedAt: number;
}
/** Live-test a host: reachable over ssh (BatchMode) and has `git`. */
export const sshPreflight = (host: string) =>
post<SshPreflight>("/api/settings/ssh/preflight", { host });
// --- settings: slurm ----------------------------------------------------------
export interface SlurmSettings {
/** Default login node (an ~/.ssh/config alias); null = must pass --host. */
host: string | null;
/** Cluster defaults; null = the cluster decides. */
partition: string | null;
account: string | null;
timeLimit: string | null;
/** Login-node candidates, from ~/.ssh/config (same source as SSH). */
hosts: SshHost[];
}
export const getSlurmSettings = () => get<SlurmSettings>("/api/settings/slurm");
/** Empty string clears a field back to the cluster default. */
export const saveSlurmSettings = (body: {
host?: string;
partition?: string;
account?: string;
timeLimit?: string;
}) => post<SlurmSettings>("/api/settings/slurm", body);
export interface SlurmPreflight {
reachable: boolean;
slurmFound: boolean;
gitFound: boolean;
partitions: string[];
error: string | null;
}
/** Live-test a login node: reachable, Slurm CLI + git present, partitions. */
export const slurmPreflight = (host: string) =>
post<SlurmPreflight>("/api/settings/slurm/preflight", { host });
// --- settings: compute targets (unified list + default) ------------------------
export type ComputeTargetId =
| "local"
| "hf"
| "modal"
| "k8s"
| "ssh"
| "slurm"
| "openresearch";
/** Cheap fs/env probe only — "worth trying", not "healthy". Deep health lives
* in each backend's own settings endpoint, fetched when its row is expanded. */
export interface ComputeTargetSummary {
id: ComputeTargetId;
configured: boolean;
summary: string;
}
export interface ComputeSettings {
defaultBackend: ComputeTargetId | null;
defaultFlavor: string | null;
targets: ComputeTargetSummary[];
}
export const getComputeSettings = () => get<ComputeSettings>("/api/settings/compute");
/** Set (or clear, with backend: null) the default compute target. Responds
* with the full compute payload so the caller reconciles in one shot. */
export const setComputeDefault = (body: {
backend: ComputeTargetId | null;
flavor?: string | null;
}) => post<ComputeSettings>("/api/settings/compute/default", body);
export interface LocalGpu {
name: string;
memMib: number | null;
}
/** What `--backend local` runs on: this machine's detected hardware. */
export interface LocalMachine {
hostname: string;
os: string;
arch: string;
/** CPU brand string on macOS (e.g. "Apple M2 Pro"). */
chip: string | null;
cpuCount: number;
memBytes: number | null;
gpus: LocalGpu[];
}
export const getLocalMachine = () => get<LocalMachine>("/api/settings/local");
export interface OpenResearchSettings {
loggedIn: boolean;
apiUrl: string | null;
orgs: string[];
/** null = signed in but the key check failed (see error). */
sshKeyRegistered: boolean | null;
error: string | null;
}
export const getOpenResearchSettings = () =>
get<OpenResearchSettings>("/api/settings/openresearch");
/** The experiment a top-level files folder is named for (folder == slug). */
export interface FileExperiment {
id: string;
slug: string;
title?: string;
branchName: string;
/** The experiment's most recent run status, if it has ever run. */
latestRunStatus?: string;
}
/** One node of the files tree: a file, or a directory with children. */
export interface FileEntry {
name: string;
/** Dir-relative `/`-joined path — the id for file/report/delete endpoints. */
path: string;
isDir: boolean;
/** 0 for directories. */
size: number;
modifiedAt: number;
/** Set when the dir holds a top-level report.md — renders as a report. */
reportTitle?: string;
/** Top-level dirs only: the experiment this folder corresponds to. */
experiment?: FileExperiment;
children?: FileEntry[];
}
/** Listing of the project's on-disk files directory. */
export interface ProjectFiles {
dir: string;
entries: FileEntry[];
truncated: boolean;
}
export const getFiles = (projectId: string) =>
get<ProjectFiles>(`/api/projects/${projectId}/files`);
export const getFileReport = (projectId: string, name: string) =>
get<{ markdown: string }>(
`/api/projects/${projectId}/files/report?path=${encodeURIComponent(name)}`,
);
/** Delete a file or report folder in the files dir. */
export const deleteFile = (projectId: string, path: string) =>
fetch(`/api/projects/${projectId}/files?path=${encodeURIComponent(path)}`, {
method: "DELETE",
}).then((r) => json<{ ok: boolean }>(r));
/** Raw file (images, CSVs, report figures) served by the API. */
export const fileUrl = (projectId: string, path: string) =>
`/api/projects/${projectId}/files/file?path=${encodeURIComponent(path)}`;
export interface GitSettings {
gitVersion: string | null;
userName: string | null;
userEmail: string | null;
ghInstalled: boolean;
githubTokenSource: "env" | "stored" | "gh" | null;
}
export const getGitSettings = () => get<GitSettings>("/api/settings/git");
export const saveGitSettings = (body: { userName?: string; userEmail?: string }) =>
post<GitSettings>("/api/settings/git", body);
/** Validate + persist a pasted GitHub token (stored in the synced env file). */
export const saveGitToken = (token: string) =>
post<GitSettings>("/api/settings/git/token", { token });
export const removeGitToken = () =>
fetch("/api/settings/git/token", { method: "DELETE" }).then((r) => json<GitSettings>(r));
export interface TelemetrySettings {
/** Whether anonymous usage analytics is currently on. */
enabled: boolean;
/** When off, a short human reason (e.g. "--no-telemetry flag"); null when on. */
reason: string | null;
}
export const getTelemetry = () => get<TelemetrySettings>("/api/settings/telemetry");
export const setTelemetry = (enabled: boolean) =>
post<TelemetrySettings>("/api/settings/telemetry", { enabled });
/** Record the consent decision (agree/reject) once, when the user leaves the
* onboarding step — fires unconditionally so opt-outs are counted too. */
export const recordTelemetryConsent = (enabled: boolean) =>
post<{ ok: boolean }>("/api/settings/telemetry/consent", { enabled });
export type HarnessId = "claude-code" | "codex" | "opencode";
export interface HarnessModel {
id: string;
}
/** One selectable value in a composer toggle (permission mode / reasoning). */
export interface OptionChoice {
id: string;
label: string;
}
/** The toggle vocabulary a harness supports. Empty arrays hide the control. */
export interface HarnessOptions {
permissionModes: OptionChoice[];
defaultPermissionMode?: string | null;
reasoningLevels: OptionChoice[];
defaultReasoningLevel?: string | null;
}
export interface Harness {
id: HarnessId;
name: string;
installed: boolean;
binPath?: string;
version?: string;
authenticated: boolean;
authMethod?: "oauth" | "apiKey";
account?: string;
org?: string;
plan?: string;
agentReady: boolean;
agentNote?: string;
models: HarnessModel[];
options: HarnessOptions;
}
export const getHarnesses = (refresh = false) =>
get<{ harnesses: Harness[] }>(`/api/harnesses${refresh ? "?refresh=1" : ""}`).then(
(r) => r.harnesses,
);
/** Slash-skill offered in the composer's `/` dropdown; expanded server-side. */
export interface SkillInfo {
name: string;
description: string;
argHint: string;
}
export const getSkills = () => get<{ skills: SkillInfo[] }>("/api/skills").then((r) => r.skills);
/** "openai/gpt-5.5" → "GPT 5.5", "anthropic/claude-opus-4-8" → "Opus 4.8". */
export function modelLabel(id: string): string {
const last = (id.split("/").pop() ?? id).replace(/^~/, "").replace(/^claude-/, "");
const words: string[] = [];
const nums: string[] = [];
for (const part of last.split("-")) {
if (/^\d+(\.\d+)?$/.test(part)) {
nums.push(part);
} else {
if (nums.length) words.push(nums.splice(0).join("."));
words.push(part === "gpt" ? "GPT" : part.charAt(0).toUpperCase() + part.slice(1));
}
}
if (nums.length) words.push(nums.join("."));
return words.join(" ");
}
// --- chat (unified harness sessions) ------------------------------------------
export interface ChatToolState {
status: "running" | "completed" | "error";
input?: { command?: string; filePath?: string; description?: string; [k: string]: unknown };
output?: string;
error?: string;
title?: string;
}
export interface ChatQuestionOption {
label: string;
description?: string;
}
/** An interactive request the user acts on before the harness continues. */
export interface ChatPrompt {
kind: "plan" | "permission" | "question";
resolved: boolean;
plan?: string;
/** plan: card synthesized from the turn's final text (no ExitPlanMode call). */
synthesized?: boolean;
tool?: string;
toolInput?: Record<string, unknown>;
question?: string;
header?: string;
options?: ChatQuestionOption[];
multiSelect?: boolean;
/** Answer echo, stamped on resolve: chosen labels (questions), whether the
* card was approved (plan/permission), and any freeform note. Absent on
* cards resolved without an answer (stale-card cleanup). */
answers?: string[];
approved?: boolean;
note?: string;
/** Backend resume routing id. Presence marks a HELD mid-turn card (the
* turn is blocked open waiting on this answer); absent on end-turn cards. */
nativeId?: string;
}
export interface ChatPart {
id: string;
type: string; // text | reasoning | tool | prompt
text?: string;
tool?: string;
state?: ChatToolState;
prompt?: ChatPrompt;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
parts: ChatPart[];
createdAt: number;
}
export interface ChatSession {
id: string;
projectId: string;
harness: HarnessId;
title: string | null;
model: string | null;
permissionMode: string | null;
reasoningLevel: string | null;
/** Hidden from the default Recents list, but fully intact and resumable. */
archived: boolean;
createdAt: number;
updatedAt: number;
busy: boolean;
}
export const listChatSessions = (projectId: string) =>
get<{ sessions: ChatSession[] }>(
`/api/chat/sessions?projectId=${encodeURIComponent(projectId)}`,
).then((r) => r.sessions);
/** Per-session (and per-turn) composer selections beyond the harness itself. */
export interface TurnOptions {
model?: string | null;
permissionMode?: string | null;
reasoningLevel?: string | null;
}
export const createChatSession = (
projectId: string,
harness: HarnessId,
opts: TurnOptions = {},
) =>
post<{ session: ChatSession }>("/api/chat/sessions", { projectId, harness, ...opts }).then(
(r) => r.session,
);
export const deleteChatSession = (sessionId: string) =>
fetch(`/api/chat/sessions/${sessionId}`, { method: "DELETE" }).then((r) =>
json<{ ok: boolean }>(r),
);
/** Archive/unarchive a session (archived chats stay resumable). */
export const setChatSessionArchived = (sessionId: string, archived: boolean) =>
patch<{ session: ChatSession }>(`/api/chat/sessions/${sessionId}`, { archived }).then(
(r) => r.session,
);
/** Rename a session. The title is trimmed server-side; empty titles are rejected. */
export const renameChatSession = (sessionId: string, title: string) =>
patch<{ session: ChatSession }>(`/api/chat/sessions/${sessionId}`, { title }).then(
(r) => r.session,
);
export const getChatMessages = (sessionId: string) =>
get<{ messages: ChatMessage[] }>(`/api/chat/sessions/${sessionId}/messages`).then(
(r) => r.messages,
);
/** Pasted image riding a chat message. */
export interface ChatImageAttachment {
mediaType: string;
dataBase64: string;
}
/** Image parts store a server-minted file name; this is where it's served. */
export const chatAttachmentUrl = (name: string) =>
`/api/chat/attachments/${encodeURIComponent(name)}`;
/** Returns immediately; the turn streams over /api/events (chat.* events). */
export const sendChatMessage = (
sessionId: string,
text: string,
opts: TurnOptions = {},
images?: ChatImageAttachment[],
) =>
post<{ ok: boolean }>(`/api/chat/sessions/${sessionId}/message`, {
text,
model: opts.model,
permissionMode: opts.permissionMode,
reasoningLevel: opts.reasoningLevel,
images,
});
export const interruptChat = (sessionId: string) =>
post<{ ok: boolean }>(`/api/chat/sessions/${sessionId}/interrupt`);
/** Answer an interactive prompt (plan / permission / question) on a session. */
export interface PromptAnswer {
promptId: string;
approve?: boolean;
/** Permission mode to resume under (plan/permission approval). */
resumeMode?: string;
/** Chosen option labels (questions). */
answers?: string[];
note?: string;
}
export const respondChat = (sessionId: string, answer: PromptAnswer) =>
post<{ ok: boolean }>(`/api/chat/sessions/${sessionId}/respond`, answer);
// --- helpers shared across views --------------------------------------------
export function statusColor(status: string): string {
switch (status) {
case "done":
return "var(--green)";
case "running":
return "var(--teal)";
case "starting":
return "var(--amber)";
case "failed":
return "var(--red)";
case "cancelled":
return "var(--muted)";
default:
return "var(--muted)";
}
}
export function timeAgo(ms: number): string {
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/** "42s" / "18m" / "2h 28m" / "1d 4h" — an elapsed duration, not a timestamp. */
export function fmtDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ${m % 60}m`;
return `${Math.floor(h / 24)}d ${h % 24}h`;
}
/** Compact byte size, e.g. "512 B", "2.0 KB", "5.3 MB". Mirrors the backend's
* `store::human_bytes`. */
export function fmtBytes(n: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let v = n;
let u = 0;
while (v >= 1024 && u < units.length - 1) {
v /= 1024;
u += 1;
}
return u === 0 ? `${n} B` : `${v.toFixed(1)} ${units[u]}`;
}
export function shortId(id: string): string {
return id.length > 10 ? `${id.slice(0, 10)}…` : id;
}
/** The backend kind from a run's `backend` descriptor ("modal_job", "hf_job", …). */
export function backendKind(backend: Run["backend"]): string {
if (!backend) return "";
if (typeof backend.kind === "string") return backend.kind;
if (typeof backend.type === "string") return backend.type;
return "";
}
/** The flavor / manifest / host that qualifies a backend, if any. k8s runs
* carry a manifest path instead of a flavor; ssh a host in `namespace`. */
export function backendDetail(backend: Run["backend"]): string {
if (!backend) return "";
if (typeof backend.flavor === "string" && backend.flavor) return backend.flavor;
if (typeof backend.manifest === "string" && backend.manifest) return backend.manifest;
if (typeof backend.namespace === "string" && backend.namespace) return backend.namespace;
return "";
}