Studio: redesign with base-model Playground#80
Conversation
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.
There was a problem hiding this comment.
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.
| 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"; | ||
| } |
| const es = openJobEvents(jobId); | ||
| es.addEventListener("training.started", (ev: MessageEvent) => { | ||
| setStatus("running"); | ||
| pushRaw(`[started] ${ev.data}`); | ||
| pushEvent("training.started", ev.data); | ||
| }); |
| 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 }, | ||
| ]); |
| if (job.completedAt) { | ||
| return Math.max(0, Date.parse(job.completedAt) - start); | ||
| } |
| 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" }; |
| 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)}`; | ||
| } |
There was a problem hiding this comment.
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 viadata-themeon<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.
| <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 |
| <Composer | ||
| value={input} | ||
| onChange={setInput} | ||
| onSubmit={send} | ||
| disabled={!canSend && !input.trim().length} | ||
| /> |
| const d = JSON.parse(ev.data) as { step: number; loss?: number | null }; | ||
| setPoints((prev) => [ | ||
| ...prev, | ||
| { step: d.step, loss: d.loss ?? null }, | ||
| ]); |
| es.addEventListener("training.started", (ev: MessageEvent) => { | ||
| setStatus("running"); | ||
| pushRaw(`[started] ${ev.data}`); | ||
| pushEvent("training.started", ev.data); | ||
| }); |
There was a problem hiding this comment.
💡 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} |
There was a problem hiding this comment.
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 👍 / 👎.
| let counter = 0; | ||
| function pushEvent(event: string, data: string) { | ||
| const id = counter++; |
There was a problem hiding this comment.
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 👍 / 👎.
# 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.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
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,#/jobsindex,#/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.
| @@ -63,105 +82,117 @@ export function Playground() { | |||
| }); | |||
| <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" |
| 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={ |
| {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); | ||
| }} |
| 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" |
There was a problem hiding this comment.
💡 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".
| .catch((err: unknown) => | ||
| setError(err instanceof Error ? err.message : String(err)), | ||
| ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 viadata-themewith 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.
| 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"; |
| 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); | ||
| } |
| // 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; | ||
| }); |
There was a problem hiding this comment.
💡 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."); |
There was a problem hiding this comment.
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 👍 / 👎.
| }; | ||
| }, []); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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"; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
| 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.
There was a problem hiding this comment.
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
adapterDisabledtreats the loading state (jobs === null) the same as “no completed jobs”. When navigating in withinitialAdapterId(mode starts as "adapter"), this makes the page briefly render the “No completed jobs yet” empty state until/api/jobsresolves. Consider distinguishingjobs === null(loading) fromjobs.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.
| // `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.
There was a problem hiding this comment.
💡 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".
| 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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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); | ||
| } |
| if (path === "playground") { | ||
| const params = new URLSearchParams(query); | ||
| const adapterJobId = params.get("adapter") ?? undefined; | ||
| return { kind: "playground", adapterJobId }; |
There was a problem hiding this comment.
💡 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".
| useEffect(() => { | ||
| const t = setInterval(() => setTick((n) => n + 1), 30_000); | ||
| return () => clearInterval(t); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| {/* `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.
There was a problem hiding this comment.
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.
| 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" }; |
| 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, ... }`.
There was a problem hiding this comment.
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.
| // 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); |
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.
@tailwindcss/vite, Geist Sans + Geist Mono via Fontsource (offline-friendly), light default with a header theme toggle that flipsdata-theme="dark"on<html>(with a pre-paint script inindex.htmlto prevent flashes).#/jobsroute lets the home page act as a Vercel-style overview while the full job index lives at its own URL./api/manifest, hint whensrc/arkor/index.tshas nocreateTrainer), compact recent-jobs preview, quick-start tile pair.<pre>; live-ticking duration in a Vercel-style metadata sidebar with copy-to-clipboard Job ID).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— cleanpnpm test— 13/13 passpnpm bundle—dist/includes a single hashed CSS (~40 KB / 7.5 KB gzipped) and the Geist woff2 setpnpm dev+arkor devon:4000— boot, walk every page in light + dark, toggle theme and reload to confirm persistenceTest plan
createTrainer(...)exists; once registered, button label includes the trainer name; log streams into the collapsible panelprefers-color-schemerespected on first load, no FOUT / no theme flash🤖 Generated with Claude Code