diff --git a/desktop/index.html b/desktop/index.html index 1ae7c95..a324904 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -7,16 +7,40 @@
-
-
Loopwright
-
-
+ + + + +
diff --git a/desktop/src/main.ts b/desktop/src/main.ts index fb3c97a..e628e4c 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -18,6 +18,7 @@ import type { RunMessage, SessionRecord, TraceResponse } from "./types.js"; const view = document.getElementById("view") as HTMLElement; const engineStatus = document.getElementById("engine-status") as HTMLElement; +const engineLabel = engineStatus.querySelector(".engine-label") as HTMLElement; // --- tiny DOM helpers ------------------------------------------------------- @@ -29,6 +30,7 @@ function h( const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") node.className = v; + else if (k === "html") node.innerHTML = v; else node.setAttribute(k, v); } for (const c of children) node.append(c); @@ -39,6 +41,24 @@ function badge(state: string): HTMLElement { return h("span", { class: `badge state-${state}` }, [state]); } +/** Inline SVG icon as an element (24x24 viewBox, stroke-based). */ +function icon(path: string, cls = ""): HTMLElement { + const span = h("span", cls ? { class: cls } : {}); + span.innerHTML = ``; + return span; +} + +const ICONS = { + plan: '', + loop: '', + verify: '', + rocket: '', + inbox: '', + key: '', + arrow: '', + caret: '', +}; + /** Valid POSIX-ish environment variable name (used for secret keys). */ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -52,68 +72,251 @@ function navigate(nav: Nav, arg?: string): void { teardown(); teardown = null; } - document.querySelectorAll("nav button").forEach((b) => { + document.querySelectorAll(".nav-item").forEach((b) => { b.classList.toggle("active", (b as HTMLElement).dataset.nav === nav); }); view.innerHTML = ""; + view.scrollTop = 0; if (nav === "start") renderStart(); else if (nav === "sessions") renderSessions(); else if (nav === "secrets") renderSecrets(); void arg; } -document.querySelectorAll("nav button").forEach((b) => { +document.querySelectorAll(".nav-item").forEach((b) => { b.addEventListener("click", () => navigate((b as HTMLElement).dataset.nav as Nav)); }); +function clearNavActive(): void { + document.querySelectorAll(".nav-item").forEach((b) => b.classList.remove("active")); +} + // --- Start view ------------------------------------------------------------- -const SAMPLE_RUNNERS = JSON.stringify( - [ - { - id: "primary", - kind: "http", - model: "gpt-4o-mini", - options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" }, - }, - ], - null, - 2, -); +interface Preset { + label: string; + json: string; +} + +const PRESETS: Record = { + openai: { + label: "OpenAI (gpt-4o-mini)", + json: JSON.stringify( + [{ id: "primary", kind: "http", model: "gpt-4o-mini", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }], + null, + 2, + ), + }, + anthropic: { + label: "Anthropic (claude-3-5-sonnet)", + json: JSON.stringify( + [{ id: "primary", kind: "http", model: "claude-3-5-sonnet-latest", options: { baseUrl: "https://api.anthropic.com/v1", apiKeyEnv: "ANTHROPIC_API_KEY" } }], + null, + 2, + ), + }, + local: { + label: "Local (Ollama / OpenAI-compatible)", + json: JSON.stringify( + [{ id: "primary", kind: "http", model: "llama3.1", options: { baseUrl: "http://localhost:11434/v1", apiKeyEnv: "OLLAMA_API_KEY" } }], + null, + 2, + ), + }, + split: { + label: "Split actor + critic", + json: JSON.stringify( + [ + { id: "builder", kind: "http", model: "gpt-4o-mini", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }, + { id: "reviewer", kind: "http", model: "gpt-4o", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }, + ], + null, + 2, + ), + }, +}; + +const EXAMPLE_GOALS = [ + "Add a /healthz endpoint with a test", + "Fix the failing auth middleware tests", + "Add input validation to the signup form", + "Refactor the config loader to use zod", +]; function renderStart(): void { + const page = h("section", { class: "page" }); + + // Header + page.append( + h("div", { class: "page-head" }, [ + h("div", { class: "eyebrow" }, ["Actor – Critic loop"]), + h("h1", {}, ["Start a new run"]), + h("p", { class: "page-sub" }, [ + "Describe a goal in plain language. Loopwright plans the work, an actor agent implements it, a critic agent reviews each change, and the loop repeats until the work is verified.", + ]), + ]), + ); + + // How it works + const steps = h("div", { class: "steps" }); + const stepData: Array<[string, string, string, string]> = [ + ["1", ICONS.plan, "Plan", "The goal is broken into small, independently verifiable tasks."], + ["2", ICONS.loop, "Build & critique", "An actor writes code; a critic reviews it. They iterate until it holds up."], + ["3", ICONS.verify, "Verify & integrate", "Mechanical checks run, branches merge, and results are traced end to end."], + ]; + for (const [n, p, title, desc] of stepData) { + steps.append( + h("div", { class: "step", "data-n": n }, [ + icon(p, "step-ico"), + h("h4", {}, [title]), + h("p", {}, [desc]), + ]), + ); + } + page.append(steps); + + // Form const form = h("form", { class: "card form" }); - form.innerHTML = ` -

Start a run

- - -
- - -
-
- - - -
-
- - -
- `; - (form.querySelector("[name=runners]") as HTMLTextAreaElement).value = SAMPLE_RUNNERS; + + // -- Goal field + const goalField = h("div", { class: "field" }); + const goalArea = h("textarea", { + id: "start-goal", + name: "goal", + rows: "3", + placeholder: "e.g. Add a /healthz endpoint that returns 200 and write a test for it", + required: "true", + }) as HTMLTextAreaElement; + const chips = h("div", { class: "chips" }); + for (const ex of EXAMPLE_GOALS) { + const chip = h("button", { type: "button", class: "chip" }, [ex]); + chip.addEventListener("click", () => { + goalArea.value = ex; + goalArea.focus(); + }); + chips.append(chip); + } + goalField.append( + h("label", { class: "label", for: "start-goal" }, ["Goal"]), + h("div", { class: "desc" }, ["What should the agents accomplish? Be specific about the outcome you expect."]), + goalArea, + chips, + ); + + // -- Model / runner field + const runnerField = h("div", { class: "field" }); + const presetSelect = h("select", { id: "start-preset", name: "preset" }) as HTMLSelectElement; + for (const [key, p] of Object.entries(PRESETS)) { + presetSelect.append(h("option", { value: key }, [p.label])); + } + const runnersArea = h("textarea", { name: "runners", rows: "10", spellcheck: "false", class: "mono", "aria-label": "Runner profiles (JSON)" }) as HTMLTextAreaElement; + runnersArea.value = PRESETS.openai!.json; + + const advanced = h("details", { class: "advanced" }); + const summary = h("summary", {}, [icon(ICONS.caret, "caret"), "Advanced — edit runner profiles (JSON)"]); + advanced.append( + summary, + h("div", { class: "advanced-body" }, [ + runnersArea, + h("small", { class: "desc" }, [ + "Maps to LOOPWRIGHT_RUNNERS. API keys are referenced by env var name (apiKeyEnv) and resolved from secure storage — never pasted here.", + ]), + ]), + ); + + presetSelect.addEventListener("change", () => { + const p = PRESETS[presetSelect.value]; + if (p) { + runnersArea.value = p.json; + syncRunnerIds(); + } + }); + + runnerField.append( + h("label", { class: "label", for: "start-preset" }, ["Model provider"]), + h("div", { class: "desc" }, ["Pick a preset to get started, or open Advanced to define your own runner profiles."]), + presetSelect, + advanced, + ); + + // -- Roles + const rolesGrid = h("div", { class: "field-grid" }); + const actorInput = h("input", { type: "text", name: "actor", value: "primary", list: "runner-ids" }) as HTMLInputElement; + const criticInput = h("input", { type: "text", name: "critic", value: "primary", list: "runner-ids" }) as HTMLInputElement; + const idDatalist = h("datalist", { id: "runner-ids" }); + rolesGrid.append( + h("label", { class: "inline-label" }, ["Actor runner", h("span", { class: "desc" }, ["Writes the code"]), actorInput]), + h("label", { class: "inline-label" }, ["Critic runner", h("span", { class: "desc" }, ["Reviews the code"]), criticInput]), + idDatalist, + ); + const rolesField = h("div", { class: "field" }, [ + h("div", { class: "label" }, ["Roles"]), + h("div", { class: "desc" }, ["Which runner profile plays each role. They can be the same."]), + rolesGrid, + ]); + + function syncRunnerIds(): void { + idDatalist.innerHTML = ""; + try { + const arr = JSON.parse(runnersArea.value) as Array<{ id?: string }>; + const ids = arr.map((r) => r.id).filter((x): x is string => typeof x === "string"); + for (const id of ids) idDatalist.append(h("option", { value: id })); + // If current actor/critic aren't valid ids, point them at the first one. + if (ids[0] && !ids.includes(actorInput.value)) actorInput.value = ids[0]; + if (ids[0] && !ids.includes(criticInput.value)) criticInput.value = ids[0]; + } catch { + /* invalid JSON — leave inputs as-is, validated on submit */ + } + } + runnersArea.addEventListener("input", syncRunnerIds); + syncRunnerIds(); + + // -- Options + const optionsField = h("div", { class: "field" }); + const options = h("div", { class: "options" }); + + function optionRow(name: string, title: string, desc: string, checked: boolean): HTMLElement { + const input = h("input", { type: "checkbox", name, "aria-label": title }) as HTMLInputElement; + if (checked) input.checked = true; + return h("div", { class: "option" }, [ + h("div", { class: "option-text" }, [h("strong", {}, [title]), h("span", {}, [desc])]), + h("label", { class: "switch" }, [input, h("span", { class: "track" })]), + ]); + } + + // Max parallel stepper + const parallelInput = h("input", { type: "number", name: "maxParallel", min: "1", value: "2", "aria-label": "Max parallel tasks" }) as HTMLInputElement; + const dec = h("button", { type: "button", "aria-label": "decrease" }, [icon("")]); + const inc = h("button", { type: "button", "aria-label": "increase" }, [icon("")]); + dec.addEventListener("click", () => { parallelInput.value = String(Math.max(1, Number(parallelInput.value || "1") - 1)); }); + inc.addEventListener("click", () => { parallelInput.value = String(Number(parallelInput.value || "1") + 1); }); + const stepper = h("div", { class: "stepper" }, [parallelInput, h("div", { class: "steps-btns" }, [inc, dec])]); + + options.append( + h("div", { class: "option" }, [ + h("div", { class: "option-text" }, [h("strong", {}, ["Max parallel tasks"]), h("span", {}, ["How many tasks run at the same time."])]), + stepper, + ]), + optionRow("worktrees", "Use git worktrees", "Isolate each task in its own worktree so parallel work never collides.", false), + optionRow("gate", "Mechanical gate", "Run build / test / lint checks before the critic reviews each change.", true), + ); + optionsField.append(h("div", { class: "label" }, ["Options"]), options); + + // -- Submit + const hint = h("span", { class: "hint", id: "start-hint" }); + const submit = h("button", { type: "submit", class: "primary" }, [icon(ICONS.rocket), "Start run"]); + const actions = h("div", { class: "actions" }, [submit, hint]); + + form.append(goalField, runnerField, rolesField, optionsField, actions); form.addEventListener("submit", async (e) => { e.preventDefault(); - const hint = form.querySelector("#start-hint") as HTMLElement; const data = new FormData(form); const goal = String(data.get("goal") ?? "").trim(); - if (!goal) return; + if (!goal) { + goalArea.focus(); + return; + } const env: Record = { LOOPWRIGHT_RUNNERS: String(data.get("runners") ?? "").trim(), @@ -128,56 +331,49 @@ function renderStart(): void { // Validate JSON early so the user gets a clear message, not a 400. if (env.LOOPWRIGHT_RUNNERS) JSON.parse(env.LOOPWRIGHT_RUNNERS); } catch (err) { + advanced.setAttribute("open", "true"); hint.textContent = `Runner profiles must be valid JSON: ${(err as Error).message}`; - hint.classList.add("error"); + hint.className = "hint error"; return; } hint.textContent = "Starting…"; - hint.classList.remove("error"); + hint.className = "hint"; + submit.setAttribute("disabled", "true"); try { const sessionId = await startRun({ goal, env }); renderMonitor(sessionId, goal); } catch (err) { + submit.removeAttribute("disabled"); hint.textContent = `Failed to start: ${(err as Error).message}`; - hint.classList.add("error"); + hint.className = "hint error"; } }); - view.append(form); + page.append(form); + view.append(page); } // --- Monitor view ----------------------------------------------------------- function renderMonitor(sessionId: string, goal: string): void { - document.querySelectorAll("nav button").forEach((b) => b.classList.remove("active")); + clearNavActive(); view.innerHTML = ""; - - const header = h("div", { class: "card" }); - header.innerHTML = ` -

Live run

-
${escapeHtml(goal)}
-
session ${escapeHtml(sessionId)}
-
running…
-
- `; - - const usage = h("div", { class: "card usage" }); - usage.innerHTML = `

Usage

no runner calls yet
`; - - const tasksCard = h("div", { class: "card" }, [h("h3", {}, ["Tasks"])]); - const tasksTable = h("table", { class: "tasks" }); - tasksTable.innerHTML = `TaskStateDetail`; - tasksCard.append(tasksTable); - - const logCard = h("div", { class: "card" }, [h("h3", {}, ["Engine log"])]); - const log = h("pre", { class: "log", id: "log" }); - logCard.append(log); - - view.append(header, usage, tasksCard, logCard); + view.scrollTop = 0; + const page = h("section", { class: "page" }); + + // Run header + const phase = h("div", { class: "phase running", id: "phase" }, ["running…"]); + const plan = h("div", { class: "plan", id: "plan" }); + const stopBtn = h("button", { class: "danger small" }, ["Stop run"]); + + const header = h("div", { class: "card run-head" }, [ + h("div", { class: "goal" }, [goal]), + h("div", { class: "meta-row" }, [phase, stopBtn, h("span", { class: "session-id" }, [`session ${sessionId}`])]), + plan, + ]); // Stop control: requests cooperative cancellation of the in-flight run. - const stopBtn = h("button", { class: "danger" }, ["Stop run"]); stopBtn.addEventListener("click", async () => { stopBtn.setAttribute("disabled", "true"); stopBtn.textContent = "Stopping…"; @@ -186,10 +382,26 @@ function renderMonitor(sessionId: string, goal: string): void { } catch (err) { stopBtn.removeAttribute("disabled"); stopBtn.textContent = "Stop run"; - (document.getElementById("plan") as HTMLElement).textContent = `Cancel failed: ${(err as Error).message}`; + plan.textContent = `Cancel failed: ${(err as Error).message}`; } }); - (header.querySelector("#phase") as HTMLElement).append(" ", stopBtn); + + const usage = h("div", { class: "card" }, [ + h("h3", {}, ["Usage"]), + h("div", { class: "usage-body", id: "usage-body" }, ["No runner calls yet."]), + ]); + + const tasksCard = h("div", { class: "card" }, [h("h3", {}, ["Tasks"])]); + const tasksTable = h("table", { class: "tasks" }); + tasksTable.innerHTML = `TaskStateDetail`; + tasksCard.append(tasksTable); + + const logCard = h("div", { class: "card" }, [h("h3", {}, ["Engine log"])]); + const log = h("pre", { class: "log", id: "log" }); + logCard.append(log); + + page.append(header, usage, tasksCard, logCard); + view.append(page); const taskRows = new Map(); let actorCalls = 0; @@ -236,13 +448,13 @@ function renderMonitor(sessionId: string, goal: string): void { else if (ev.data.role === "critic") criticCalls++; totalTokens += Number(ev.data?.usage?.totalTokens ?? 0); (document.getElementById("usage-body") as HTMLElement).textContent = - `actor ${actorCalls} calls · critic ${criticCalls} calls · ${totalTokens} tokens`; + `actor ${actorCalls} calls · critic ${criticCalls} calls · ${totalTokens.toLocaleString()} tokens`; } else if (ev.type === "plan_reviewed") { (document.getElementById("plan") as HTMLElement).textContent = - `plan: approved=${ev.data.approved} · revisions=${ev.data.revisions} · open items=${ev.data.openItems}`; + `Plan: approved=${ev.data.approved} · revisions=${ev.data.revisions} · open items=${ev.data.openItems}`; } } else if (msg.type === "status") { - const phase = document.getElementById("phase") as HTMLElement; + const phaseEl = document.getElementById("phase") as HTMLElement; if (msg.data.phase === "done" || msg.data.phase === "error") { stopBtn.remove(); // run is over; no longer cancellable } @@ -254,21 +466,21 @@ function renderMonitor(sessionId: string, goal: string): void { const integrationFailed = r.integration && r.integration.ok === false; const needsHuman = Array.isArray(r.needsHuman) && r.needsHuman.length > 0; if (integrationFailed) { - phase.className = "phase error"; - phase.textContent = "integration failed — needs attention"; + phaseEl.className = "phase error"; + phaseEl.textContent = "Integration failed — needs attention"; } else if (needsHuman) { - phase.className = "phase error"; - phase.textContent = "completed — some tasks need human attention"; + phaseEl.className = "phase error"; + phaseEl.textContent = "Completed — some tasks need a human"; } else { - phase.className = "phase done"; - phase.textContent = "completed"; + phaseEl.className = "phase done"; + phaseEl.textContent = "Completed"; } - const btn = h("button", { class: "primary" }, ["View results"]); + const btn = h("button", { class: "primary small" }, ["View results"]); btn.addEventListener("click", () => renderResults(sessionId)); - phase.append(" ", btn); + phaseEl.after(btn); } else if (msg.data.phase === "error") { - phase.className = "phase error"; - phase.textContent = `error: ${msg.data.error}`; + phaseEl.className = "phase error"; + phaseEl.textContent = `Error: ${msg.data.error}`; } } } @@ -287,73 +499,69 @@ function renderMonitor(sessionId: string, goal: string): void { // --- Results view ----------------------------------------------------------- async function renderResults(sessionId: string): Promise { - document.querySelectorAll("nav button").forEach((b) => b.classList.remove("active")); + clearNavActive(); view.innerHTML = ""; - view.append(h("div", { class: "card" }, ["Loading trace…"])); + view.scrollTop = 0; + const loadingPage = h("section", { class: "page" }, [ + h("div", { class: "card" }, [h("div", { class: "loading" }, [h("span", { class: "spinner" }), "Loading trace…"])]), + ]); + view.append(loadingPage); let resp: TraceResponse; try { resp = await getTrace(sessionId); } catch (err) { view.innerHTML = ""; - view.append(h("div", { class: "card error" }, [`Failed to load trace: ${(err as Error).message}`])); + view.append( + h("section", { class: "page" }, [ + h("div", { class: "card error" }, [`Failed to load trace: ${(err as Error).message}`]), + ]), + ); return; } const { trace } = resp; view.innerHTML = ""; + const page = h("section", { class: "page" }); - const summary = h("div", { class: "card" }); const s = trace.session; const byState = countStates(trace); - summary.innerHTML = ` -

Results

-
${escapeHtml(s?.goal ?? "")}
-
session ${escapeHtml(sessionId)} — ${escapeHtml(s?.status ?? "?")}
-
- GREEN ${byState.GREEN} - UNVERIFIED ${byState.UNVERIFIED_BY_CRITIC} - NEEDS_HUMAN ${byState.NEEDS_HUMAN} -
- `; - const u = trace.usage; - const usage = h("div", { class: "card" }); - usage.innerHTML = ` -

Usage

- - - - - -
callspromptcompletiontotalquota hits
actor${u.perRole.actor.calls}${u.perRole.actor.promptTokens}${u.perRole.actor.completionTokens}${u.perRole.actor.totalTokens}${u.perRole.actor.quotaHits}
critic${u.perRole.critic.calls}${u.perRole.critic.promptTokens}${u.perRole.critic.completionTokens}${u.perRole.critic.totalTokens}${u.perRole.critic.quotaHits}
total${u.total.calls}${u.total.promptTokens}${u.total.completionTokens}${u.total.totalTokens}${u.total.quotaHits}
- `; + // Header + const backBtn = h("button", { class: "ghost small" }, [icon(""), "All sessions"]); + backBtn.addEventListener("click", () => navigate("sessions")); - const tasksCard = h("div", { class: "card" }, [h("h3", {}, ["Tasks"])]); - for (const t of trace.tasks) { - const block = h("div", { class: "task-block" }); - const head = h("div", { class: "task-head" }, [`${t.taskId} `, badge(t.state)]); - if (t.degradedReason) head.append(h("span", { class: "degraded" }, [` ${t.degradedReason}`])); - block.append(head); - const txs = trace.transitions.filter((x) => x.taskId === t.taskId); - if (txs.length) { - const ul = h("ul", { class: "tx" }); - for (const x of txs) ul.append(h("li", {}, [`${x.from} —(${x.event})→ ${x.to} ${x.reason}`])); - block.append(ul); - } - tasksCard.append(block); - } + const counts = h("div", { class: "counts" }, [ + h("span", { class: "count green" }, [h("span", { class: "n" }, [String(byState.GREEN)]), "Green"]), + h("span", { class: "count unverified" }, [h("span", { class: "n" }, [String(byState.UNVERIFIED_BY_CRITIC)]), "Unverified"]), + h("span", { class: "count needs-human" }, [h("span", { class: "n" }, [String(byState.NEEDS_HUMAN)]), "Needs human"]), + ]); + + page.append( + h("div", { class: "page-head" }, [ + h("div", { class: "head-row" }, [ + h("div", { class: "eyebrow" }, ["Run results"]), + backBtn, + ]), + h("h1", {}, [s?.goal ?? "Results"]), + h("div", { class: "meta-row" }, [ + h("span", { class: `phase ${statusPhase(s?.status)}` }, [s?.status ?? "?"]), + h("span", { class: "session-id" }, [`session ${sessionId}`]), + ]), + ]), + counts, + ); // Blocking summary cards (shown high up so a merge/verify failure can't be // missed behind all-green task counts). Sourced from the durable event log. - const blocking: HTMLElement[] = []; - const failedEvent = trace.events.find((e) => e.type === "session_failed"); if (failedEvent) { - const card = h("div", { class: "card error" }); - card.append(h("h3", {}, ["Run failed"])); - card.append(h("div", {}, [String((failedEvent.data as Record).error ?? "unknown error")])); - blocking.push(card); + page.append( + h("div", { class: "card error" }, [ + h("h3", {}, ["Run failed"]), + h("div", {}, [String((failedEvent.data as Record).error ?? "unknown error")]), + ]), + ); } const integrationEvent = trace.events.find((e) => e.type === "integration"); @@ -372,7 +580,7 @@ async function renderResults(sessionId: string): Promise { card.append(h("h3", {}, ["Integration & verification"])); card.append( h("div", { class: `phase ${ok ? "done" : "error"}` }, [ - ok ? "branches merged and full verification passed" : "FAILED — merge conflicts or verification did not pass", + ok ? "Branches merged and full verification passed" : "Failed — merge conflicts or verification did not pass", ]), ); card.append( @@ -388,13 +596,52 @@ async function renderResults(sessionId: string): Promise { if (d.verification && d.verification.passed === false) { card.append(h("div", { class: "error" }, ["Full-tree verification failed after merge."])); } - blocking.push(card); + page.append(card); + } + + // Usage + const u = trace.usage; + const usage = h("div", { class: "card" }); + usage.innerHTML = ` +

Usage

+ + + + + +
callspromptcompletiontotalquota hits
actor${u.perRole.actor.calls}${u.perRole.actor.promptTokens}${u.perRole.actor.completionTokens}${u.perRole.actor.totalTokens}${u.perRole.actor.quotaHits}
critic${u.perRole.critic.calls}${u.perRole.critic.promptTokens}${u.perRole.critic.completionTokens}${u.perRole.critic.totalTokens}${u.perRole.critic.quotaHits}
total${u.total.calls}${u.total.promptTokens}${u.total.completionTokens}${u.total.totalTokens}${u.total.quotaHits}
+ `; + page.append(usage); + + // Tasks + const tasksCard = h("div", { class: "card" }, [h("h3", {}, ["Tasks"])]); + for (const t of trace.tasks) { + const block = h("div", { class: "task-block" }); + const head = h("div", { class: "task-head" }, [`${t.taskId} `, badge(t.state)]); + if (t.degradedReason) head.append(h("span", { class: "degraded" }, [` ${t.degradedReason}`])); + block.append(head); + const txs = trace.transitions.filter((x) => x.taskId === t.taskId); + if (txs.length) { + const ul = h("ul", { class: "tx" }); + for (const x of txs) ul.append(h("li", {}, [`${x.from} —(${x.event})→ ${x.to} ${x.reason}`])); + block.append(ul); + } + tasksCard.append(block); } + page.append(tasksCard); + // Raw trace const raw = h("details", { class: "card" }); raw.append(h("summary", {}, ["Raw trace (text)"]), h("pre", { class: "log" }, [resp.text])); + page.append(raw); + + view.append(page); +} - view.append(summary, ...blocking, usage, tasksCard, raw); +function statusPhase(status?: string): string { + if (status === "completed") return "done"; + if (status === "failed" || status === "needs_human") return "error"; + return "running"; } function countStates(trace: TraceResponse["trace"]): Record { @@ -407,25 +654,55 @@ function countStates(trace: TraceResponse["trace"]): Record { async function renderSessions(): Promise { view.innerHTML = ""; - const card = h("div", { class: "card" }, [h("h2", {}, ["Sessions"])]); - view.append(card); + const page = h("section", { class: "page" }); + page.append( + h("div", { class: "page-head" }, [ + h("h1", {}, ["Sessions"]), + h("p", { class: "page-sub" }, ["Every run you've started. Select one to view its trace, usage, and task outcomes."]), + ]), + ); + const card = h("div", { class: "card" }); + page.append(card); + view.append(page); + + card.append(h("div", { class: "loading" }, [h("span", { class: "spinner" }), "Loading sessions…"])); + let sessions: SessionRecord[]; try { sessions = await listSessions(); } catch (err) { + card.innerHTML = ""; card.append(h("div", { class: "error" }, [`Failed to load: ${(err as Error).message}`])); return; } + + card.innerHTML = ""; if (!sessions.length) { - card.append(h("div", { class: "hint" }, ["No runs yet."])); + card.append( + h("div", { class: "empty" }, [ + icon(ICONS.inbox, "empty-ico"), + h("h3", {}, ["No runs yet"]), + h("p", {}, ["Start your first run and it will show up here with its full trace."]), + (() => { + const b = h("button", { class: "primary small" }, [icon(ICONS.rocket), "New run"]); + b.addEventListener("click", () => navigate("start")); + return b; + })(), + ]), + ); return; } + const list = h("ul", { class: "session-list" }); for (const s of sessions) { const li = h("li", {}); const btn = h("button", { class: "linkish" }, [ - h("span", { class: "s-goal" }, [s.goal]), - h("span", { class: "s-meta" }, [`${s.status} · ${new Date(s.createdAt).toLocaleString()}`]), + h("div", { class: "s-main" }, [ + h("span", { class: "s-goal" }, [s.goal]), + h("span", { class: "s-meta" }, [`${s.status} · ${new Date(s.createdAt).toLocaleString()}`]), + ]), + h("span", { class: `badge state-${stateForStatus(s.status)}` }, [s.status]), + icon(ICONS.arrow, "s-arrow"), ]); btn.addEventListener("click", () => renderResults(s.id)); li.append(btn); @@ -434,29 +711,45 @@ async function renderSessions(): Promise { card.append(list); } +function stateForStatus(status: string): string { + if (status === "completed") return "GREEN"; + if (status === "failed" || status === "needs_human") return "NEEDS_HUMAN"; + return "PLANNED"; +} + // --- Secrets view (Tauri only) --------------------------------------------- async function renderSecrets(): Promise { view.innerHTML = ""; - const card = h("div", { class: "card" }, [h("h2", {}, ["Secrets"])]); - view.append(card); + const page = h("section", { class: "page" }); + page.append( + h("div", { class: "page-head" }, [ + h("h1", {}, ["Secrets"]), + h("p", { class: "page-sub" }, [ + "API keys stored in your OS keychain and injected into the engine as environment variables. Reference them from runner profiles via apiKeyEnv (e.g. OPENAI_API_KEY).", + ]), + ]), + ); + view.append(page); + if (!isTauri()) { - card.append( - h("div", { class: "hint" }, [ - "Secure secret storage is only available in the desktop app. In a browser, provide API keys via the engine server's environment.", + page.append( + h("div", { class: "card" }, [ + h("div", { class: "empty" }, [ + icon(ICONS.key, "empty-ico"), + h("h3", {}, ["Desktop only"]), + h("p", {}, ["Secure secret storage is only available in the desktop app. In a browser, provide API keys via the engine server's environment."]), + ]), ]), ); return; } - card.append( - h("p", { class: "hint" }, [ - "Stored in the OS keychain and injected into the engine as environment variables. Reference them from runner profiles via apiKeyEnv (e.g. OPENAI_API_KEY).", - ]), - ); + const card = h("div", { class: "card" }); + page.append(card); const listEl = h("ul", { class: "secret-list" }); - card.append(listEl); + card.append(h("h3", {}, ["Stored keys"]), listEl); // Stored secrets are injected into the engine only at (re)start, so changing // them requires a restart to take effect. Make that gate explicit rather than @@ -472,23 +765,37 @@ async function renderSecrets(): Promise { async function refresh(): Promise { listEl.innerHTML = ""; - const keys = await listSecretKeys(); - if (!keys.length) listEl.append(h("li", { class: "hint" }, ["No secrets stored."])); + let keys: string[]; + try { + keys = await listSecretKeys(); + } catch (err) { + formError.textContent = `Failed to load secrets: ${(err as Error).message}`; + formError.hidden = false; + return; + } + formError.hidden = true; + if (!keys.length) listEl.append(h("li", { class: "hint" }, ["No secrets stored yet."])); for (const k of keys) { const del = h("button", { class: "danger small" }, ["Delete"]); del.addEventListener("click", async () => { - await deleteSecret(k); - await refresh(); - markPending(); + try { + await deleteSecret(k); + await refresh(); + markPending(); + } catch (err) { + formError.textContent = `Failed to delete ${k}: ${(err as Error).message}`; + formError.hidden = false; + } }); - listEl.append(h("li", {}, [h("code", {}, [k]), del])); + listEl.append(h("li", {}, [icon(ICONS.key), h("code", {}, [k]), del])); } } - const form = h("form", { class: "row secret-form" }); + const addCard = h("div", { class: "card" }, [h("h3", {}, ["Add a key"])]); + const form = h("form", { class: "secret-form" }); form.innerHTML = ` - - + + `; form.addEventListener("submit", async (e) => { @@ -515,9 +822,9 @@ async function renderSecrets(): Promise { await refresh(); markPending(); }); - card.append(form, formError); + addCard.append(form, formError); - const restart = h("button", {}, ["Restart engine to apply secret changes"]); + const restart = h("button", { class: "ghost" }, ["Restart engine to apply secret changes"]); // A restart re-spawns the sidecar (backend restart = shutdown + start), which // ABORTS any in-flight runs. Guard it: if runs are active, require an explicit // confirmation naming how many will be cancelled rather than silently killing @@ -549,7 +856,7 @@ async function renderSecrets(): Promise { `Cancel ${active} run${active === 1 ? "" : "s"} & restart`, ]); proceed.addEventListener("click", () => void performRestart()); - const keep = h("button", {}, ["Keep runs going"]); + const keep = h("button", { class: "ghost" }, ["Keep runs going"]); keep.addEventListener("click", () => { confirmPanel.hidden = true; }); @@ -579,7 +886,9 @@ async function renderSecrets(): Promise { } await performRestart(); }); - card.append(h("div", { class: "actions" }, [restart, pending]), confirmPanel); + addCard.append(h("div", { class: "actions" }, [restart, pending]), confirmPanel); + + page.append(addCard); await refresh(); } @@ -592,7 +901,7 @@ function escapeHtml(s: string): string { async function checkEngine(): Promise { const ok = await health(); - engineStatus.textContent = ok ? "engine: connected" : "engine: offline"; + engineLabel.textContent = ok ? "engine connected" : "engine offline"; engineStatus.className = `engine-status ${ok ? "ok" : "down"}`; } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index e2c4bbf..f77a63e 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -1,157 +1,630 @@ +/* =========================================================================== + Loopwright — design system + A refined, modern dark theme for the actor–critic engine desktop app. + =========================================================================== */ + :root { - --bg: #0f1115; - --panel: #181b22; - --panel-2: #1f232c; - --border: #2a2f3a; - --text: #e6e9ef; - --muted: #9aa3b2; - --accent: #5b8cff; - --green: #3fb950; - --yellow: #d29922; - --red: #f85149; - --mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace; + /* Surfaces (low -> high elevation) */ + --bg: #0a0c10; + --surface: #0e1116; + --panel: #14181f; + --panel-2: #1a1f28; + --elevated: #20262f; + + /* Lines */ + --border: #232a34; + --border-soft: #1b212a; + --border-strong: #2f3744; + + /* Text */ + --text: #eef1f6; + --muted: #98a2b3; + --faint: #687184; + + /* Brand / accent */ + --accent: #6d8cff; + --accent-strong: #5b78ff; + --accent-soft: rgba(109, 140, 255, 0.14); + --accent-ring: rgba(109, 140, 255, 0.35); + + /* Status */ + --green: #46c46a; + --green-soft: rgba(70, 196, 106, 0.14); + --yellow: #e0a93a; + --yellow-soft: rgba(224, 169, 58, 0.14); + --red: #f2606b; + --red-soft: rgba(242, 96, 107, 0.14); + + /* Radii */ + --r-xs: 6px; + --r-sm: 8px; + --r-md: 12px; + --r-lg: 16px; + --r-pill: 999px; + + /* Shadow */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 18px 50px rgba(0, 0, 0, 0.45); + + --sidebar-w: 232px; + --mono: ui-monospace, "SF Mono", "SFMono-Regular", "Menlo", "Consolas", monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, system-ui, sans-serif; } * { box-sizing: border-box; } +html, body { height: 100%; } + body { margin: 0; - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: var(--sans); background: var(--bg); color: var(--text); font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } -.topbar { +::selection { background: var(--accent-soft); } + +/* Scrollbars ---------------------------------------------------------------- */ +* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--r-pill); border: 3px solid transparent; background-clip: padding-box; } +*::-webkit-scrollbar-thumb:hover { background: #3a4350; background-clip: padding-box; } + +/* =========================================================================== + App shell + =========================================================================== */ +#app { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr; + height: 100vh; + overflow: hidden; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px 12px; + background: linear-gradient(180deg, #0c0f14 0%, #0a0c10 100%); + border-right: 1px solid var(--border-soft); +} + +.brand { display: flex; align-items: center; - gap: 16px; - padding: 10px 18px; - background: var(--panel); - border-bottom: 1px solid var(--border); - position: sticky; - top: 0; - z-index: 10; -} -.brand { font-weight: 700; letter-spacing: 0.3px; } -.topbar nav { display: flex; gap: 6px; } -.topbar nav button { + gap: 10px; + padding: 8px 10px 18px; +} +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: var(--r-sm); + background: linear-gradient(145deg, var(--accent) 0%, #7b6dff 100%); + color: #fff; + box-shadow: 0 4px 14px rgba(109, 140, 255, 0.4); +} +.brand-mark svg { width: 18px; height: 18px; } +.brand-name { font-weight: 700; font-size: 16px; letter-spacing: -0.01em; } + +.nav { display: flex; flex-direction: column; gap: 2px; } +.nav-item { + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 9px 11px; + border: none; + border-radius: var(--r-sm); background: transparent; - border: 1px solid transparent; color: var(--muted); - padding: 6px 12px; - border-radius: 6px; + font: inherit; + font-weight: 500; cursor: pointer; + text-align: left; + transition: background 0.14s ease, color 0.14s ease; } -.topbar nav button.active, -.topbar nav button:hover { color: var(--text); background: var(--panel-2); } +.nav-item svg { width: 17px; height: 17px; flex: none; } +.nav-item:hover { background: var(--panel); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--text); } +.nav-item.active svg { color: var(--accent); } -.engine-status { margin-left: auto; font-size: 12px; color: var(--muted); } -.engine-status.ok { color: var(--green); } -.engine-status.down { color: var(--red); } +.sidebar-footer { margin-top: auto; padding: 8px 6px 2px; } +.engine-status { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 11px; + border-radius: var(--r-pill); + border: 1px solid var(--border); + background: var(--panel); + font-size: 12px; + color: var(--muted); +} +.engine-status .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex: none; } +.engine-status.ok { color: var(--green); border-color: rgba(70, 196, 106, 0.3); } +.engine-status.ok .dot { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); } +.engine-status.down { color: var(--red); border-color: rgba(242, 96, 107, 0.3); } +.engine-status.down .dot { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); } -main { max-width: 920px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 16px; } +.content { + overflow-y: auto; + background: + radial-gradient(1200px 600px at 100% -10%, rgba(109, 140, 255, 0.06), transparent 60%), + var(--surface); +} +/* =========================================================================== + Page scaffolding + =========================================================================== */ +.page { + max-width: 880px; + margin: 0 auto; + padding: 40px 32px 80px; + display: flex; + flex-direction: column; + gap: 20px; +} +.page-head { display: flex; flex-direction: column; gap: 6px; } +.page-head .eyebrow { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.page-head h1 { margin: 0; font-size: 26px; font-weight: 700; letter-spacing: -0.02em; } +.page-head .page-sub { margin: 0; color: var(--muted); font-size: 14.5px; max-width: 60ch; } +.page-head .head-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } + +/* =========================================================================== + Cards + =========================================================================== */ .card { background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 16px 18px; + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + padding: 22px; + box-shadow: var(--shadow-sm); } -.card h2 { margin: 0 0 12px; font-size: 17px; } -.card h3 { margin: 0 0 10px; font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; } +.card > h2 { margin: 0 0 4px; font-size: 16px; font-weight: 650; } +.card > h3 { + margin: 0 0 14px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.card.error { border-color: rgba(242, 96, 107, 0.4); background: linear-gradient(0deg, var(--red-soft), transparent), var(--panel); } + +/* =========================================================================== + "How it works" steps + =========================================================================== */ +.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.step { + position: relative; + padding: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + background: var(--panel); + overflow: hidden; +} +.step::before { + content: attr(data-n); + position: absolute; + top: -10px; + right: 4px; + font-size: 54px; + font-weight: 800; + color: var(--panel-2); + line-height: 1; + pointer-events: none; +} +.step .step-ico { + display: grid; place-items: center; + width: 32px; height: 32px; margin-bottom: 10px; + border-radius: var(--r-sm); + background: var(--accent-soft); color: var(--accent); +} +.step .step-ico svg { width: 18px; height: 18px; } +.step h4 { margin: 0 0 4px; font-size: 13.5px; font-weight: 650; } +.step p { margin: 0; font-size: 12.5px; color: var(--muted); } + +/* =========================================================================== + Forms + =========================================================================== */ +.form { display: flex; flex-direction: column; gap: 22px; } +.field { display: flex; flex-direction: column; gap: 7px; } +.field > .label { font-size: 13px; font-weight: 600; color: var(--text); } +.field > .desc { font-size: 12.5px; color: var(--muted); margin: -2px 0 2px; } +.field small, small.desc { color: var(--faint); font-size: 12px; display: block; } + +.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.form label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); } -.form .row { display: flex; gap: 14px; flex-wrap: wrap; } -.form .row label { flex: 1; min-width: 140px; } -label.check { display: flex; align-items: center; gap: 8px; color: var(--text); } -label.check input { width: auto; } +label.inline-label { display: flex; flex-direction: column; gap: 7px; font-size: 13px; font-weight: 600; color: var(--text); } -input, textarea, select { +input[type="text"], input[type="password"], input[type="number"], input:not([type]), textarea, select { width: 100%; - margin-top: 5px; - background: var(--panel-2); + background: var(--surface); color: var(--text); border: 1px solid var(--border); - border-radius: 6px; - padding: 8px 10px; + border-radius: var(--r-sm); + padding: 10px 12px; + font-family: var(--sans); + font-size: 13.5px; + transition: border-color 0.14s ease, box-shadow 0.14s ease, background 0.14s ease; + appearance: none; +} +input::placeholder, textarea::placeholder { color: var(--faint); } +input:hover, textarea:hover, select:hover { border-color: var(--border-strong); } +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); + background: var(--panel); +} +textarea { resize: vertical; line-height: 1.6; } +textarea.mono, textarea[name="runners"] { font-family: var(--mono); font-size: 12.5px; } + +select { + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 11px center; + padding-right: 34px; + cursor: pointer; +} + +/* Example goal chips */ +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + border: 1px solid var(--border); + background: var(--surface); + color: var(--muted); + border-radius: var(--r-pill); + padding: 6px 12px; + font-size: 12.5px; + cursor: pointer; + transition: all 0.14s ease; +} +.chip:hover { border-color: var(--accent); color: var(--text); background: var(--accent-soft); } + +/* Collapsible advanced section */ +details.advanced { border: 1px solid var(--border-soft); border-radius: var(--r-sm); background: var(--surface); } +details.advanced > summary { + list-style: none; + cursor: pointer; + padding: 11px 14px; font-size: 13px; + font-weight: 600; + color: var(--muted); + display: flex; + align-items: center; + gap: 8px; +} +details.advanced > summary::-webkit-details-marker { display: none; } +details.advanced > summary .caret { transition: transform 0.18s ease; display: inline-flex; } +details.advanced[open] > summary .caret { transform: rotate(90deg); } +details.advanced[open] > summary { border-bottom: 1px solid var(--border-soft); } +details.advanced .advanced-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; } + +/* Option rows with switch */ +.options { display: flex; flex-direction: column; gap: 2px; } +.option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 2px; + border-bottom: 1px solid var(--border-soft); +} +.option:last-child { border-bottom: none; } +.option .option-text strong { display: block; font-size: 13.5px; font-weight: 600; } +.option .option-text span { display: block; font-size: 12.5px; color: var(--muted); margin-top: 2px; } + +/* iOS-style switch */ +.switch { position: relative; display: inline-flex; flex: none; cursor: pointer; } +.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } +.switch .track { + width: 40px; height: 23px; + border-radius: var(--r-pill); + background: var(--elevated); + border: 1px solid var(--border-strong); + transition: background 0.18s ease, border-color 0.18s ease; +} +.switch .track::after { + content: ""; + position: absolute; + top: 3px; left: 3px; + width: 17px; height: 17px; + border-radius: 50%; + background: #cdd3dd; + transition: transform 0.18s ease, background 0.18s ease; } -textarea[name="runners"] { font-family: var(--mono); } -small { color: var(--muted); display: block; margin-top: 4px; } +.switch input:checked + .track { background: var(--accent); border-color: var(--accent); } +.switch input:checked + .track::after { transform: translateX(17px); background: #fff; } +.switch input:focus-visible + .track { box-shadow: 0 0 0 3px var(--accent-ring); } +/* Stepper (number) */ +.stepper { display: inline-flex; align-items: stretch; max-width: 130px; } +.stepper input { border-radius: var(--r-sm) 0 0 var(--r-sm); border-right: none; -moz-appearance: textfield; } +.stepper input::-webkit-outer-spin-button, .stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.stepper .steps-btns { display: flex; flex-direction: column; border: 1px solid var(--border); border-left: none; border-radius: 0 var(--r-sm) var(--r-sm) 0; overflow: hidden; } +.stepper .steps-btns button { + flex: 1; width: 30px; border: none; background: var(--panel-2); color: var(--muted); + cursor: pointer; display: grid; place-items: center; padding: 0; +} +.stepper .steps-btns button:hover { background: var(--elevated); color: var(--text); } +.stepper .steps-btns button:first-child { border-bottom: 1px solid var(--border); } + +/* =========================================================================== + Buttons + =========================================================================== */ +button.primary, button.ghost, button.danger { + font: inherit; + font-weight: 600; + border-radius: var(--r-sm); + cursor: pointer; + padding: 10px 18px; + transition: all 0.14s ease; + display: inline-flex; + align-items: center; + gap: 8px; +} button.primary { background: var(--accent); - border: none; - color: white; - padding: 8px 16px; - border-radius: 6px; - cursor: pointer; - font-weight: 600; + border: 1px solid var(--accent); + color: #fff; + box-shadow: 0 4px 14px rgba(91, 120, 255, 0.3); } -button.danger { background: transparent; border: 1px solid var(--red); color: var(--red); border-radius: 6px; cursor: pointer; padding: 5px 10px; } -button.small { font-size: 12px; padding: 3px 8px; } -.actions { display: flex; align-items: center; gap: 12px; margin-top: 8px; } -.hint { color: var(--muted); font-size: 12px; } -.hint.error, .error { color: var(--red); } +button.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); transform: translateY(-1px); } +button.primary:active { transform: translateY(0); } +button.primary:disabled { opacity: 0.55; cursor: default; transform: none; box-shadow: none; } + +button.ghost { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); } +button.ghost:hover { background: var(--elevated); border-color: var(--border-strong); } + +button.danger { background: transparent; border: 1px solid rgba(242, 96, 107, 0.5); color: var(--red); } +button.danger:hover { background: var(--red-soft); border-color: var(--red); } + +button.small { padding: 5px 11px; font-size: 12.5px; } + +.actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } + +/* =========================================================================== + Hints / inline messages + =========================================================================== */ +.hint { color: var(--muted); font-size: 12.5px; } +.hint.error, .error { color: var(--red); font-size: 13px; } .hint.pending { color: var(--yellow); } +.error[hidden], .hint[hidden] { display: none; } /* Inline confirmation shown before a restart that would abort active runs. */ -.confirm-restart { margin-top: 10px; } -.confirm-restart .warn { color: var(--yellow); font-size: 13px; margin-bottom: 6px; } +.confirm-restart { margin-top: 12px; padding: 14px; border: 1px solid rgba(224, 169, 58, 0.4); background: var(--yellow-soft); border-radius: var(--r-sm); } +.confirm-restart .warn { color: var(--yellow); font-size: 13px; margin-bottom: 10px; font-weight: 600; } + +/* =========================================================================== + Run header (monitor + results) + =========================================================================== */ +.run-head { display: flex; flex-direction: column; gap: 12px; } +.run-head .goal { font-size: 18px; font-weight: 650; letter-spacing: -0.01em; line-height: 1.4; } +.run-head .meta-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.session-id { + font-family: var(--mono); + font-size: 12px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border-soft); + padding: 3px 9px; + border-radius: var(--r-pill); +} + +/* Phase pill */ +.phase { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 13px; + padding: 5px 13px; + border-radius: var(--r-pill); + border: 1px solid var(--border); +} +.phase::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.phase.running { color: var(--yellow); border-color: rgba(224, 169, 58, 0.4); background: var(--yellow-soft); } +.phase.running::before { animation: pulse 1.4s ease-in-out infinite; } +.phase.done { color: var(--green); border-color: rgba(70, 196, 106, 0.4); background: var(--green-soft); } +.phase.error { color: var(--red); border-color: rgba(242, 96, 107, 0.4); background: var(--red-soft); } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } -.goal { font-size: 15px; margin-bottom: 4px; } -.session-id { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 10px; } +.plan { color: var(--muted); font-size: 13px; } -.phase { font-weight: 600; padding: 6px 0; } -.phase.running { color: var(--yellow); } -.phase.done { color: var(--green); } -.phase.error { color: var(--red); } -.plan { color: var(--muted); font-size: 13px; margin-top: 6px; } +/* =========================================================================== + Usage + =========================================================================== */ +.usage-body { font-family: var(--mono); font-size: 13px; color: var(--muted); } table { width: 100%; border-collapse: collapse; font-size: 13px; } -table.tasks th, table.tasks td, table.kv th, table.kv td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); } -table.kv th { color: var(--muted); font-weight: 500; } -table.kv tr.total td { font-weight: 700; } - -.badge { font-family: var(--mono); font-size: 11px; padding: 2px 7px; border-radius: 10px; border: 1px solid var(--border); } -.state-GREEN { color: var(--green); border-color: var(--green); } -.state-NEEDS_HUMAN { color: var(--red); border-color: var(--red); } -.state-UNVERIFIED_BY_CRITIC { color: var(--yellow); border-color: var(--yellow); } -.state-BUILDING, .state-CRITIC_REVIEWING, .state-PLANNED, .state-CHANGES_REQUIRED, .state-MECHANICAL_FAILED { color: var(--accent); border-color: var(--accent); } - -.usage-body { font-family: var(--mono); font-size: 13px; } -.counts { display: flex; gap: 10px; margin-top: 10px; } -.count { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border); font-size: 12px; } -.count.green { color: var(--green); } -.count.unverified { color: var(--yellow); } -.count.needs-human { color: var(--red); } - -.card.integration-bad { border-color: var(--red); } - -.task-block { padding: 8px 0; border-bottom: 1px solid var(--border); } -.task-head { font-family: var(--mono); display: flex; align-items: center; gap: 8px; } +table.tasks th, table.tasks td, table.kv th, table.kv td { + text-align: left; + padding: 9px 10px; + border-bottom: 1px solid var(--border-soft); +} +table th { color: var(--faint); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; } +table.tasks tbody tr:hover { background: var(--surface); } +table.kv td:first-child { color: var(--muted); } +table.kv tr.total td { font-weight: 700; color: var(--text); border-top: 1px solid var(--border); } +table.kv td:not(:first-child), table.kv th:not(:first-child) { font-family: var(--mono); text-align: right; } + +/* =========================================================================== + Badges (task states) + =========================================================================== */ +.badge { + font-family: var(--mono); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 3px 9px; + border-radius: var(--r-pill); + border: 1px solid var(--border); + background: var(--surface); + white-space: nowrap; +} +.state-GREEN { color: var(--green); border-color: rgba(70, 196, 106, 0.4); background: var(--green-soft); } +.state-NEEDS_HUMAN { color: var(--red); border-color: rgba(242, 96, 107, 0.4); background: var(--red-soft); } +.state-UNVERIFIED_BY_CRITIC { color: var(--yellow); border-color: rgba(224, 169, 58, 0.4); background: var(--yellow-soft); } +.state-BUILDING, .state-CRITIC_REVIEWING, .state-PLANNED, .state-CHANGES_REQUIRED, .state-MECHANICAL_FAILED { + color: var(--accent); border-color: var(--accent-ring); background: var(--accent-soft); +} + +/* Result count pills */ +.counts { display: flex; gap: 10px; flex-wrap: wrap; } +.count { + display: inline-flex; align-items: baseline; gap: 6px; + padding: 7px 14px; border-radius: var(--r-sm); + border: 1px solid var(--border); background: var(--surface); + font-size: 12px; color: var(--muted); +} +.count .n { font-size: 17px; font-weight: 700; font-family: var(--mono); } +.count.green { color: var(--green); border-color: rgba(70, 196, 106, 0.3); } +.count.unverified { color: var(--yellow); border-color: rgba(224, 169, 58, 0.3); } +.count.needs-human { color: var(--red); border-color: rgba(242, 96, 107, 0.3); } + +.card.integration-bad { border-color: rgba(242, 96, 107, 0.5); } + +/* =========================================================================== + Task blocks / transitions + =========================================================================== */ +.task-block { padding: 12px 0; border-bottom: 1px solid var(--border-soft); } +.task-block:last-child { border-bottom: none; } +.task-head { font-family: var(--mono); display: flex; align-items: center; gap: 10px; font-size: 13px; } .degraded { color: var(--yellow); font-size: 12px; } -ul.tx { margin: 6px 0 0; padding-left: 18px; color: var(--muted); font-family: var(--mono); font-size: 12px; } +ul.tx { margin: 8px 0 0; padding-left: 18px; color: var(--muted); font-family: var(--mono); font-size: 12px; } +ul.tx li { padding: 2px 0; } +/* =========================================================================== + Engine log + =========================================================================== */ .log { - background: #0b0d11; - border: 1px solid var(--border); - border-radius: 6px; - padding: 10px; - max-height: 320px; + background: #07090c; + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + padding: 14px 16px; + max-height: 360px; overflow: auto; font-family: var(--mono); font-size: 12px; + line-height: 1.7; white-space: pre-wrap; + color: #c4ccd8; margin: 0; } +details.card > summary { + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--muted); + list-style: none; +} +details.card > summary::-webkit-details-marker { display: none; } +details.card[open] > summary { margin-bottom: 14px; } +/* =========================================================================== + Sessions list + =========================================================================== */ .session-list, .secret-list { list-style: none; padding: 0; margin: 0; } -.session-list li { border-bottom: 1px solid var(--border); } -button.linkish { width: 100%; text-align: left; background: transparent; border: none; color: var(--text); cursor: pointer; padding: 10px 4px; display: flex; justify-content: space-between; gap: 12px; } -button.linkish:hover { background: var(--panel-2); } -.s-meta { color: var(--muted); font-size: 12px; } -.secret-list li { display: flex; align-items: center; gap: 10px; padding: 6px 0; } -.secret-list code { font-family: var(--mono); } -.secret-form { align-items: flex-end; } +.session-list li { border-bottom: 1px solid var(--border-soft); } +.session-list li:last-child { border-bottom: none; } +button.linkish { + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--text); + cursor: pointer; + padding: 14px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + border-radius: var(--r-sm); + transition: background 0.14s ease; + font: inherit; +} +button.linkish:hover { background: var(--surface); } +.s-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.s-goal { font-weight: 550; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.s-meta { color: var(--faint); font-size: 12px; } +.s-arrow { color: var(--faint); flex: none; } +button.linkish:hover .s-arrow { color: var(--accent); } + +/* =========================================================================== + Secrets + =========================================================================== */ +.secret-list { margin-top: 4px; } +.secret-list li { + display: flex; align-items: center; gap: 10px; + padding: 11px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + margin-bottom: 8px; + background: var(--surface); +} +.secret-list li.hint { background: transparent; border-style: dashed; justify-content: center; color: var(--muted); } +.secret-list code { font-family: var(--mono); font-size: 13px; flex: 1; } +.secret-form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; } +.secret-form button { align-self: end; } + +/* =========================================================================== + Empty state + =========================================================================== */ +.empty { + display: flex; flex-direction: column; align-items: center; text-align: center; + gap: 10px; padding: 48px 24px; color: var(--muted); +} +.empty .empty-ico { + display: grid; place-items: center; width: 52px; height: 52px; + border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--border-soft); + color: var(--faint); +} +.empty .empty-ico svg { width: 26px; height: 26px; } +.empty h3 { margin: 0; color: var(--text); font-size: 15px; text-transform: none; letter-spacing: 0; } +.empty p { margin: 0; font-size: 13px; max-width: 36ch; } + +/* =========================================================================== + Loading skeleton + =========================================================================== */ +.loading { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; padding: 8px 0; } +.spinner { + width: 15px; height: 15px; border-radius: 50%; + border: 2px solid var(--border-strong); border-top-color: var(--accent); + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* =========================================================================== + Responsive + =========================================================================== */ +@media (max-width: 720px) { + #app { grid-template-columns: 1fr; } + .sidebar { + flex-direction: row; + align-items: center; + padding: 8px 12px; + border-right: none; + border-bottom: 1px solid var(--border-soft); + } + .brand { padding: 4px 8px; } + .nav { flex-direction: row; } + .nav-item span { display: none; } + .sidebar-footer { margin: 0 0 0 auto; padding: 0; } + .steps { grid-template-columns: 1fr; } + .field-grid, .secret-form { grid-template-columns: 1fr; } + .page { padding: 24px 18px 60px; } +}