Skip to content

Studio: redesign with base-model Playground#80

Open
soleil-colza wants to merge 23 commits intomainfrom
eng-582
Open

Studio: redesign with base-model Playground#80
soleil-colza wants to merge 23 commits intomainfrom
eng-582

Conversation

@soleil-colza
Copy link
Copy Markdown
Contributor

Summary

Brings Arkor Studio's UI in line with the marketing site's design language and a Vercel/GitHub dashboard feel, and lets users chat with a supported base model the moment Studio boots — no completed training run required.

  • Foundation — Tailwind v4 via @tailwindcss/vite, Geist Sans + Geist Mono via Fontsource (offline-friendly), light default with a header theme toggle that flips data-theme="dark" on <html> (with a pre-paint script in index.html to prevent flashes).
  • App shell — sticky top header (Arkor wordmark → "Studio"), GitHub-style underlined tab nav (Overview / Jobs / Playground), connection chip + theme toggle on the right. New #/jobs route lets the home page act as a Vercel-style overview while the full job index lives at its own URL.
  • Overview — manifest-aware "Run training" hero card (shows the trainer name from /api/manifest, hint when src/arkor/index.ts has no createTrainer), compact recent-jobs preview, quick-start tile pair.
  • Jobs — full-width table with status pills, duration, relative-time age, monospace IDs, search box, status-filter pills, refresh button with spinner.
  • Job detail — breadcrumb + status pill + actions header, two-column body (upgraded LossChart with axis labels, gridlines, gradient area fill, hover tooltip; color-coded EventsStream replacing the raw <pre>; live-ticking duration in a Vercel-style metadata sidebar with copy-to-clipboard Job ID).
  • Playground — defaults to Base model mode so the Composer is usable from the first boot. Two-segment ModelToggle switches between Base model (mono dropdown over SUPPORTED_BASE_MODELS) and Adapter (job-name + truncated-id picker, disabled until a completed job exists). Right-aligned user bubbles, left-aligned assistant text with a pulsing teal caret while streaming, sticky autosizing Composer with Enter-to-send.

The data layer (src/lib/api.ts) and the prior base-model wiring are preserved end-to-end.

Verification

Run from packages/studio-app/:

  • pnpm typecheck — clean
  • pnpm test — 13/13 pass
  • pnpm bundledist/ includes a single hashed CSS (~40 KB / 7.5 KB gzipped) and the Geist woff2 set
  • pnpm dev + arkor dev on :4000 — boot, walk every page in light + dark, toggle theme and reload to confirm persistence

Test plan

  • Header logo renders the Arkor mark; "Arkor / Studio" reads cleanly in both themes
  • Overview: Run training disables until createTrainer(...) exists; once registered, button label includes the trainer name; log streams into the collapsible panel
  • Jobs: status filter pills + search both narrow the table; refresh icon spins while fetching; row click opens detail
  • Job detail: LossChart hover tooltip tracks the nearest step; EventsStream auto-scrolls but releases on manual scroll-up; copy-Job-ID button shows the check-tick feedback; duration ticks every 1 s while running
  • Playground (no jobs): Base model mode is active by default, Adapter segment disabled with hover hint; sending a message streams responses
  • Playground (with completed jobs): switching to Adapter loads the picker; switching modes clears the conversation
  • Theme: toggle persists across reload, prefers-color-scheme respected on first load, no FOUT / no theme flash

🤖 Generated with Claude Code

Replaces the bespoke ~180-line dark-only stylesheet with a Tailwind v4
entry that declares zinc + teal tokens via @theme and a tiny @layer base.
Adds Geist Sans and Geist Mono via Fontsource so the local Studio app
matches the marketing site's typography without a runtime <link> to
Google Fonts (works offline under `arkor dev`).

Light is now the default theme; dark is opt-in via data-theme="dark" on
<html>, persisted to localStorage("arkor-studio-theme"), with a pre-paint
script in index.html that prevents the wrong-palette flash on reload.

Splits common formatting helpers (duration, relative time, truncate) and
theme accessors into src/lib/{format,theme,fonts}.ts so subsequent
components can lean on them without re-implementing the same logic.
Adds the leaf components every redesigned page will lean on: Button (primary
/ secondary / ghost / danger, sm + md sizes, focus ring + leading/trailing
icon slots), Card with Header/Title/Description/Content/Footer, StatusBadge
with a pulsing dot for `running`, Breadcrumb, EmptyState, IconButton,
Skeleton, CopyButton (clipboard with check-tick feedback), and a
RelativeTime <time> that re-renders every 30s.

Hand-rolls a small SVG icon set (~10 glyphs: chevrons, copy/check, sun/moon,
search, play, send, etc.) instead of pulling in lucide-react — the redesign
needs fewer than ten icons and a homegrown set keeps stroke widths and
sizing consistent with the marketing site's existing inline SVGs.
Wraps the SPA in a sticky top header (logo, breadcrumb, identity chip,
theme toggle) plus a GitHub-style underlined tab nav. Tabs route between
Overview / Jobs / Playground; the active tab gets a 2px teal underline
flush with the header's bottom border.

Splits the hash route helpers into src/route.ts and adds a third route
(`#/jobs`) so the home page can become a Vercel-style overview while
the existing Jobs index moves to its own URL. `parseRoute` distinguishes
the new `#/jobs` listing from `#/jobs/<id>` detail by checking the bare
`jobs` form first.

IdentityChip mirrors the marketing site's connection chip: an emerald
pill with a pulsing dot, plus a monospaced host:port suffix when running
against a non-production cloud-api (preserved from the prior
`formatIdentity` helper). ThemeToggle flips data-theme="dark" on <html>
and persists the choice to localStorage.
Rebuilds the three job-management surfaces around the new design system:

Overview (#/) now opens with a hero "Run training" card (manifest-aware:
shows the trainer name from /api/manifest, renders a hint when
src/arkor/index.ts has no createTrainer yet, disables the button until a
trainer is registered), a compact "Recent jobs" preview limited to 5
rows, and a quick-start tile pair for docs and Playground.

Jobs (#/jobs) becomes a full-width JobsTable with status-pill columns,
duration, relative-time age, and a monospaced truncated ID. The header
exposes a search box and a row of status filter pills (All / Running /
Completed / Queued / Failed / Cancelled), with a Refresh icon button
that visibly spins while a fetch is in flight. Loading and empty states
both flow through the shared Skeleton and EmptyState primitives.

Job detail (#/jobs/:id) gets a sub-header with a Jobs breadcrumb, the
job name, status pill, and contextual actions (back-to-jobs always; Open
in Playground when the job is completed). The body splits into a main
column (upgraded LossChart with axis labels, gridlines, gradient area
fill, and a hover tooltip; color-coded EventsStream replacing the raw
<pre> tail, with sticky scroll-to-bottom) and a 280px Vercel-style
metadata sidebar (Status, Duration with a 1-second live ticker while
running, timestamps, base model, dataset, artifact count, and a
copy-to-clipboard Job ID). The whole layout stacks below 1024px.
…ickers

Rebuilds the Playground around three new components — ModelToggle (a
two-segment pill control switching between Base model and Adapter, with
the Adapter segment disabled until a completed job exists),
BaseModelPicker (mono-styled dropdown over SUPPORTED_BASE_MODELS), and
AdapterPicker (job-name + truncated-id dropdown) — alongside the
MessageList (right-aligned user bubbles in zinc-900/white inversion,
left-aligned assistant text with a pulsing teal caret while streaming)
and Composer (autosizing textarea, Enter to send, Shift+Enter for
newline) primitives.

The page now renders the Composer immediately on first load: with the
toggle defaulting to Base model, users can chat with a supported base
model before any training run completes, which lets them poke at the
Studio while a long fine-tune is still in progress. Switching to the
Adapter mode (or changing the selected base model / adapter) clears the
conversation so each session is rooted in one source of truth.
Switches the header logo from the placeholder Sparkles glyph to the
official Arkor mark (the same SVG marketing site uses in
SiteHeader.ArkorMark) so the local Studio reads as a first-party Arkor
surface rather than a generic dashboard.

Drops the org/project breadcrumb chip in favour of a flat "Arkor / Studio"
label. Studio is currently scoped to single-user, single-project local
runs; surfacing "no org" when no org slug exists was noisy and surfacing
real slugs added a navigation affordance the page doesn't yet support.
The IdentityChip on the right still tells contributors which cloud-api
they're talking to.
Copilot AI review requested due to automatic review settings April 30, 2026 16:09
Comment thread packages/studio-app/src/pages/Overview.tsx Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR redesigns the Studio SPA UI to a Vercel/GitHub-style dashboard and makes the Playground usable immediately by defaulting to base-model chat, while introducing a new app shell (header/tabs/theme) and Tailwind v4 styling.

Changes:

  • Adds Tailwind v4 + Geist fonts, updates Vite config, and replaces legacy CSS with Tailwind-based theming (light default, data-theme="dark" support).
  • Introduces a new app shell (sticky header, nav tabs, identity chip, theme toggle) and new routes (Overview, Jobs index at #/jobs, Job detail, Playground).
  • Rebuilds core pages/components (Jobs table/detail, loss chart, event stream, Playground composer/pickers) to match the new design and UX.

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
pnpm-lock.yaml Locks new Tailwind/Vite/font dependencies for the Studio redesign.
packages/studio-app/vite.config.ts Adds @tailwindcss/vite plugin to the Studio build.
packages/studio-app/src/styles.css Replaces legacy CSS with Tailwind import + base theme styling and dark variant.
packages/studio-app/src/route.ts Adds hash-based routing for Overview/Jobs/Job/Playground.
packages/studio-app/src/pages/Playground.tsx Refactors Playground UI/UX; base model default + new composer/pickers.
packages/studio-app/src/pages/Overview.tsx New Overview dashboard page with recent jobs + quick-start tiles.
packages/studio-app/src/pages/JobsList.tsx New Jobs index page with search, filters, refresh, and table component.
packages/studio-app/src/pages/JobDetail.tsx New Job detail layout with loss chart, events stream, and metadata sidebar.
packages/studio-app/src/main.tsx Imports fonts + Tailwind styles at app entry.
packages/studio-app/src/lib/theme.ts Adds theme persistence helpers (localStorage + prefers-color-scheme).
packages/studio-app/src/lib/format.ts Adds duration/relative-time formatting and ID truncation helper.
packages/studio-app/src/lib/fonts.ts Side-effect imports for offline Geist font loading.
packages/studio-app/src/components/ui/cn.ts Adds cn() utility for composing class names.
packages/studio-app/src/components/ui/StatusBadge.tsx New status pill component used across Jobs/Job detail.
packages/studio-app/src/components/ui/Skeleton.tsx Adds skeleton loading component for tables/cards.
packages/studio-app/src/components/ui/RelativeTime.tsx Adds live-updating relative timestamp display.
packages/studio-app/src/components/ui/IconButton.tsx Adds icon-only button component used for refresh/theme/etc.
packages/studio-app/src/components/ui/EmptyState.tsx Adds reusable empty state component for no-data screens.
packages/studio-app/src/components/ui/CopyButton.tsx Adds clipboard copy button (used in job metadata sidebar).
packages/studio-app/src/components/ui/Card.tsx Adds Card primitives used throughout new UI.
packages/studio-app/src/components/ui/Button.tsx Adds button component with variants/sizes/icons.
packages/studio-app/src/components/ui/Breadcrumb.tsx Adds breadcrumb component for Job detail header.
packages/studio-app/src/components/playground/ModelToggle.tsx Adds segmented control for Base model vs Adapter mode.
packages/studio-app/src/components/playground/MessageList.tsx Adds new chat transcript renderer with sticky autoscroll + streaming caret.
packages/studio-app/src/components/playground/Composer.tsx Adds autosizing textarea composer with Enter-to-send.
packages/studio-app/src/components/playground/BaseModelPicker.tsx Adds base-model dropdown UI.
packages/studio-app/src/components/playground/AdapterPicker.tsx Adds adapter/job picker UI for adapter mode.
packages/studio-app/src/components/layout/ThemeToggle.tsx Adds header theme toggle using data-theme.
packages/studio-app/src/components/layout/NavTabs.tsx Adds underlined tab navigation for main sections.
packages/studio-app/src/components/layout/IdentityChip.tsx Adds connection/identity chip showing auth mode and (non-prod) host.
packages/studio-app/src/components/layout/Header.tsx Adds sticky header with branding, nav tabs, identity chip, theme toggle.
packages/studio-app/src/components/layout/AppShell.tsx Adds app layout wrapper and consistent page container sizing.
packages/studio-app/src/components/jobs/LossChart.tsx Adds upgraded loss chart (grid, gradient fill, hover tooltip).
packages/studio-app/src/components/jobs/JobsTable.tsx Adds reusable jobs table (compact/full) with relative time + duration.
packages/studio-app/src/components/jobs/JobMetaSidebar.tsx Adds metadata sidebar layout + copy affordance.
packages/studio-app/src/components/jobs/EventsStream.tsx Adds colored events stream with sticky autoscroll behavior.
packages/studio-app/src/components/icons/index.tsx Introduces consistent icon set used across the redesigned UI.
packages/studio-app/src/components/icons/ArkorMark.tsx Adds Arkor mark icon for header branding.
packages/studio-app/src/components/RunTraining.tsx Redesigns training runner with new button, hints, and log panel.
packages/studio-app/src/App.tsx Switches app to AppShell + new routing and new pages.
packages/studio-app/package.json Adds Tailwind + Geist fontsource dependencies.
packages/studio-app/index.html Adds pre-paint theme init script + color-scheme meta; removes CSS link.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +27
export type Theme = "light" | "dark";

const STORAGE_KEY = "arkor-studio-theme";

export function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}

export function setTheme(theme: Theme): void {
if (typeof document === "undefined") return;
document.documentElement.dataset.theme = theme;
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// localStorage may be unavailable (private mode, etc.) — silently skip.
}
}

export function getCurrentTheme(): Theme {
if (typeof document === "undefined") return "light";
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
Comment on lines +91 to 94
const es = openJobEvents(jobId);
es.addEventListener("training.started", (ev: MessageEvent) => {
setStatus("running");
pushRaw(`[started] ${ev.data}`);
pushEvent("training.started", ev.data);
});
Comment on lines 95 to 102
es.addEventListener("training.log", (ev: MessageEvent) => {
pushEvent("training.log", ev.data);
try {
const d = JSON.parse(ev.data) as {
step: number;
loss?: number | null;
};
setEvents((prev) => [
const d = JSON.parse(ev.data) as { step: number; loss?: number | null };
setPoints((prev) => [
...prev,
{ step: d.step, loss: d.loss ?? null },
]);
Comment on lines 296 to 298
if (job.completedAt) {
return Math.max(0, Date.parse(job.completedAt) - start);
}
Comment thread packages/studio-app/src/route.ts Outdated
Comment on lines +11 to +17
if (hash === "jobs") return { kind: "jobs" };
if (hash.startsWith("jobs/")) {
const id = hash.slice("jobs/".length);
if (id) return { kind: "job", id };
}
if (hash === "playground") return { kind: "playground" };
return { kind: "home" };
Comment on lines +1 to +50
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "—";
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
}
if (minutes > 0) {
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
}
return `${seconds}s`;
}

const RTF =
typeof Intl !== "undefined" && "RelativeTimeFormat" in Intl
? new Intl.RelativeTimeFormat("en", { numeric: "auto" })
: null;

export function formatRelativeTime(iso: string, now: number = Date.now()): string {
const t = Date.parse(iso);
if (Number.isNaN(t)) return "—";
const diffSec = Math.round((t - now) / 1000);
const abs = Math.abs(diffSec);
if (abs < 45) return RTF ? RTF.format(diffSec, "second") : `${diffSec}s`;
if (abs < 60 * 45) {
const v = Math.round(diffSec / 60);
return RTF ? RTF.format(v, "minute") : `${v}m`;
}
if (abs < 3600 * 22) {
const v = Math.round(diffSec / 3600);
return RTF ? RTF.format(v, "hour") : `${v}h`;
}
if (abs < 86400 * 26) {
const v = Math.round(diffSec / 86400);
return RTF ? RTF.format(v, "day") : `${v}d`;
}
if (abs < 86400 * 320) {
const v = Math.round(diffSec / (86400 * 30));
return RTF ? RTF.format(v, "month") : `${v}mo`;
}
const v = Math.round(diffSec / (86400 * 365));
return RTF ? RTF.format(v, "year") : `${v}y`;
}

export function truncateMiddle(s: string, head = 6, tail = 4): string {
if (s.length <= head + tail + 1) return s;
return `${s.slice(0, head)}…${s.slice(-tail)}`;
}
@soleil-colza soleil-colza marked this pull request as ready for review April 30, 2026 19:03
@soleil-colza soleil-colza requested a review from Copilot April 30, 2026 19:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates Arkor Studio (packages/studio-app) to a Tailwind v4 + Geist-based app shell with hash routing tabs (Overview / Jobs / Playground), adds a new Overview page, and reworks Playground to support base-model chat immediately on boot.

Changes:

  • Add Tailwind v4 build pipeline (@tailwindcss/vite) + Geist variable fonts, plus a persisted light/dark theme via data-theme on <html> with a pre-paint initializer.
  • Introduce new routing + layout shell (sticky header, tab nav) and a new Overview page; move full Jobs list to #/jobs.
  • Refactor Jobs, Job Detail, and Playground into new UI/components (tables, badges, charts, pickers, composer, event stream).

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pnpm-lock.yaml Locks new Tailwind/Geist dependencies and related transitive updates.
packages/studio-app/package.json Adds Tailwind v4 + Geist font dev dependencies.
packages/studio-app/vite.config.ts Enables Tailwind via @tailwindcss/vite plugin.
packages/studio-app/index.html Adds pre-paint theme init + color-scheme meta.
packages/studio-app/src/main.tsx Imports fonts + global styles at app entry.
packages/studio-app/src/styles.css Replaces legacy CSS with Tailwind v4 entry + theme tokens/variants.
packages/studio-app/src/App.tsx Switches to new AppShell + route-based page rendering.
packages/studio-app/src/route.ts Adds hash router supporting #/, #/jobs, #/jobs/:id, #/playground.
packages/studio-app/src/pages/Overview.tsx New Overview page (run training card, recent jobs, quick links).
packages/studio-app/src/pages/JobsList.tsx New Jobs page UI with search/filter + refresh + auto-poll.
packages/studio-app/src/pages/JobDetail.tsx New job detail layout with LossChart, EventsStream, metadata sidebar.
packages/studio-app/src/pages/Playground.tsx Refactors Playground into base/adaptor modes with new components.
packages/studio-app/src/lib/fonts.ts Side-effect imports for Geist Sans/Mono (offline-friendly).
packages/studio-app/src/lib/theme.ts Theme persistence helpers (localStorage + html dataset).
packages/studio-app/src/lib/format.ts Shared formatting helpers (duration/relative time/truncation).
packages/studio-app/src/components/ui/cn.ts cn() helper for composing className strings.
packages/studio-app/src/components/ui/Button.tsx New button component with variants/sizes/icons.
packages/studio-app/src/components/ui/Card.tsx Card primitives used across pages.
packages/studio-app/src/components/ui/IconButton.tsx Icon-only button with a11y labeling + focus styles.
packages/studio-app/src/components/ui/StatusBadge.tsx Status pill used in tables/detail.
packages/studio-app/src/components/ui/Skeleton.tsx Skeleton loading placeholder.
packages/studio-app/src/components/ui/EmptyState.tsx Shared empty-state component.
packages/studio-app/src/components/ui/CopyButton.tsx Copy-to-clipboard control for job ID, etc.
packages/studio-app/src/components/ui/Breadcrumb.tsx Breadcrumb nav for job detail header.
packages/studio-app/src/components/ui/RelativeTime.tsx Live relative-time display for job created timestamps.
packages/studio-app/src/components/layout/AppShell.tsx App chrome container (header + main content).
packages/studio-app/src/components/layout/Header.tsx Sticky header with branding, identity chip, theme toggle, tabs.
packages/studio-app/src/components/layout/NavTabs.tsx Tab navigation matched to hash route.
packages/studio-app/src/components/layout/ThemeToggle.tsx Theme switch using persisted dataset theme.
packages/studio-app/src/components/layout/IdentityChip.tsx Connection/status indicator for credentials fetch.
packages/studio-app/src/components/jobs/JobsTable.tsx Shared jobs table (compact + full).
packages/studio-app/src/components/jobs/LossChart.tsx New interactive loss curve chart with hover tooltip.
packages/studio-app/src/components/jobs/EventsStream.tsx Styled SSE event stream with stick-to-bottom behavior.
packages/studio-app/src/components/jobs/JobMetaSidebar.tsx Key/value metadata list with copy affordance.
packages/studio-app/src/components/playground/ModelToggle.tsx Base vs adapter segmented toggle.
packages/studio-app/src/components/playground/BaseModelPicker.tsx Base-model dropdown/popover picker.
packages/studio-app/src/components/playground/AdapterPicker.tsx Adapter picker over completed jobs.
packages/studio-app/src/components/playground/MessageList.tsx Chat transcript rendering + autoscroll.
packages/studio-app/src/components/playground/Composer.tsx Autosizing message composer with Enter-to-send.
packages/studio-app/src/components/icons/index.tsx New icon set used across redesigned UI.
packages/studio-app/src/components/icons/ArkorMark.tsx Arkor mark icon used in header branding.
packages/studio-app/src/components/RunTraining.tsx Updates training runner UI to match new design system.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to +30
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-200 bg-red-50 px-2.5 py-1 text-[11px] font-medium text-red-700 dark:border-red-400/30 dark:bg-red-400/10 dark:text-red-300">
<Dot className="bg-red-500" />
offline
Comment on lines +178 to +183
<Composer
value={input}
onChange={setInput}
onSubmit={send}
disabled={!canSend && !input.trim().length}
/>
Comment on lines 98 to 102
const d = JSON.parse(ev.data) as { step: number; loss?: number | null };
setPoints((prev) => [
...prev,
{ step: d.step, loss: d.loss ?? null },
]);
Comment on lines 92 to 94
es.addEventListener("training.started", (ev: MessageEvent) => {
setStatus("running");
pushRaw(`[started] ${ev.data}`);
pushEvent("training.started", ev.data);
});
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 088cc33f8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

value={input}
onChange={setInput}
onSubmit={send}
disabled={!canSend && !input.trim().length}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep composer enabled before first prompt

In the initial empty-chat view, disabled is computed as !canSend && !input.trim().length, which evaluates to true on first render (input is empty), so the textarea is disabled and users cannot type their first message. This blocks Playground chat entirely until a message already exists, which can never happen from a fresh state.

Useful? React with 👍 / 👎.

Comment on lines +58 to +60
let counter = 0;
function pushEvent(event: string, data: string) {
const id = counter++;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset per-job state when jobId changes

This effect re-initializes the event counter for each new jobId but keeps appending into existing events/points state, so navigating from one job detail to another can mix old and new job data and produce duplicate event keys (id restarts at 0). Clearing per-job state at route change avoids stale status/logs leaking across jobs.

Useful? React with 👍 / 👎.

@soleil-colza soleil-colza changed the title Studio: Vercel/GitHub-style redesign with base-model Playground Studio: redesign with base-model Playground May 1, 2026
# Conflicts:
#	packages/studio-app/package.json
#	pnpm-lock.yaml
…aces

Playground (P1): the Composer rendered in the empty-chat state computed
its `disabled` prop as `!canSend && !input.trim().length`, which
evaluated to `true` on first render and blocked typing of the very first
message. Switch to `disabled={streaming}`; the inner Send button still
gates submission on a non-empty input.

JobDetail: clear `events`, `points`, `terminal`, `eventErr`, and `job`
when `jobId` changes so navigating between detail routes does not leak
stale logs, loss curves, or status across jobs (the per-effect
`counter` already restarts at 0, but the existing arrays were retained).

JobDetail: react to `training.started` SSE events by setting
`status: "running"` and (if the event carries a `timestamp`) seeding
`startedAt`, so the page reflects the live SSE source-of-truth instead
of waiting on the 5 s `/api/jobs` poll, which can lag or fail.

JobDetail: cap retained `LossPoint`s at `MAX_LOSS_POINTS = 2000` to
avoid unbounded growth on long/high-step training runs that would
otherwise slow LossChart re-renders.

JobDetail: `computeDuration` now falls back to `now - start` when
`Date.parse(job.completedAt)` returns NaN, so the helper never returns
NaN through its declared `number | null` contract.

route: strip trailing slashes after the leading `#/` so `#/jobs/`
resolves to `{ kind: "jobs" }` instead of falling through to `home`.

IdentityChip: when `error` is set, label the chip "error" (not
"offline" — the failure may be a 500 / auth issue, not a network drop)
and surface the actual message via the `title` tooltip.

Overview: drop the unused `CardFooter` import flagged by code-quality.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR overhauls the @arkor/studio-app SPA UI to a new dashboard-style design system (Tailwind v4 + Geist fonts), adds an Overview landing page, improves Jobs/Job detail presentation, and makes Playground usable immediately by defaulting to base-model chat.

Changes:

  • Add Tailwind v4 styling pipeline + Geist variable fonts, and replace legacy CSS with Tailwind-based theming (light default + data-theme="dark").
  • Introduce a new app shell (header/nav/theme toggle/connection chip) and new hash routes (#/ overview, #/jobs index, #/jobs/:id, #/playground).
  • Rebuild Jobs list, Job detail, and Playground UIs with new components (tables, status badges, charts, event stream, pickers, composer).

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds Tailwind/Vite plugin + Geist font packages and related dependency graph updates.
packages/studio-app/package.json Adds tailwindcss, @tailwindcss/vite, and Geist font devDependencies.
packages/studio-app/vite.config.ts Registers the Tailwind Vite plugin.
packages/studio-app/index.html Adds pre-paint theme initialization script and color-scheme meta; removes direct CSS link.
packages/studio-app/src/main.tsx Imports fonts + global styles once at app startup.
packages/studio-app/src/styles.css Replaces handcrafted CSS with Tailwind v4 import + theme variables + base styles + dark variant.
packages/studio-app/src/route.ts Adds hash route parsing + useHashRoute() for new routing structure.
packages/studio-app/src/App.tsx Switches to new AppShell layout and routes (Overview/Jobs/Job/Playground).
packages/studio-app/src/pages/Overview.tsx New Overview page: Run training card, recent jobs preview, quick-start tiles.
packages/studio-app/src/pages/JobsList.tsx New Jobs index page with search, status filters, refresh, skeleton/empty states.
packages/studio-app/src/pages/JobDetail.tsx Rebuilt job detail with SSE events stream, capped loss chart points, metadata sidebar, actions.
packages/studio-app/src/pages/Playground.tsx Rebuilt Playground with base/adaptor mode toggle, pickers, message list, composer, empty states.
packages/studio-app/src/lib/fonts.ts Imports Geist fonts via Fontsource as side effects (offline-friendly).
packages/studio-app/src/lib/theme.ts Adds theme persistence/helpers for data-theme + localStorage.
packages/studio-app/src/lib/format.ts Adds duration formatting, relative time formatting, and middle truncation utility.
packages/studio-app/src/components/icons/index.tsx Introduces a shared icon set used across the new UI.
packages/studio-app/src/components/icons/ArkorMark.tsx Adds the Arkor mark SVG for the header identity.
packages/studio-app/src/components/layout/AppShell.tsx Provides the new top-level layout wrapper (header + constrained main).
packages/studio-app/src/components/layout/Header.tsx Adds sticky header with identity, connection chip, theme toggle, and tabs.
packages/studio-app/src/components/layout/NavTabs.tsx Adds GitHub-style tab navigation tied to hash routes.
packages/studio-app/src/components/layout/IdentityChip.tsx Adds connection/status chip with environment-aware base URL display.
packages/studio-app/src/components/layout/ThemeToggle.tsx Adds a light/dark toggle wired to lib/theme.
packages/studio-app/src/components/RunTraining.tsx Restyles training runner UI, improves log autoscroll behavior, uses new Button/icons.
packages/studio-app/src/components/jobs/JobsTable.tsx Adds a reusable jobs table with status badges and relative time.
packages/studio-app/src/components/jobs/LossChart.tsx Adds upgraded SVG loss chart with axes, gridlines, gradient fill, hover tooltip.
packages/studio-app/src/components/jobs/EventsStream.tsx Adds structured, auto-scrolling events stream component.
packages/studio-app/src/components/jobs/JobMetaSidebar.tsx Adds metadata sidebar with copy-to-clipboard support.
packages/studio-app/src/components/playground/ModelToggle.tsx Adds segmented base/adaptor mode toggle UI.
packages/studio-app/src/components/playground/BaseModelPicker.tsx Adds base model dropdown picker UI.
packages/studio-app/src/components/playground/AdapterPicker.tsx Adds adaptor picker UI for completed jobs.
packages/studio-app/src/components/playground/MessageList.tsx Adds chat message rendering (user/assistant bubbles) with streaming caret indicator.
packages/studio-app/src/components/playground/Composer.tsx Adds autosizing textarea composer with Enter-to-send behavior.
packages/studio-app/src/components/ui/Button.tsx Adds shared button component styling/variants.
packages/studio-app/src/components/ui/IconButton.tsx Adds shared icon-only button component with focus/disabled states.
packages/studio-app/src/components/ui/Card.tsx Adds shared card primitives used by Overview/Jobs/Job detail.
packages/studio-app/src/components/ui/StatusBadge.tsx Adds shared status badge component for job statuses.
packages/studio-app/src/components/ui/RelativeTime.tsx Adds auto-updating relative time display.
packages/studio-app/src/components/ui/Skeleton.tsx Adds skeleton loader component for pending states.
packages/studio-app/src/components/ui/EmptyState.tsx Adds reusable empty state component.
packages/studio-app/src/components/ui/CopyButton.tsx Adds copy-to-clipboard UI with “copied” feedback.
packages/studio-app/src/components/ui/Breadcrumb.tsx Adds breadcrumb component for Job detail navigation.
packages/studio-app/src/components/ui/cn.ts Adds a small className join helper used throughout new components.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 67 to 82
@@ -63,105 +82,117 @@ export function Playground() {
});
Comment on lines +147 to +151
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
className="group flex items-start gap-4 rounded-xl border border-zinc-200 bg-white p-5 transition-colors hover:border-zinc-300 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-zinc-700"
Comment on lines +16 to +33
return (
<div
role="tablist"
aria-label="Model source"
className="inline-flex h-9 items-center rounded-full border border-zinc-200 bg-zinc-50 p-1 text-[13px] dark:border-zinc-800 dark:bg-zinc-900"
>
<Segment
active={mode === "base"}
disabled={disabled}
onClick={() => onChange("base")}
label="Base model"
/>
<Segment
active={mode === "adapter"}
disabled={disabled || adapterDisabled}
onClick={() => onChange("adapter")}
label="Adapter"
title={
Comment on lines +59 to +75
{open ? (
<div
role="listbox"
className="absolute right-0 top-full z-20 mt-2 min-w-[260px] overflow-hidden rounded-xl border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-950"
>
{SUPPORTED_BASE_MODELS.map((m) => {
const active = m === value;
return (
<button
key={m}
type="button"
role="option"
aria-selected={active}
onClick={() => {
onChange(m);
setOpen(false);
}}
Comment on lines +40 to +43
onClick={() => {
window.location.hash = `#/jobs/${j.id}`;
}}
className="group cursor-pointer border-t border-zinc-100 transition-colors hover:bg-zinc-50 dark:border-zinc-900 dark:hover:bg-zinc-900/60"
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 053f456437

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +42 to +44
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : String(err)),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set jobs state on fetch failure

When fetchJobs() rejects, this catch path only sets error and leaves jobs as null, so the render logic stays in the jobs === null loading branch and never shows the composer. In that state, base-model chat is completely blocked even though it does not require completed jobs; any /api/jobs outage or permission error makes Playground unusable instead of degrading to base-only mode.

Useful? React with 👍 / 👎.

… table

Playground send(): capture the assistant placeholder's index inside
the queued setMessages updater (`assistantIndex = prev.length + 1`) so
streaming fragments and the catch-branch error replacement target a
stable slot. This avoids the latent race where the streaming/catch
updater would otherwise rely on `out[out.length - 1]` and depend on
the initial placeholder updater having already been applied.

ModelToggle: drop the `role="tablist"` / `role="tab"` /
`aria-selected` semantics, which require arrow-key navigation and a
linked tabpanel that this control doesn't provide. The toggle is a
two-option picker, not a tab strip — switch to `role="group"` with
`aria-pressed` toggle buttons so assistive tech announces the state
correctly without expecting tabs keyboard behavior.

BaseModelPicker / AdapterPicker: remove the `role="listbox"` /
`role="option"` / `aria-selected` markup for the same reason — neither
implements the listbox keyboard contract (arrow nav, focus, active
descendant). Replace with `aria-haspopup` / `aria-expanded` on the
trigger and `aria-pressed` on each option; the popup is now a plain
button menu, which the existing Tab-to-focus + Enter-to-activate
behavior already supports.

JobsTable: make each `<tr>` a real keyboard-operable target by
adding `tabIndex={0}`, `role="link"`, an `aria-label="Open job <name>"`,
and `onKeyDown` for Enter / Space. The duplicate inner `<a>` on the
Name cell is dropped (it was a second tab stop pointing at the same
URL) so there's a single focusable handle per row, plus a
focus-visible ring so keyboard users can see where they are.

Overview: external `target="_blank"` link now uses
`rel="noopener noreferrer"` to block reverse-tabnabbing on the opened
page.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Redesigns Arkor Studio’s SPA UI (layout, navigation, and page styling) to match the marketing design language and introduces a “base-model-first” Playground experience so users can chat immediately without a completed training job.

Changes:

  • Adopt Tailwind v4 (via @tailwindcss/vite) + Geist Variable fonts (Fontsource) and implement light/dark theming via data-theme with a pre-paint initializer.
  • Rework Studio routing/app shell (sticky header + tab nav) and rebuild the main pages: Overview, Jobs list/detail, and Playground (base model default + adapter mode).
  • Add Japanese docs “Roadmap” page and cross-link existing “roadmap” references to anchors on that page.

Reviewed changes

Copilot reviewed 41 out of 42 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
pnpm-lock.yaml Locks new Tailwind/Fontsource dependencies and transitive updates (e.g., jiti/lightningcss).
packages/studio-app/vite.config.ts Adds @tailwindcss/vite plugin to the Studio Vite build.
packages/studio-app/src/styles.css Replaces legacy CSS with Tailwind v4 import, theme tokens, and base styles.
packages/studio-app/src/route.ts Introduces hash-route parsing for Overview/Jobs/Job/Playground routes.
packages/studio-app/src/pages/Playground.tsx Rebuilds Playground UI; base-model-first flow; new components for picker/chat/composer.
packages/studio-app/src/pages/Overview.tsx Adds new Overview page with “Run training”, recent jobs preview, and quick-start tiles.
packages/studio-app/src/pages/JobsList.tsx Replaces basic jobs list with filtered/searchable table + manual refresh UX.
packages/studio-app/src/pages/JobDetail.tsx Rebuilds job detail page with SSE event stream UI, loss chart, and metadata sidebar.
packages/studio-app/src/main.tsx Imports font side-effects and Tailwind-based global stylesheet.
packages/studio-app/src/lib/theme.ts Adds theme persistence helpers (localStorage + data-theme setter/getters).
packages/studio-app/src/lib/format.ts Adds duration/relative-time/id truncation helpers for new UI surfaces.
packages/studio-app/src/lib/fonts.ts Adds Fontsource side-effect imports for Geist Variable + Geist Mono Variable.
packages/studio-app/src/components/ui/cn.ts Adds cn() utility for className composition.
packages/studio-app/src/components/ui/StatusBadge.tsx Adds status pill UI for job statuses (queued/running/completed/failed/cancelled).
packages/studio-app/src/components/ui/Skeleton.tsx Adds loading skeleton primitive for tables/cards.
packages/studio-app/src/components/ui/RelativeTime.tsx Adds auto-updating relative time renderer for job timestamps.
packages/studio-app/src/components/ui/IconButton.tsx Adds accessible icon-only button primitive used in header/actions.
packages/studio-app/src/components/ui/EmptyState.tsx Adds empty state component used across Jobs/Playground/Overview.
packages/studio-app/src/components/ui/CopyButton.tsx Adds clipboard copy button with “Copied” feedback (used in Job metadata).
packages/studio-app/src/components/ui/Card.tsx Adds Card primitives (header/content/footer) for consistent layout.
packages/studio-app/src/components/ui/Button.tsx Adds button primitive with variants/sizes and optional icons.
packages/studio-app/src/components/ui/Breadcrumb.tsx Adds breadcrumb UI for job detail navigation context.
packages/studio-app/src/components/playground/ModelToggle.tsx Adds segmented control for switching Base model vs Adapter mode.
packages/studio-app/src/components/playground/MessageList.tsx Adds chat transcript renderer with autoscroll + streaming caret.
packages/studio-app/src/components/playground/Composer.tsx Adds autosizing textarea composer with Enter-to-send behavior.
packages/studio-app/src/components/playground/BaseModelPicker.tsx Adds dropdown picker for supported base models (custom menu UI).
packages/studio-app/src/components/playground/AdapterPicker.tsx Adds dropdown picker for completed adapter jobs (custom menu UI).
packages/studio-app/src/components/layout/ThemeToggle.tsx Adds header theme toggle wiring to data-theme + persistence.
packages/studio-app/src/components/layout/NavTabs.tsx Adds GitHub-style underlined tab navigation.
packages/studio-app/src/components/layout/IdentityChip.tsx Replaces old identity text with a styled connection/auth chip.
packages/studio-app/src/components/layout/Header.tsx Adds sticky header with branding, nav tabs, identity chip, and theme toggle.
packages/studio-app/src/components/layout/AppShell.tsx Adds shared app shell wrapper (header + constrained main layout).
packages/studio-app/src/components/jobs/LossChart.tsx Adds upgraded loss chart (gridlines/labels/gradient/hover tooltip).
packages/studio-app/src/components/jobs/JobsTable.tsx Adds reusable jobs table with clickable rows and compact mode.
packages/studio-app/src/components/jobs/JobMetaSidebar.tsx Adds job metadata sidebar with copy-to-clipboard affordance.
packages/studio-app/src/components/jobs/EventsStream.tsx Adds styled SSE event list with autoscroll behavior.
packages/studio-app/src/components/icons/index.tsx Adds shared icon set used throughout the new UI.
packages/studio-app/src/components/icons/ArkorMark.tsx Adds Arkor mark SVG used in the header brand.
packages/studio-app/src/components/RunTraining.tsx Updates RunTraining UX to use new button/card styling and improved log panel.
packages/studio-app/src/App.tsx Switches to new AppShell + new routing, adds Overview and /jobs route.
packages/studio-app/package.json Adds Tailwind + Fontsource devDependencies for Studio.
packages/studio-app/index.html Adds pre-paint theme initializer and color-scheme meta; removes direct CSS link.
docs/ja/sdk/create-arkor.mdx Links “ロードマップマーカー” reference to the new roadmap page anchor.
docs/ja/roadmap.mdx Adds Japanese roadmap page with anchored sections.
docs/ja/cli/overview.mdx Links “ロードマップにあります” to the new roadmap page anchor.
docs/ja/cli/auth.mdx Links token auto-refresh note to the new roadmap page anchor.
docs/docs.json Adds “ロードマップ” group with ja/roadmap to the Japanese docs nav.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +5 to +11
export function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
Comment on lines +28 to +38
async function load() {
try {
setRefreshing(true);
const { jobs } = await fetchJobs();
setJobs(jobs);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRefreshing(false);
}
Comment on lines +58 to +77
// Capture the assistant placeholder's index inside the queued
// updater so subsequent streaming/error updates target the same
// slot regardless of how React batches the queued setMessages
// calls.
let assistantIndex = -1;
setMessages((prev) => {
assistantIndex = prev.length + 1;
return [...prev, userMsg, { role: "assistant", content: "" }];
});
setInput("");
setStreaming(true);
responseRef.current = "";

function writeAssistant(content: string) {
setMessages((prev) => {
if (assistantIndex < 0 || assistantIndex >= prev.length) return prev;
const out = prev.slice();
out[assistantIndex] = { role: "assistant", content };
return out;
});
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2be277264

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

});
es.addEventListener("end", () => es.close());
es.onerror = () => pushRaw("[stream error]");
es.onerror = () => setEventErr("Event stream interrupted.");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stream error once SSE reconnects

es.onerror sets a persistent "Event stream interrupted." banner, but nothing clears it when the EventSource reconnects and starts delivering events again. In environments with transient disconnects (dev server restart, brief network flap), the job page can continue showing an error even while live events are flowing, which is a misleading status regression for operators.

Useful? React with 👍 / 👎.

Comment on lines 31 to 32
};
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh manifest state after trainer setup

The manifest is fetched only once on mount, so the component never re-checks whether createTrainer(...) was added later in the same Studio session. A common flow is opening Overview before defining a trainer; after the user adds it in src/arkor/index.ts, hasTrainer remains stale and the Run Training button stays disabled until a full page reload, blocking training from the current session.

Useful? React with 👍 / 👎.

…during streaming

JobDetail (Codex P1): `liveStatus = "running"` is set when the SSE
`training.started` frame arrives but never cleared on its own. If the
SSE stream drops before `training.completed` / `training.failed`
arrives, the polled `/api/jobs` correctly returns a terminal status
but the UI was still preferring the stale `liveStatus`, so the page
kept showing "running" with the duration ticker active. Update the
status precedence so polled terminal statuses (`completed`, `failed`,
`cancelled`) preempt `liveStatus`. SSE-derived running still beats
polled queued, which is the case `liveStatus` is actually for.

Playground (Codex P2 + Copilot): the AdapterPicker stayed interactive
while a response was streaming, and selecting a different adapter
clears `messages` without aborting the in-flight request — incoming
fragments would then call `writeAssistant` against the cleared list
(no-op) and the user would see an empty chat until the orphaned
stream finished. Pass `disabled={streaming}` (mirroring
BaseModelPicker), and have AdapterPicker forward that into the
underlying native `<select>`'s `disabled` together with the existing
"no completed jobs" guard.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40ed2b0667

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

size="sm"
leadingIcon={<Sparkles />}
onClick={() => {
window.location.hash = "#/playground";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve selected job when navigating to Playground

When users click Open in Playground from a completed job, the handler only sets #/playground, so the destination page cannot infer which adapter was requested and falls back to its default selection logic (first completed job). In projects with multiple completed runs, this can open a different adapter than the one the user was inspecting, leading to evaluations against the wrong model and misleading comparisons.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 44 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 44 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

function onMouseMove(e: React.MouseEvent<SVGRectElement>) {
const rect = (e.currentTarget as SVGRectElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const fraction = (x - PADDING.left) / innerW;
Comment on lines +19 to +23
export function setTheme(theme: Theme): void {
if (typeof document === "undefined") return;
document.documentElement.dataset.theme = theme;
try {
window.localStorage.setItem(STORAGE_KEY, theme);
…ver, harden setTheme

JobDetail / Playground / route (Codex P2): the "Open in Playground"
button on a completed job's detail view used to set the hash to a
bare `#/playground`, so Playground had no way to know which adapter
the user was inspecting and fell back to its default-selection logic
(first completed job in the list). On projects with multiple
completed runs that meant clicking the button could open a different
adapter from the one on screen, leading to evaluations against the
wrong model.

  * Extend the Route union with `playground.adapterJobId?` and parse
    it from a `?adapter=<id>` query string on the hash.
  * `Playground` now accepts `initialAdapterId` and seeds both `mode`
    (defaults to "adapter" when set) and `selectedJob` from it; the
    existing fetchJobs effect uses a functional `setSelectedJob(prev
    => prev ?? completed[0]!.id)` so the seed survives.
  * The detail-page button writes `#/playground?adapter=<jobId>` so
    the destination Playground lands on the right run.

LossChart (Copilot): the hover handler computed `fraction = (x -
PADDING.left) / innerW` but `x` was already `e.clientX -
rect.left`, and the `<rect>` is positioned at `x={PADDING.left}`, so
its bounding-box origin already accounts for the chart's left
padding. Subtracting PADDING.left a second time shifted the
step-mapping left by 48 px and made the rightmost step unreachable.
Compute the fraction relative to `rect.width` directly and clamp to
[0, 1].

theme.ts (Copilot): `setTheme` only guarded `typeof document` before
touching `window.localStorage`, but some test / SSR environments
expose `document` without a `window` global, which would throw a
ReferenceError on the localStorage line. Add an explicit `typeof
window === "undefined"` early return so the dataset write still
happens but the persistence call is skipped cleanly.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 44 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)

packages/studio-app/src/pages/Playground.tsx:82

  • adapterDisabled treats the loading state (jobs === null) the same as “no completed jobs”. When navigating in with initialAdapterId (mode starts as "adapter"), this makes the page briefly render the “No completed jobs yet” empty state until /api/jobs resolves. Consider distinguishing jobs === null (loading) from jobs.length === 0 (empty), and only disabling the Adapter segment / showing the empty-state when jobs have actually loaded.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 61 to 64
// `load` is stable (defined in component scope, no captured deps),
// intentionally omitted to avoid re-scheduling on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Copilot caught that the previous round's claim of `load` being
"stable" was wrong: a function declaration in component scope is
re-created every render, so the empty deps array was masked by an
eslint suppression rather than actually safe. Wrap `load` in
`useCallback(..., [])` (it only refers to setters and a ref, all
stable) so the reference really is preserved across renders, then
include it in the polling effect's deps and drop the suppression.
Behavior is unchanged.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca310c157f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +394 to +397
if (terminal) return Math.max(0, now - start);
if (job?.status === "running") return Math.max(0, now - start);
if (liveStartedAt) return Math.max(0, now - start);
return null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Freeze duration when non-running job lacks completion time

computeDuration continues to return now - startedAt whenever liveStartedAt is set, even if the polled status is terminal and there is no completedAt. After a training.started event, cancelled jobs (or any terminal job missing a completion timestamp) will keep showing an increasing duration forever, which misrepresents historical runtime as still progressing.

Useful? React with 👍 / 👎.

// Functional update so we don't capture a stale `selectedJob`
// from the mount-time closure (the effect runs once with
// `[]` deps).
setSelectedJob((prev) => prev ?? completed[0]!.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile preselected adapter against fetched completed jobs

The mount-time adapter selection preserves any non-null initialAdapterId without checking whether that ID exists in the fetched completed-job list. If the URL contains a stale/deleted job ID, canSend still treats adapter mode as valid and sends inference requests with that invalid jobId, leading to repeated server errors until the user manually changes selection.

Useful? React with 👍 / 👎.

…e URL adapter id

JobDetail (Codex P2): `computeDuration` was returning `now - startedAt`
whenever `liveStartedAt` was set, even after the job had reached a
terminal status (cancelled / failed) without a `completedAt`. The
duration ticker would then climb forever, misrepresenting historical
runtime as if the run were still in progress. Restructure the
function so a known-terminal status — either an SSE terminal frame
or a polled `completed`/`failed`/`cancelled` — short-circuits to
`null` (rendered as `—`) when no completion timestamp is available
to anchor against. Also tighten the previous Date.parse(completedAt)
NaN fallback from `now - start` to `null` for the same reason.

Playground (Codex P2): the mount-time `selectedJob` could be seeded
from `initialAdapterId` (parsed out of `?adapter=<id>` on the
playground hash), and the prior reconcile-with-fetched-jobs step only
ran when `selectedJob` was null. If the URL pointed at a stale or
deleted job id, the page stayed in adapter mode with that id forever
and any send fired inference requests against a non-existent adapter.
Validate the seeded id against the fetched completed list and fall
back to the first completed job (or null if none) when it doesn't
match, so the AdapterPicker / canSend reflect what's actually
selectable.
Copilot AI review requested due to automatic review settings May 2, 2026 17:22
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 44 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 59 to 75
async function run() {
setRunning(true);
setLog("");
try {
await streamTraining((chunk) => {
setLog((prev) => {
const next = prev + chunk;
queueMicrotask(() => {
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
});
return next;
});
setLog((prev) => appendCapped(prev, chunk));
});
} catch (err) {
setLog((prev) => prev + `\n[error] ${err instanceof Error ? err.message : String(err)}\n`);
setLog((prev) =>
appendCapped(
prev,
`\n[error] ${err instanceof Error ? err.message : String(err)}\n`,
),
);
} finally {
setRunning(false);
}
Comment on lines +19 to +22
if (path === "playground") {
const params = new URLSearchParams(query);
const adapterJobId = params.get("adapter") ?? undefined;
return { kind: "playground", adapterJobId };
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0cc58058d5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +12 to +14
useEffect(() => {
const t = setInterval(() => setTick((n) => n + 1), 30_000);
return () => clearInterval(t);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a shared clock for relative-time updates

RelativeTime creates its own setInterval, and this component is rendered once per job row in both JobsTable usages (pages/JobsList.tsx and pages/Overview.tsx). In projects with many jobs, this scales to hundreds of independent timers firing every 30s, causing avoidable wakeups/re-renders and measurable UI overhead for a long-lived Studio tab. Use a shared timer (e.g., one tick in the table/page context) so all rows update from the same clock.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 44 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +202 to +208
{/* `mode === "adapter" && jobs === null` is unreachable: the
Adapter segment of ModelToggle stays disabled while jobs is
null, so the user can't switch into Adapter mode until jobs
has resolved. The next branch below covers the case where
jobs has resolved but the user is in Adapter mode without
completed jobs. */}
{mode === "adapter" && adapterDisabled ? (
…t, restore loading branch

RelativeTime (Codex P2): every row in JobsTable / Overview that
displayed a relative timestamp owned its own
`setInterval(... 30_000)`. With dozens of jobs that scaled to
hundreds of independent timers per Studio tab. Move the timer to a
module-level subscriber set: the first instance lazily starts one
shared `setInterval`, subsequent instances just register a callback,
and the timer is torn down when the last subscriber unmounts. All
rows now tick on the same clock.

streamTraining + RunTraining (Copilot): the inference stream had an
AbortController already, but the `/api/train` stream did not.
Navigating away from Overview while training was streaming would
leave the async loop running and trigger setState after unmount.

  * `streamTraining(onChunk, file?, signal?)` now accepts an
    `AbortSignal`, threads it into the `apiFetch` call, and cancels
    the underlying `body.getReader()` when the signal fires so the
    `await reader.read()` doesn't hang.
  * `RunTraining` keeps the controller in `trainingAbortRef`, aborts
    on unmount and on any subsequent `run()` invocation, and treats
    `ac.signal.aborted` in catch / finally as expected (no `[error]`
    line in the log, no `setRunning(false)` after unmount).

route.ts (Copilot): `?adapter=` with an empty value used to flow
through as `adapterJobId: ""` for one render, briefly tagging an
empty string as a "selected" adapter. Trim and treat empty as
undefined so a malformed URL doesn't slip past.

Playground (Copilot): `mode === "adapter" && jobs === null` is in
fact reachable when we mount with `initialAdapterId` from the URL
("Open in Playground" navigation), so the previous round's removal
of that branch was wrong — the page would briefly show "No completed
jobs yet" while the initial fetch was still in flight. Restore the
"Loading jobs…" branch as the explicit loading state and keep the
empty-list state for the genuine empty case.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/studio-app/src/route.ts Outdated
Comment on lines +10 to +14
const raw = window.location.hash.replace(/^#\/?/, "").replace(/\/+$/, "");
const queryStart = raw.indexOf("?");
const path = queryStart === -1 ? raw : raw.slice(0, queryStart);
const query = queryStart === -1 ? "" : raw.slice(queryStart + 1);
if (path === "jobs") return { kind: "jobs" };
Comment on lines 134 to +141
es.addEventListener("training.log", (ev: MessageEvent) => {
pushEvent("training.log", ev.data);
try {
const d = JSON.parse(ev.data) as {
step: number;
loss?: number | null;
};
setEvents((prev) => [
...prev,
{ step: d.step, loss: d.loss ?? null },
]);
pushRaw(`[log] step=${d.step} loss=${d.loss ?? "—"}`);
const d = JSON.parse(ev.data) as { step: number; loss?: number | null };
// Cap retained points so long/high-step runs don't grow without
// bound and slow LossChart re-renders. 2000 is well above the
// chart's visual resolution at any reasonable width.
setPoints((prev) => {
route.ts (Copilot): the previous round trimmed trailing slashes from
the raw hash before splitting off the query string, so a hash like
`#/playground/?adapter=foo` left the slash on the path segment
(`playground/?adapter=foo` → split → `path === "playground/"`) and
fell through to `home`. Move the `replace(/\/+$/, "")` after the
path/query split so the path itself is trimmed regardless of whether
a query is present.

JobDetail (Copilot): every SSE listener parsed `ev.data` twice — once
inside `pushEvent` to format the events-stream message and once
immediately after to extract structured fields (step/loss, timestamp,
artifacts, error). For high-frequency `training.log` frames that's
avoidable per-frame overhead. Refactor:

  * `pushEvent(event, data, parsed)` now takes the already-parsed
    payload and only does its own inspection — no `JSON.parse` inside.
  * A small `safeParse(data): unknown` helper is the single parse
    site; each listener calls it once and threads the result into
    both `pushEvent` and the listener's own state-update path.
  * The `training.log` path also rejects payloads without a numeric
    `step` (was implicitly assumed before) so the chart never gets
    `{ step: undefined, ... }`.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +243 to +255
// Cancel the underlying body when the caller aborts so we don't
// hang on `reader.read()` after the page (and the AbortController
// cleanup) have moved on.
const onAbort = () => void reader.cancel().catch(() => {});
signal?.addEventListener("abort", onAbort);
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
onChunk(decoder.decode(value, { stream: true }));
}
} finally {
signal?.removeEventListener("abort", onAbort);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants