Compare commits

..

3 Commits

Author SHA1 Message Date
nntrivi2001 94ee2dc79c chore(release): bump version to 1.8.2 2026-09-23 13:23:40 +07:00
nntrivi2001 3f50e162e6 feat(run): Enter sends the prompt, and a busy lane attaches instead of failing
- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter
  inserts a newline, and IME composition (Vietnamese/CJK) never sends.
- Starting or resuming into a lane that still holds a live run answered a
  raw 409 ERUNLIVE; the console now attaches to that run with a notice.
  Checked by error code, since the server's message is prose.
- Active runs gains a two-click Kill for live dashboard-spawned rows, the
  way to free a lane when the previous session really should go.
2026-09-23 13:23:20 +07:00
nntrivi2001 1f5c04e765 feat(ui): warm light theme and pre-mixed status fills
- Light mode moves to Claude's ivory/terracotta family: page #e8e5da, cards
  #f5f3ec (a notch below Claude's ivory, no pure-white step), warm
  borders and card shadow; dark mode is untouched.
- The surface ramp rises in lightness with its index in BOTH themes, so a
  card sits brighter than the page and every hover lifts instead of dims.
- Light --accent #a34a28 (the brand #cc785c is 3.1:1 on ivory); --fg-muted
  held at >=4.5:1; new --syn-* syntax tokens, dark keeps its pastels.
- Status/accent fills (bg-*/N, border-*/N) compile to color-mix against
  --tint-base instead of rgb(/a), so a chip is one colour on every surface.
- Hardcoded dark-only hovers (hover:text-white, hover:bg-white/5) replaced
  by tokens in DateTimePicker and SessionDrillIn.
2026-09-23 13:23:00 +07:00
20 changed files with 400 additions and 77 deletions
+1
View File
@@ -470,6 +470,7 @@ graph TD
### Splash & loading UX ### Splash & loading UX
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies. - **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per theme, consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#a34a28` — Claude's terracotta two steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's `#0090ff`, which is 3.3:1 on white and fails AA; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`. - **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). - **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover. - **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
+1 -1
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -53,6 +53,16 @@ The client is a single-page application (SPA) built with modern web technologies
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts - **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds - **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class - **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
- **React Router 6.28** - Client-side routing with nested layouts - **React Router 6.28** - Client-side routing with nested layouts
- **WebSocket** - Real-time event streaming from server - **WebSocket** - Real-time event streaming from server
- **Lucide Icons** - Modern, consistent icon set - **Lucide Icons** - Modern, consistent icon set
+7 -4
View File
@@ -174,7 +174,7 @@ export function DateTimePicker({
? "bg-accent text-white font-medium" ? "bg-accent text-white font-medium"
: isToday : isToday
? "bg-surface-3 text-accent font-medium" ? "bg-surface-3 text-accent font-medium"
: "hover:bg-surface-2 text-fg-secondary hover:text-white" : "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
}`} }`}
> >
{i} {i}
@@ -201,7 +201,10 @@ export function DateTimePicker({
{dateObj ? formatDisplay(dateObj) : placeholder} {dateObj ? formatDisplay(dateObj) : placeholder}
</span> </span>
{dateObj && ( {dateObj && (
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} /> <X
className="w-3 h-3 text-fg-muted hover:text-fg-primary shrink-0"
onClick={clearValue}
/>
)} )}
</button> </button>
@@ -216,7 +219,7 @@ export function DateTimePicker({
onClick={() => onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)) setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
} }
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white" className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
@@ -228,7 +231,7 @@ export function DateTimePicker({
onClick={() => onClick={() =>
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1)) setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
} }
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white" className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
+67 -10
View File
@@ -15,6 +15,15 @@
* swaps the whole pane over to the new lane — its cwd, its tool, its history, * swaps the whole pane over to the new lane — its cwd, its tool, its history,
* and its live tmux session — instead of leaving the previous lane's terminal * and its live tmux session — instead of leaving the previous lane's terminal
* on screen under a new lane's header. * on screen under a new lane's header.
*
* A lane's pane is one tmux session, so starting or resuming into a lane
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
* that and attach to the still-live session instead of failing outright
* (with a notice explaining what happened), since that session is still
* reachable and the user did not ask to kill it. `killRun` is the other half:
* the Active runs list's Kill button (`RunHistory.tsx`), for when the user
* DOES want that previous session gone so a fresh start/resume can proceed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -246,12 +255,16 @@ export function LaneConsolePane({
resumeLast: args.resumeLast, resumeLast: args.resumeLast,
}); });
} catch (laneErr: unknown) { } catch (laneErr: unknown) {
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr); // A code-only check: the server's message is human prose ("lane
if (msg.includes("409") || msg.includes("ERUNLIVE")) { // already has a live run"), never the literal strings "409" or
// "ERUNLIVE" — matching against `.message` here is dead code that
// always falls through to the raw error below.
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null); const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId); const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) { if (updatedLane?.run_id) {
await attachToRun(updatedLane.run_id); await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return; return;
} }
} }
@@ -307,13 +320,34 @@ export function LaneConsolePane({
onLaneCreated(ensureResult.lane); onLaneCreated(ensureResult.lane);
} }
const laneStartResult = await api.lanes.action(targetLaneId, "start", { let laneStartResult;
prompt: "", try {
model: item.model || undefined, laneStartResult = await api.lanes.action(targetLaneId, "start", {
permissionMode: item.permission_mode || undefined, prompt: "",
effort: item.effort || undefined, model: item.model || undefined,
resumeSessionId: item.session_id, permissionMode: item.permission_mode || undefined,
}); effort: item.effort || undefined,
resumeSessionId: item.session_id,
});
} catch (laneErr: unknown) {
// The lane's own pane is still busy with a PREVIOUS live session —
// that session is still active and reachable, so land on it
// instead of failing outright. Killing it (Active runs → Kill) is
// what actually frees the lane for this resume. Checked by `.code`
// — the server's `.message` is prose, never the literal string
// "ERUNLIVE" or "409".
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
const fresh = await api.lanes.list().catch(() => null);
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
if (updatedLane?.run_id) {
onLaneIdChange(targetLaneId);
await attachToRun(updatedLane.run_id);
setError(t("errors.attachedToLiveInstead"));
return;
}
}
throw laneErr;
}
if (!laneStartResult.lane?.run_id) { if (!laneStartResult.lane?.run_id) {
throw new Error("No run_id returned from lane start"); throw new Error("No run_id returned from lane start");
@@ -343,7 +377,7 @@ export function LaneConsolePane({
setBusy(null); setBusy(null);
} }
}, },
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange] [busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
); );
const onViewFromHistory = useCallback( const onViewFromHistory = useCallback(
@@ -360,6 +394,28 @@ export function LaneConsolePane({
setError(null); setError(null);
}, []); }, []);
// Frees a lane blocked by a live run nobody wants anymore (see Active
// runs' Kill button) — the lane's own `run_id` self-heals to null the next
// time it's read (routes/lanes.js's healRunId), so no lane-side call here.
const killRun = useCallback(
async (id: string) => {
setBusy("kill");
setError(null);
try {
await api.run.kill(id);
if (handle?.id === id) newRun();
refreshList();
} catch (err) {
setError(
t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" })
);
} finally {
setBusy(null);
}
},
[handle, newRun, refreshList, t]
);
if (laneId === null && showLaneSelector) { if (laneId === null && showLaneSelector) {
return ( return (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4"> <div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
@@ -438,6 +494,7 @@ export function LaneConsolePane({
externalSessions={externalSessions} externalSessions={externalSessions}
onResumeFromHistory={onResumeFromHistory} onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory} onViewFromHistory={onViewFromHistory}
onKill={killRun}
onRefresh={refreshList} onRefresh={refreshList}
/> />
</header> </header>
+51 -4
View File
@@ -10,18 +10,23 @@
* - `RunsModal` — the merged list itself: live in-memory handles from * - `RunsModal` — the merged list itself: live in-memory handles from
* `activeRuns` deduped against persistent `runHistory`, newest first, with * `activeRuns` deduped against persistent `runHistory`, newest first, with
* status / mode chip filters, a free-text search, and the per-row Attach / * status / mode chip filters, a free-text search, and the per-row Attach /
* Resume / View actions. * Resume / View / Kill actions.
* *
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn — * `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so * e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
* "Active runs" counts everything actually running. They carry no tmux session * "Active runs" counts everything actually running. They carry no tmux session
* the dashboard can attach to, so their only action is Resume, which spawns a * the dashboard can attach to, so their only action is Resume, which spawns a
* fresh tmux-backed `claude --resume <session>` in that cwd. * fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
* from them for the same reason.
* *
* Props only: no API call of its own. The page passes `activeRuns`, * Props only: no API call of its own. The page passes `activeRuns`,
* `runHistory` and `externalSessions` in and gets attach / resume / view / * `runHistory` and `externalSessions` in and gets attach / resume / view /
* refresh back out through callbacks; the 2 s refresh ticker the modal runs * kill / refresh back out through callbacks; the 2 s refresh ticker the modal
* just calls `onRefresh`. * runs just calls `onRefresh`. Kill (two clicks, same confirm convention as
* discarding a file in the Changes panel) is what actually frees a lane whose
* pane is still busy with a previous session — `LaneConsolePane` needs it
* because starting or resuming into a lane already holding a live run answers
* 409 ERUNLIVE rather than overwriting a prompt in flight.
* *
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -39,6 +44,7 @@ import {
Search, Search,
RotateCcw, RotateCcw,
Eye, Eye,
Square,
} from "lucide-react"; } from "lucide-react";
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api"; import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
import type { Session } from "../../lib/types"; import type { Session } from "../../lib/types";
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
externalSessions = [], externalSessions = [],
onResumeFromHistory, onResumeFromHistory,
onViewFromHistory, onViewFromHistory,
onKill,
onRefresh, onRefresh,
}: { }: {
activeRuns: RunListResponse | null; activeRuns: RunListResponse | null;
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
externalSessions?: Session[]; externalSessions?: Session[];
onResumeFromHistory: (item: DashboardRunHistoryItem) => void; onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
onViewFromHistory: (item: DashboardRunHistoryItem) => void; onViewFromHistory: (item: DashboardRunHistoryItem) => void;
/** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard-
* spawned rows only — an external session has no tmux session here to
* kill. */
onKill: (id: string) => void;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
setOpen(false); setOpen(false);
onViewFromHistory(item); onViewFromHistory(item);
}} }}
onKill={onKill}
runHistory={historyItems} runHistory={historyItems}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onRefresh={onRefresh} onRefresh={onRefresh}
@@ -273,6 +285,7 @@ export function RunsModal({
onAttach, onAttach,
onResume, onResume,
onView, onView,
onKill,
runHistory, runHistory,
onClose, onClose,
onRefresh, onRefresh,
@@ -282,6 +295,7 @@ export function RunsModal({
onAttach: (id: string) => void; onAttach: (id: string) => void;
onResume: (item: DashboardRunHistoryItem) => void; onResume: (item: DashboardRunHistoryItem) => void;
onView: (item: DashboardRunHistoryItem) => void; onView: (item: DashboardRunHistoryItem) => void;
onKill: (id: string) => void;
runHistory: DashboardRunHistoryItem[]; runHistory: DashboardRunHistoryItem[];
onClose: () => void; onClose: () => void;
onRefresh: () => void; onRefresh: () => void;
@@ -446,6 +460,7 @@ export function RunsModal({
const h = historyById.get(r.id); const h = historyById.get(r.id);
if (h) onView(h); if (h) onView(h);
}} }}
onKill={() => onKill(r.id)}
/> />
); );
}) })
@@ -512,14 +527,20 @@ function UnifiedRunRowView({
onAttach, onAttach,
onResume, onResume,
onView, onView,
onKill,
}: { }: {
row: UnifiedRunRow; row: UnifiedRunRow;
isCurrent: boolean; isCurrent: boolean;
onAttach: () => void; onAttach: () => void;
onResume: () => void; onResume: () => void;
onView: () => void; onView: () => void;
onKill: () => void;
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
// Two clicks before the tmux session actually dies — same convention as
// discarding a file's changes in the Changes panel, for the one action
// here that cannot be undone.
const [confirmKill, setConfirmKill] = useState(false);
const startedDate = new Date(row.startedAt); const startedDate = new Date(row.startedAt);
// 0 is "unknown", not the epoch — a run whose persisted row is missing has no // 0 is "unknown", not the epoch — a run whose persisted row is missing has no
// start time, and printing "Jan 1, 08:00 AM" for it reads as real data. // start time, and printing "Jan 1, 08:00 AM" for it reads as real data.
@@ -593,6 +614,32 @@ function UnifiedRunRowView({
{t("runs.viewLabel", "View")} {t("runs.viewLabel", "View")}
</button> </button>
)} )}
{row.isLive && !row.external && (
<button
onClick={() => {
if (!confirmKill) {
setConfirmKill(true);
return;
}
setConfirmKill(false);
onKill();
}}
onBlur={() => setConfirmKill(false)}
title={
confirmKill
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
: t("runs.killLabel", "Kill")
}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
confirmKill
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
}`}
>
<Square className="w-3 h-3" />
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
</button>
)}
</span> </span>
</div> </div>
{row.promptPreview && ( {row.promptPreview && (
+3 -1
View File
@@ -77,7 +77,9 @@ interface PromptEditorProps {
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) { function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { // Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault(); e.preventDefault();
onSubmit(); onSubmit();
} }
@@ -6,6 +6,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { LaneConsolePane } from "../LaneConsolePane"; import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api"; import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types"; import type { Lane } from "../../../lib/types";
@@ -266,6 +267,64 @@ describe("LaneConsolePane", () => {
await waitFor(() => expect(prompt.value).toBe("add a button")); await waitFor(() => expect(prompt.value).toBe("add a button"));
}); });
it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => {
const HISTORY_ITEM = {
id: "run-old",
session_id: "sess-old",
cwd: "/workspace/a",
model: null,
permission_mode: null,
effort: null,
resume_session_id: null,
prompt_preview: "earlier prompt",
status: "abandoned" as const,
exit_code: null,
started_at: new Date(1000).toISOString(),
ended_at: new Date(2000).toISOString(),
isLive: false,
tool: "claude" as const,
};
(api.run.history as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
(api.lanes.action as ReturnType<typeof vi.fn>).mockRejectedValue(
// Matches the real shape `request()` throws: a plain, human-readable
// `.message` plus a `.code` a caller actually branches on.
Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 })
);
(api.lanes.list as ReturnType<typeof vi.fn>).mockResolvedValue({
lanes: [{ ...LANE, run_id: "run-live" }],
});
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "run-live",
laneId: 1,
status: "running",
cwd: "/workspace/a",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
});
render(
<MemoryRouter>
<LaneConsolePane {...baseProps()} />
</MemoryRouter>
);
fireEvent.click(await screen.findByRole("button", { name: /active runs/i }));
const resumeButtons = await screen.findAllByRole("button", {
name: "Resume existing session",
});
fireEvent.click(resumeButtons[0]!);
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live")
);
expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument();
});
it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => { it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => {
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />); render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
onAttach: vi.fn(), onAttach: vi.fn(),
onResumeFromHistory: vi.fn(), onResumeFromHistory: vi.fn(),
onViewFromHistory: vi.fn(), onViewFromHistory: vi.fn(),
onKill: vi.fn(),
onRefresh: vi.fn(), onRefresh: vi.fn(),
}; };
const utils = render( const utils = render(
@@ -127,6 +128,7 @@ function renderModal(
onAttach: vi.fn(), onAttach: vi.fn(),
onResume: vi.fn(), onResume: vi.fn(),
onView: vi.fn(), onView: vi.fn(),
onKill: vi.fn(),
onClose: vi.fn(), onClose: vi.fn(),
onRefresh: vi.fn(), onRefresh: vi.fn(),
}; };
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID); expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
}); });
it("needs two clicks to kill a live row, and never offers Kill for an external one", () => {
const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill"));
fireEvent.click(killButton);
expect(spies.onKill).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?")));
expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID);
unmount();
renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]);
expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull();
});
it("fires resume with the history item behind a finished row", () => { it("fires resume with the history item behind a finished row", () => {
const { spies } = renderModal([row(PAST_ID)]); const { spies } = renderModal([row(PAST_ID)]);
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
return ( return (
<div> <div>
<div <div
className="flex items-center gap-2 py-1.5 hover:bg-white/5 rounded transition-colors" className="flex items-center gap-2 py-1.5 hover:bg-surface-4 rounded transition-colors"
style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }} style={{ paddingLeft: `${indentPx + 8}px`, paddingRight: "8px" }}
> >
{/* Depth connector line */} {/* Depth connector line */}
@@ -252,7 +252,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{events.map((ev) => ( {events.map((ev) => (
<div <div
key={ev.id} key={ev.id}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors" className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
> >
{/* Tool pill */} {/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap"> <span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
@@ -310,7 +310,7 @@ function EventSequence({ events }: EventSequenceProps) {
{recent.map((ev) => ( {recent.map((ev) => (
<div <div
key={ev.id} key={ev.id}
className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-white/5 transition-colors group" className="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors group"
> >
{/* Event type badge */} {/* Event type badge */}
<span <span
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors" className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.closePanel")} aria-label={t("drillIn.closePanel")}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
key={s.id} key={s.id}
type="button" type="button"
onClick={() => handleSelect(s.id)} onClick={() => handleSelect(s.id)}
className="flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors border-b border-border/50 last:border-0" className="flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-4 transition-colors border-b border-border/50 last:border-0"
> >
<span <span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`} className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border ${sc}`}
@@ -654,7 +654,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button" type="button"
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={loading} disabled={loading}
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50" className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors border-t border-border/50 disabled:opacity-50"
> >
{loading ? t("drillIn.loading") : t("drillIn.loadMore")} {loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button> </button>
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors" className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors"
aria-label={t("drillIn.close")} aria-label={t("drillIn.close")}
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
+2 -1
View File
@@ -34,6 +34,7 @@
}, },
"errors": { "errors": {
"attachFailed": "Couldn't attach to run: {{message}}", "attachFailed": "Couldn't attach to run: {{message}}",
"attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.",
"cwdRequired": "Working directory required to start a run", "cwdRequired": "Working directory required to start a run",
"killFailed": "Couldn't stop: {{message}}", "killFailed": "Couldn't stop: {{message}}",
"laneCreateFailed": "Failed to create lane: {{message}}", "laneCreateFailed": "Failed to create lane: {{message}}",
@@ -97,7 +98,7 @@
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.", "headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.", "permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
"sandboxWarning": "Codex will run commands with no sandbox.", "sandboxWarning": "Codex will run commands with no sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter to send" "shortcut": "Enter to send · Shift+Enter for newline"
}, },
"limitations": { "limitations": {
"dismiss": "Got it" "dismiss": "Got it"
+2 -1
View File
@@ -33,6 +33,7 @@
}, },
"errors": { "errors": {
"attachFailed": "Không gắn được vào run: {{message}}", "attachFailed": "Không gắn được vào run: {{message}}",
"attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.",
"cwdRequired": "Cần thư mục làm việc để bắt đầu run", "cwdRequired": "Cần thư mục làm việc để bắt đầu run",
"killFailed": "Không dừng được: {{message}}", "killFailed": "Không dừng được: {{message}}",
"laneCreateFailed": "Không thể tạo làn đường: {{message}}", "laneCreateFailed": "Không thể tạo làn đường: {{message}}",
@@ -96,7 +97,7 @@
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.", "headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.", "permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.", "sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi" "shortcut": "Enter để gửi · Shift+Enter xuống dòng"
}, },
"limitations": { "limitations": {
"dismiss": "Đã rõ" "dismiss": "Đã rõ"
+89 -23
View File
@@ -15,7 +15,10 @@
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger) * 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
* 3-5 component background 9-10 solid (buttons, vibrant fills) * 3-5 component background 9-10 solid (buttons, vibrant fills)
* 11-12 text (11 secondary, 12 primary) * 11-12 text (11 secondary, 12 primary)
* `slate` supplies surface/border/fg here; `blue` supplies accent; `green`/ * The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for * `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are * fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to * the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
@@ -23,27 +26,70 @@
*/ */
:root { :root {
/* Light theme — the CSS default, so it applies with no class on <html>. /* Light theme — the CSS default, so it applies with no class on <html>.
Radix `slate` (light), steps 1/2/3/4/5/6/7. */ Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
--surface-0: 252 252 253; /* slate-1 #fcfcfd */ as it does in dark: the page is the DARKEST step and a card is brighter
--surface-1: 249 249 251; /* slate-2 #f9f9fb */ than the page it sits on. The previous light block used the scale in its
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */ native direction, which inverted the whole language — a near-white page
--surface-3: 232 232 236; /* slate-4 #e8e8ec */ (glare) under grey cards that read as recessed, and every `hover:bg-*`
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */ darkening instead of lifting.
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
--border: 217 217 224; /* slate-6 #d9d9e0 */ The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
--border-light: 205 206 214; /* slate-7 #cdced6 */ A cool near-white ramp kept reading as glare no matter how far the page
--accent: 0 144 255; /* blue-9 #0090ff */ step was pulled down, because the brightest thing on screen was also the
--accent-hover: 5 136 240; /* blue-10 #0588f0 */ bluest. Cream lowers the perceived glare at the same measured lightness
--fg-primary: 28 32 36; /* slate-12 #1c2024 */ and gives every neutral a warm cast to sit against. The whole ramp sits
--fg-secondary: 96 100 108; /* slate-11 #60646c */ one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */ white: at full brightness a large light panel still tires the eye. */
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08); --surface-0: 232 229 218; /* #e8e5da — page background */
/* Status: green/red/amber step 11 (light) — the same "readable text" step --surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
as fg-secondary, so success/danger/warning sit at the same visual weight --surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
as ordinary secondary text, not shouting over it. */ --surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--status-success: 33 131 88; /* green-11 #218358 */ --surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--status-danger: 206 44 49; /* red-11 #ce2c31 */ --surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--status-warning: 171 100 0; /* amber-11 #ab6400 */ --border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer. */
--accent: 163 74 40; /* #a34a28 */
--accent-hover: 142 63 32; /* #8e3f20 */
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
--tint-base: 245 243 236; /* = surface-3 */
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
--syn-plain: 31 30 27; /* = fg-primary */
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
} }
.dark { .dark {
@@ -70,6 +116,21 @@
--status-success: 61 214 140; /* green-11 dark #3dd68c */ --status-success: 61 214 140; /* green-11 dark #3dd68c */
--status-danger: 255 149 146; /* red-11 dark #ff9592 */ --status-danger: 255 149 146; /* red-11 dark #ff9592 */
--status-warning: 255 202 22; /* amber-11 dark #ffca16 */ --status-warning: 255 202 22; /* amber-11 dark #ffca16 */
--tint-base: 39 42 45; /* = surface-3 */
/* Unchanged from the pastels this file has always drawn code in. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 110 231 183; /* emerald-300 #6ee7b7 */
--syn-number: 253 186 116; /* orange-300 #fdba74 */
--syn-keyword: 196 181 253; /* violet-300 #c4b5fd */
--syn-builtin: 125 211 252; /* sky-300 #7dd3fc */
--syn-function: 254 240 138; /* yellow-200 #fef08a */
--syn-operator: 249 168 212; /* pink-300 #f9a8d4 */
--syn-punctuation: 156 163 175; /* gray-400 #9ca3af */
--syn-property: 103 232 249; /* cyan-300 #67e8f9 */
--syn-tag: 253 164 175; /* rose-300 #fda4af */
--syn-plain: 229 231 235; /* gray-200 #e5e7eb */
--syn-add: 110 231 183; /* emerald-300 */
--syn-del: 252 165 165; /* red-300 #fca5a5 */
} }
@layer base { @layer base {
@@ -102,12 +163,17 @@
} }
::-webkit-calendar-picker-indicator { ::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
/* The browser draws this glyph near-black. Inverting it is a DARK-mode fix;
applied globally it painted a white icon onto a white field. */
.dark ::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(0.8);
}
::-webkit-calendar-picker-indicator:hover { ::-webkit-calendar-picker-indicator:hover {
opacity: 1; opacity: 1;
} }
+22 -19
View File
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
/** /**
* Map a token type to a Tailwind colour class for rendering. * Map a token type to a Tailwind colour class for rendering.
* @param type A {@link TokenType} produced by {@link highlight}. * @param type A {@link TokenType} produced by {@link highlight}.
* @returns Tailwind utility classes (text colour, and a background tint for * @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown. * (`--syn-*` in index.css), not on fixed palette steps: the dark set is the
* pastel one this file has always used, and light mode takes the same hues
* four steps darker, because a pastel keyword on a light code block is
* unreadable. Diff rows keep their faint ±10% tint, which works on both.
*/ */
export function tokenClass(type: TokenType): string { export function tokenClass(type: TokenType): string {
switch (type) { switch (type) {
case "comment": case "comment":
return "text-gray-500 italic"; // muted + italic to recede visually return "text-syntax-comment italic"; // muted + italic to recede visually
case "string": case "string":
return "text-emerald-300"; // green, shared with diff-add text return "text-syntax-string"; // green, shared with diff-add text
case "number": case "number":
return "text-orange-300"; // orange, shared with boolean literals return "text-syntax-number"; // orange, shared with boolean literals
case "keyword": case "keyword":
return "text-violet-300"; // violet, shared with diff-meta headers return "text-syntax-keyword"; // violet, shared with diff-meta headers
case "builtin": case "builtin":
return "text-sky-300"; return "text-syntax-builtin";
case "function": case "function":
return "text-yellow-200"; // yellow, shared with HTML attr names return "text-syntax-function"; // yellow, shared with HTML attr names
case "operator": case "operator":
return "text-pink-300"; return "text-syntax-operator";
case "punctuation": case "punctuation":
return "text-gray-400"; // slightly brighter than comments, dimmer than plain return "text-syntax-punctuation"; // brighter than comments, dimmer than plain
case "property": case "property":
return "text-cyan-300"; return "text-syntax-property";
case "tag": case "tag":
return "text-rose-300"; return "text-syntax-tag";
case "attr": case "attr":
return "text-yellow-200"; // matches "function" colour by design return "text-syntax-function"; // matches "function" colour by design
case "variable": case "variable":
return "text-amber-300"; return "text-syntax-function"; // amber family, same as functions/attrs
case "boolean": case "boolean":
return "text-orange-300"; // literals grouped with numbers by colour return "text-syntax-number"; // literals grouped with numbers by colour
case "diff-add": case "diff-add":
return "text-emerald-300 bg-emerald-500/10"; // green text + faint green row tint return "text-syntax-add bg-emerald-500/10"; // green text + faint green row tint
case "diff-del": case "diff-del":
return "text-red-300 bg-red-500/10"; // red text + faint red row tint return "text-syntax-del bg-red-500/10"; // red text + faint red row tint
case "diff-meta": case "diff-meta":
return "text-violet-300"; return "text-syntax-keyword";
case "plain": case "plain":
default: default:
return "text-gray-200"; // default body text colour return "text-syntax-plain"; // default body text colour
} }
} }
@@ -5578,7 +5578,7 @@ exports[`screen snapshots > Run 1`] = `
<div <div
class="mt-1 text-[10px] text-fg-muted" class="mt-1 text-[10px] text-fg-muted"
> >
Cmd+Enter / Ctrl+Enter to send Enter to send · Shift+Enter for newline
· / for slash commands · @ for file references · / for slash commands · @ for file references
</div> </div>
</div> </div>
@@ -9617,7 +9617,7 @@ exports[`screen snapshots > Settings 1`] = `
<p <p
class="text-[10px] text-status-warning/90 mt-1" class="text-[10px] text-status-warning/90 mt-1"
> >
UI build v1.8.1 (rebuild client to match) UI build v1.8.2 (rebuild client to match)
</p> </p>
</div> </div>
<div <div
+54
View File
@@ -8,6 +8,43 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
/**
* A status/accent FILL is pre-mixed against one fixed base, never translucent.
*
* `bg-status-success/10` used to emit `rgb(var(--status-success) / 0.1)`, which
* composites with whatever is behind it — so the one "success" chip rendered as
* five different colours over the five surface steps, and washed out badly on
* the light theme's near-white cards. Mixing against `--tint-base` (the card
* surface of the active theme) gives an OPAQUE colour: the same chip reads the
* same on the page, in a card, in a modal and inside an input row.
*
* Applied to `backgroundColor`/`borderColor` only. `text-status-warning/80` and
* `ring-accent/40` stay genuinely translucent — text and rings sit on top of a
* fill rather than replacing it — and the surface family keeps its alpha too,
* or every modal scrim would turn solid.
*/
const tint =
(variable) =>
({ opacityValue }) => {
const alpha = Number(opacityValue);
// No modifier at all: Tailwind passes `var(--tw-bg-opacity, 1)` here.
if (!Number.isFinite(alpha) || alpha >= 1) return `rgb(var(${variable}))`;
return `color-mix(in srgb, rgb(var(${variable})) ${alpha * 100}%, rgb(var(--tint-base)))`;
};
const tinted = {
accent: {
DEFAULT: tint("--accent"),
hover: tint("--accent-hover"),
muted: "color-mix(in srgb, rgb(var(--accent)) 15%, rgb(var(--tint-base)))",
},
status: {
success: tint("--status-success"),
danger: tint("--status-danger"),
warning: tint("--status-warning"),
},
};
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: "class", darkMode: "class",
@@ -37,12 +74,29 @@ export default {
secondary: "rgb(var(--fg-secondary) / <alpha-value>)", secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)", muted: "rgb(var(--fg-muted) / <alpha-value>)",
}, },
syntax: {
comment: "rgb(var(--syn-comment) / <alpha-value>)",
string: "rgb(var(--syn-string) / <alpha-value>)",
number: "rgb(var(--syn-number) / <alpha-value>)",
keyword: "rgb(var(--syn-keyword) / <alpha-value>)",
builtin: "rgb(var(--syn-builtin) / <alpha-value>)",
function: "rgb(var(--syn-function) / <alpha-value>)",
operator: "rgb(var(--syn-operator) / <alpha-value>)",
punctuation: "rgb(var(--syn-punctuation) / <alpha-value>)",
property: "rgb(var(--syn-property) / <alpha-value>)",
tag: "rgb(var(--syn-tag) / <alpha-value>)",
plain: "rgb(var(--syn-plain) / <alpha-value>)",
add: "rgb(var(--syn-add) / <alpha-value>)",
del: "rgb(var(--syn-del) / <alpha-value>)",
},
status: { status: {
success: "rgb(var(--status-success) / <alpha-value>)", success: "rgb(var(--status-success) / <alpha-value>)",
danger: "rgb(var(--status-danger) / <alpha-value>)", danger: "rgb(var(--status-danger) / <alpha-value>)",
warning: "rgb(var(--status-warning) / <alpha-value>)", warning: "rgb(var(--status-warning) / <alpha-value>)",
}, },
}, },
backgroundColor: tinted,
borderColor: tinted,
fontFamily: { fontFamily: {
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"], sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"], mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
+4
View File
@@ -444,8 +444,12 @@ An external session is deduped against a dashboard run with the same `session_id
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one. External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one.
Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a **Kill** button, `DELETE /api/run/:id` behind a two-click confirm. This is how you free a lane stuck on a previous live session when you actually want it gone, rather than attached to.
**Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all. **Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `agenthub-lane-<id>` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all.
**A lane whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The console does not surface that raw error: `LaneConsolePane` catches it, re-reads the lane, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → **Kill**, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the lane for a fresh Start or a different Resume; the kill goes straight through `DELETE /api/run/:id`, and the lane's own `run_id`/`status` self-heal to idle the next time anything reads the lane (`healRunId` in `routes/lanes.js`), so no separate lane-side call is needed.
The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`. The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`.
### Finding or adopting a lane: `POST /api/lanes/ensure` ### Finding or adopting a lane: `POST /api/lanes/ensure`
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 1.8.1 version: 1.8.2
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "1.8.1", "version": "1.8.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "1.8.1", "version": "1.8.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "1.8.1", "version": "1.8.2",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },