Files
OpenResearch/ui/src/api.ts
T
Daniel Kim 979d8e9362 Rework first-run onboarding and the New project modal (v0.1.79) (#126)
* Tighten the onboarding + new-project flows

Onboarding:
- Gate steps 1 and 2 (a signed-in agent, GitHub access) — both are hard
  requirements for chat and project creation, so Continue is disabled
  until they're met.
- Drop the data-dir step: the default suits ~everyone, it's write-once,
  and Settings > Storage already exposes it with a move flow.
- Split harness notes into not-installed vs not-signed-in, each with the
  real command (claude auth login / codex login / opencode auth login).
- Fix OpenCode reporting Connected while unauthenticated (agent_ready
  ignored authentication).

New project modal:
- Auto-open on the onboarding hand-off.
- Only show the fork choice when the user can actually push (new
  /api/github/repo-access), and say what happens when they can't.
- Name the real GitHub account in repo previews (new /api/github/account).
- Drop the branch picker; resolve the repo's real default branch server
  side instead of assuming "main".
- Describe the copy honestly: it's a single-branch, history-less snapshot,
  not a GitHub fork.
- Top-anchor the modal so the tab row doesn't move between tabs.

* Review round 1: fix onboarding lockouts, trapped name field, stale fork mode

- opencode: treat a non-empty `opencode models` list as authenticated, so
  env-key installs (no auth.json) aren't locked out by the new step-1 gate.
  Claude and Codex already had the equivalent fallback.
- Onboarding: surface a load error. A rejected probe left harnesses/git at
  null forever, which the gates read as not-ready — disabling Continue with
  no hint and no way out, on the screen that is the whole app at first boot.
- NewProjectForm: hoist the project-name field out of repoFields. A stalled
  access check used to unmount it, and `valid` needs a name, so the form
  became unsubmittable with no escape.
- NewProjectForm: reset canPush on every repo change and sync repoMode to
  the answer, so a forced "fork" can't linger and silently copy a repo the
  user can push to.
- github.rs: share one authed_get helper (with a 10s timeout — there was
  none), URL-encode owner/repo, drop the now-dead has_push_access wrapper.
- Drop the unread `known`/`defaultBranch` fields from the repo-access
  response and its TS type.
- ProjectsHome: consume the post-onboarding hand-off so a later remount
  can't re-open the modal.
- Deduplicate the paper-repo hint and the two identical code-chip CSS rules;
  tie the modal offset and max-height together with a --modal-top token.

* Review round 2: correct the opencode fallback, unblock SSH-only users

The round-1 opencode fix rested on a false premise — I claimed `opencode
models` only lists authenticated providers. Tested: under a signed-out HOME
it returns nothing, so the fallback never fired for the users it was meant to
rescue, and had the catalog ever been listed it would have marked signed-out
installs Connected. Replaced with an explicit provider env-key check, matching
claude.rs. Verified both directions: env key set -> ready, none -> gated.

- Onboarding: step 2 gets a "Skip — I use SSH keys" escape. ensure_clone
  tries ssh first, so a token isn't actually required for the use-existing
  path, and git_settings_json can't see SSH keys at all — the hard gate was
  locking out a setup the CLI's own startup note calls supported. Also covers
  the git-not-installed dead end, where the token form is unreachable.
- Onboarding: split the shared loadError into per-probe flags. A telemetry
  failure was putting a connectivity error on unrelated gates and hiding the
  actionable hint behind it.
- NewProjectForm: only force "fork" on denied access; the symmetric sync was
  undoing selectPaper's deliberate copy default for writable paper repos.
  Reset repoMode in onRepoChange instead, which is where the repo changes.
- github.rs: drop the deleted has_push_access's doc comment, left welded onto
  authed_get and describing the wrong thing.
- Bump to v0.1.79 (+ Cargo.lock): ui/dist changes don't ship without it.

* Review round 3: detect synced-env keys, unstick the branch fallback

- Harness detection: resolve API keys through orx's synced env file as well
  as the process env (new detect::api_key, used by claude.rs and opencode.rs).
  A key pasted into Settings > Environment is handed to the harness child, so
  chat works — but detect() couldn't see it, and step 1 is a hard gate.
  Verified: key only in ~/.openresearch/env now reports ready for both.
- opencode: widen the provider key list (Gemini/Groq/xAI/DeepSeek). Measured
  rather than assumed that `opencode models` can't stand in for a credential:
  signed out with no keys it still lists 7 free/bundled models, so keying off
  it would report signed-out installs as Connected.
- baseline_branch: fall back to `git ls-remote --symref` when the GitHub API
  can't answer. Without a token the branch resolved to a hardcoded "main" and
  the clone died on any master/dev repo — and the form no longer has a branch
  field to recover with. git's own ssh/https credentials need no token.
- Step 1 no longer gates on a failed probe: detection fans out to per-binary
  version/model commands that can stall, and there's no way out of onboarding.
- Step 2's skip is always offered and reworded "Skip for now": a stale gh
  token still reads as connected, hiding the escape from the users who need
  it most. Promoted to a real button rather than a muted link.

* Rebuild ui/dist after rebase

* Review round 4: stop 404 forcing a history-losing copy

- repo_meta: a 404 means "invisible to this token", which is also how a
  private repo looks to an SSH-only user who can push to it fine. It was
  reported as can_push=false, which force-forks — and this branch removed the
  "Use this repo" button in that state, so the override was gone too. The
  user would silently get a new repo with their history dropped. Report
  unknown instead, matching the fallback used everywhere else.
- remote_default_branch: bound it with a 15s timeout. ls-remote tries ssh then
  https, and a black-holed host blocks on TCP connect ~75s each — the exact
  hazard authed_get got a timeout for last round, reintroduced on the git path
  of the same request. A stall now degrades to "main" instead of hanging.
- git.rs: the new function had been inserted between ensure_clone's doc
  comment and ensure_clone, so the comment described the wrong function and
  ensure_clone was left undocumented.
- Never show a "Detecting…" spinner next to a fetch error: on a failed probe
  both steps now render the retry copy in place of the card, instead of a
  spinner that claims work is in progress beside an enabled Continue.
- onRepoChange no longer resets repoMode in paper mode, where editing the
  auto-filled repo would quietly retarget pushes to the upstream paper repo.

* Review round 5: make step 2 an honest soft gate, fix stale-state contradictions

- Step 2 no longer pretends to gate. Cloning and pushing work over SSH keys,
  so a disabled Continue beside an always-enabled "Skip for now" was two
  buttons doing the same thing — one greyed out, which reads as a bug. One
  Continue, always enabled, and the hint states what's missing instead of
  blocking on it.
- A rejected probe now clears its state as well as setting the error flag.
  Otherwise a failed *re-check* kept the old cards on screen: stale "Not
  signed in" badges and a "sign in to continue" hint next to a Continue that
  harnessError had just un-gated, with no indication the check had failed.
- Mode switches reset repoMode via a chooseMode helper. Leaving paper mode
  carries the paper's repo into the field but not its copy default — and
  because the field is pre-filled, onRepoChange never fired to reset it, so
  "Private copy" stayed selected on a repo the user never chose it for.
- Deduplicate the project-name field and blank-repo hint, both of which were
  inlined a third time in "New blank repo" (flagged since round 2).

* Review round 6: restore the Existing repo name field, kill the footgun

Round 5's nameField refactor turned it into a function but updated only two of
three call sites. `{parsed && nameField}` passes the function itself as a
React child, which renders nothing — so "Existing repo" had no Project name
input and Create stayed disabled. Both tsc and a grep of the call sites missed
it; three reviewers caught it.

Made the class of bug impossible rather than just fixing the call site: the
autoFocus parameter was the only reason nameField was a function, so it's a
plain element again like blankRepoHint. Verified in a browser this time —
name field present and submit enabled in every mode.

- Onboarding: the docstring still called step 2 a hard gate, which round 5
  made false, and it sat below the RETRY_COPY const so it documented the wrong
  symbol. Rewritten and moved.
- Onboarding: sequence-guard load(). A token save landing mid-refresh was
  overwritten by the pre-save snapshot, leaving the card reading "Not
  connected" until the next check; the save now supersedes in-flight probes.

* Review round 7: fix a Re-check lockout, share renderNote, trim comments

- load()'s .finally was sequence-guarded, so a token save (which bumps loadSeq
  to supersede the in-flight probe) left `checking` true forever and both
  Re-check buttons dead for the session. `checking` is "a load is running",
  not data a stale response can corrupt — it shouldn't be guarded. Introduced
  by round 6's guard; verified fixed by superseding a slow load in a browser.
- renderNote moved to its own module: ModelPicker, a core chat control, was
  importing it from the first-run walkthrough.
- Drop the redundant {parsed && …} guard (repoFields already returns null),
  a doubled CSS comment block, and two stacked rationale comments.
- Document the opencode agent_ready tightening where the behaviour changes:
  installs with no auth.json and no provider key now read "Not signed in".
2026-07-26 21:48:33 -07:00

1013 lines
33 KiB
TypeScript
Raw Permalink 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;
/** Absolute path of the project's files dir (`<data dir>/files/<slug>`),
* non-canonical to match the paths agents inline into chat. Lets the UI
* recognize a files-dir link and route it to the files endpoints. */
filesDir: 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;
githubOrganization?: 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,
);
/** The signed-in GitHub login, for naming the account a new repo lands on.
* `login` is null when there's no usable token. */
export const githubAccount = () => get<{ login: string | null }>("/api/github/account");
/** Whether the stored credentials can push to a repo. An unanswerable check
* (no token / API hiccup) reports `true`, matching the server's own fallback,
* so an outage never shows a fork choice the server wouldn't honour. */
export const repoAccess = (owner: string, repo: string) =>
get<{ canPush: boolean }>(
`/api/github/repo-access?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
);
/** 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` picks the session's live worktree; neither picks the hub
* clone. Don't send both — the file endpoint ignores `sessionId` under `ref`,
* but code-tree rejects the combination outright. */
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 a chat session's live worktree when `sessionId` is given, else
* the hub clone's checkout — plus the branch name. `ref` and `sessionId` are
* mutually exclusive (the server rejects both). */
export const getCodeTree = (projectId: string, opts: CheckoutRef = {}) => {
const qs = checkoutQuery(opts).toString();
return get<CodeTree>(`/api/projects/${projectId}/code-tree${qs ? `?${qs}` : ""}`);
};
/** How a file in a session's worktree differs from the diff base. Lowercase to
* match the server's serialization and the single-letter badges the UI draws. */
export type ChangedStatus = "added" | "modified" | "deleted" | "renamed" | "untracked";
export interface ChangedFile {
path: string;
status: ChangedStatus;
/** Pre-rename path — present only for `renamed` entries. */
oldPath?: string;
}
/** Live view of a chat session's private worktree. `exists: false` when the
* agent hasn't started yet (the worktree is created lazily on the first turn)
* or was pruned — the remaining fields are then absent. `files` is the
* complete change list even when `diff` truncates (they come from separate git
* passes); `diff` is the working tree against the baseline merge-base, with
* untracked files rendered as new-file diffs. */
export interface SessionWorktree {
exists: boolean;
/** Checked-out branch, or null when detached at the baseline tip. */
branch?: string | null;
baselineBranch?: string;
baseSha?: string;
files?: ChangedFile[];
diff?: DiffPayload;
}
export const getSessionWorktree = (sessionId: string) =>
get<SessionWorktree>(`/api/chat/sessions/${sessionId}/worktree`);
/** 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;
/**
* The readiness check couldn't run (offline, unreadable ~/.ssh), so
* `configured` is a guess rather than an answer. Absent for backends whose
* state is decidable locally.
*/
unverified?: 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[];
/**
* Whether a registered key's private half is on THIS machine — `matched` is
* the only state that can actually reach a box. Optional: an older `orx`
* binary serving a newer ui omits it.
*/
sshKeyStatus?: "matched" | "no_local_match" | "none_registered" | "unknown";
/** The `.pub` on this machine worth registering; null if there isn't one. */
sshKeyPath?: string | 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)}`;
/** Text body of a files-dir file (raw bytes decoded as UTF-8), or `null` when
* the file is missing (404). The endpoint returns bytes, not JSON, so this
* bypasses the `get`/`json` helpers; a 404 is a normal "not found", not an
* error to surface. */
export const getFilesDirFileText = (projectId: string, path: string): Promise<string | null> =>
fetch(fileUrl(projectId, path)).then((r) => {
if (r.status === 404) return null;
// Bare message — the viewer prefixes "Failed to load file:" itself.
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
});
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;
}
/** How much of the model's context window a session has used, measured off the
* most recent API request (latest wins, not cumulative). `contextWindow` is
* absent when the harness doesn't report one. */
export interface ContextUsage {
usedTokens: number;
contextWindow?: 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;
contextUsage?: ContextUsage;
}
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]}`;
}
/** Compact token count, e.g. 62300 → "62k", 1_200_000 → "1.2M", 940 → "940". */
export function fmtTokens(n: number): string {
if (n < 1000) return `${Math.round(n)}`;
// One decimal, dropped when it's .0 ("31.4k", "200k", "1M"). The k branch
// stops where toFixed(1) would round to "1000.0" (e.g. 999_960 → "1M").
if (n < 999_950) return `${trimZero((n / 1000).toFixed(1))}k`;
return `${trimZero((n / 1_000_000).toFixed(1))}M`;
}
function trimZero(s: string): string {
return s.endsWith(".0") ? s.slice(0, -2) : s;
}
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 "";
}