Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 |
@@ -470,6 +470,7 @@ graph TD
|
||||
### 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.
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
|
||||
**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
|
||||
- **WebSocket** - Real-time event streaming from server
|
||||
- **Lucide Icons** - Modern, consistent icon set
|
||||
|
||||
@@ -174,7 +174,7 @@ export function DateTimePicker({
|
||||
? "bg-accent text-white font-medium"
|
||||
: isToday
|
||||
? "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}
|
||||
@@ -201,7 +201,10 @@ export function DateTimePicker({
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
</span>
|
||||
{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>
|
||||
|
||||
@@ -216,7 +219,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
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" />
|
||||
</button>
|
||||
@@ -228,7 +231,7 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -246,12 +255,16 @@ export function LaneConsolePane({
|
||||
resumeLast: args.resumeLast,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
const msg = laneErr instanceof Error ? laneErr.message : String(laneErr);
|
||||
if (msg.includes("409") || msg.includes("ERUNLIVE")) {
|
||||
// A code-only check: the server's message is human prose ("lane
|
||||
// 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 updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -307,13 +320,34 @@ export function LaneConsolePane({
|
||||
onLaneCreated(ensureResult.lane);
|
||||
}
|
||||
|
||||
const laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
effort: item.effort || undefined,
|
||||
resumeSessionId: item.session_id,
|
||||
});
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
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) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
@@ -343,7 +377,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange]
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -360,6 +394,28 @@ export function LaneConsolePane({
|
||||
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) {
|
||||
return (
|
||||
<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}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
onRefresh={refreshList}
|
||||
/>
|
||||
</header>
|
||||
|
||||
@@ -10,18 +10,23 @@
|
||||
* - `RunsModal` — the merged list itself: live in-memory handles from
|
||||
* `activeRuns` deduped against persistent `runHistory`, newest first, with
|
||||
* 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 —
|
||||
* 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
|
||||
* 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`,
|
||||
* `runHistory` and `externalSessions` in and gets attach / resume / view /
|
||||
* refresh back out through callbacks; the 2 s refresh ticker the modal runs
|
||||
* just calls `onRefresh`.
|
||||
* kill / refresh back out through callbacks; the 2 s refresh ticker the modal
|
||||
* 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>
|
||||
*/
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
@@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions = [],
|
||||
onResumeFromHistory,
|
||||
onViewFromHistory,
|
||||
onKill,
|
||||
onRefresh,
|
||||
}: {
|
||||
activeRuns: RunListResponse | null;
|
||||
@@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({
|
||||
externalSessions?: Session[];
|
||||
onResumeFromHistory: (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;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
@@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({
|
||||
setOpen(false);
|
||||
onViewFromHistory(item);
|
||||
}}
|
||||
onKill={onKill}
|
||||
runHistory={historyItems}
|
||||
onClose={() => setOpen(false)}
|
||||
onRefresh={onRefresh}
|
||||
@@ -273,6 +285,7 @@ export function RunsModal({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
runHistory,
|
||||
onClose,
|
||||
onRefresh,
|
||||
@@ -282,6 +295,7 @@ export function RunsModal({
|
||||
onAttach: (id: string) => void;
|
||||
onResume: (item: DashboardRunHistoryItem) => void;
|
||||
onView: (item: DashboardRunHistoryItem) => void;
|
||||
onKill: (id: string) => void;
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
@@ -446,6 +460,7 @@ export function RunsModal({
|
||||
const h = historyById.get(r.id);
|
||||
if (h) onView(h);
|
||||
}}
|
||||
onKill={() => onKill(r.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -512,14 +527,20 @@ function UnifiedRunRowView({
|
||||
onAttach,
|
||||
onResume,
|
||||
onView,
|
||||
onKill,
|
||||
}: {
|
||||
row: UnifiedRunRow;
|
||||
isCurrent: boolean;
|
||||
onAttach: () => void;
|
||||
onResume: () => void;
|
||||
onView: () => void;
|
||||
onKill: () => void;
|
||||
}) {
|
||||
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);
|
||||
// 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.
|
||||
@@ -593,6 +614,32 @@ function UnifiedRunRowView({
|
||||
{t("runs.viewLabel", "View")}
|
||||
</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>
|
||||
</div>
|
||||
{row.promptPreview && (
|
||||
|
||||
@@ -77,7 +77,9 @@ interface PromptEditorProps {
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
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();
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
@@ -266,6 +267,64 @@ describe("LaneConsolePane", () => {
|
||||
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)", () => {
|
||||
render(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
|
||||
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
|
||||
|
||||
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
|
||||
onAttach: vi.fn(),
|
||||
onResumeFromHistory: vi.fn(),
|
||||
onViewFromHistory: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
const utils = render(
|
||||
@@ -127,6 +128,7 @@ function renderModal(
|
||||
onAttach: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onView: vi.fn(),
|
||||
onKill: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
|
||||
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", () => {
|
||||
const { spies } = renderModal([row(PAST_ID)]);
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
|
||||
@@ -171,7 +171,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
return (
|
||||
<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" }}
|
||||
>
|
||||
{/* Depth connector line */}
|
||||
@@ -252,7 +252,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
{events.map((ev) => (
|
||||
<div
|
||||
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 */}
|
||||
<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) => (
|
||||
<div
|
||||
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 */}
|
||||
<span
|
||||
@@ -454,7 +454,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
<button
|
||||
type="button"
|
||||
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")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -615,7 +615,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
key={s.id}
|
||||
type="button"
|
||||
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
|
||||
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"
|
||||
onClick={handleLoadMore}
|
||||
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")}
|
||||
</button>
|
||||
@@ -739,7 +739,7 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
|
||||
<button
|
||||
type="button"
|
||||
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")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"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",
|
||||
"killFailed": "Couldn't stop: {{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.",
|
||||
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
|
||||
"sandboxWarning": "Codex will run commands with no sandbox.",
|
||||
"shortcut": "Cmd+Enter / Ctrl+Enter to send"
|
||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
||||
},
|
||||
"limitations": {
|
||||
"dismiss": "Got it"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"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",
|
||||
"killFailed": "Không dừng được: {{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.",
|
||||
"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.",
|
||||
"shortcut": "Cmd+Enter / Ctrl+Enter để gửi"
|
||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
||||
},
|
||||
"limitations": {
|
||||
"dismiss": "Đã rõ"
|
||||
|
||||
+89
-23
@@ -15,7 +15,10 @@
|
||||
* 1-2 app/chrome background 6-8 borders (6 subtle, 7-8 stronger)
|
||||
* 3-5 component background 9-10 solid (buttons, vibrant fills)
|
||||
* 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
|
||||
* 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
|
||||
@@ -23,27 +26,70 @@
|
||||
*/
|
||||
:root {
|
||||
/* Light theme — the CSS default, so it applies with no class on <html>.
|
||||
Radix `slate` (light), steps 1/2/3/4/5/6/7. */
|
||||
--surface-0: 252 252 253; /* slate-1 #fcfcfd */
|
||||
--surface-1: 249 249 251; /* slate-2 #f9f9fb */
|
||||
--surface-2: 240 240 243; /* slate-3 #f0f0f3 */
|
||||
--surface-3: 232 232 236; /* slate-4 #e8e8ec */
|
||||
--surface-4: 224 225 230; /* slate-5 #e0e1e6 */
|
||||
--surface-5: 217 217 224; /* slate-6 #d9d9e0 */
|
||||
--border: 217 217 224; /* slate-6 #d9d9e0 */
|
||||
--border-light: 205 206 214; /* slate-7 #cdced6 */
|
||||
--accent: 0 144 255; /* blue-9 #0090ff */
|
||||
--accent-hover: 5 136 240; /* blue-10 #0588f0 */
|
||||
--fg-primary: 28 32 36; /* slate-12 #1c2024 */
|
||||
--fg-secondary: 96 100 108; /* slate-11 #60646c */
|
||||
--fg-muted: 139 141 152; /* slate-9 #8b8d98 */
|
||||
--shadow-card: 0 1px 2px rgb(15 23 42 / 0.06), 0 1px 3px rgb(15 23 42 / 0.08);
|
||||
/* Status: green/red/amber step 11 (light) — the same "readable text" step
|
||||
as fg-secondary, so success/danger/warning sit at the same visual weight
|
||||
as ordinary secondary text, not shouting over it. */
|
||||
--status-success: 33 131 88; /* green-11 #218358 */
|
||||
--status-danger: 206 44 49; /* red-11 #ce2c31 */
|
||||
--status-warning: 171 100 0; /* amber-11 #ab6400 */
|
||||
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
|
||||
as it does in dark: the page is the DARKEST step and a card is brighter
|
||||
than the page it sits on. The previous light block used the scale in its
|
||||
native direction, which inverted the whole language — a near-white page
|
||||
(glare) under grey cards that read as recessed, and every `hover:bg-*`
|
||||
darkening instead of lifting.
|
||||
|
||||
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
|
||||
A cool near-white ramp kept reading as glare no matter how far the page
|
||||
step was pulled down, because the brightest thing on screen was also the
|
||||
bluest. Cream lowers the perceived glare at the same measured lightness
|
||||
and gives every neutral a warm cast to sit against. The whole ramp sits
|
||||
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
|
||||
white: at full brightness a large light panel still tires the eye. */
|
||||
--surface-0: 232 229 218; /* #e8e5da — page background */
|
||||
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
|
||||
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
|
||||
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
|
||||
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
|
||||
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
|
||||
--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 {
|
||||
@@ -70,6 +116,21 @@
|
||||
--status-success: 61 214 140; /* green-11 dark #3dd68c */
|
||||
--status-danger: 255 149 146; /* red-11 dark #ff9592 */
|
||||
--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 {
|
||||
@@ -102,12 +163,17 @@
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(0.8);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+22
-19
@@ -1033,46 +1033,49 @@ export function highlight(source: string, lang: string): Token[] {
|
||||
/**
|
||||
* Map a token type to a Tailwind colour class for rendering.
|
||||
* @param type A {@link TokenType} produced by {@link highlight}.
|
||||
* @returns Tailwind utility classes (text colour, and a background tint for
|
||||
* diff add/remove lines); falls back to a neutral gray for "plain"/unknown.
|
||||
* @returns Tailwind utility classes built on the `syntax.*` THEME TOKENS
|
||||
* (`--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 {
|
||||
switch (type) {
|
||||
case "comment":
|
||||
return "text-gray-500 italic"; // muted + italic to recede visually
|
||||
return "text-syntax-comment italic"; // muted + italic to recede visually
|
||||
case "string":
|
||||
return "text-emerald-300"; // green, shared with diff-add text
|
||||
return "text-syntax-string"; // green, shared with diff-add text
|
||||
case "number":
|
||||
return "text-orange-300"; // orange, shared with boolean literals
|
||||
return "text-syntax-number"; // orange, shared with boolean literals
|
||||
case "keyword":
|
||||
return "text-violet-300"; // violet, shared with diff-meta headers
|
||||
return "text-syntax-keyword"; // violet, shared with diff-meta headers
|
||||
case "builtin":
|
||||
return "text-sky-300";
|
||||
return "text-syntax-builtin";
|
||||
case "function":
|
||||
return "text-yellow-200"; // yellow, shared with HTML attr names
|
||||
return "text-syntax-function"; // yellow, shared with HTML attr names
|
||||
case "operator":
|
||||
return "text-pink-300";
|
||||
return "text-syntax-operator";
|
||||
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":
|
||||
return "text-cyan-300";
|
||||
return "text-syntax-property";
|
||||
case "tag":
|
||||
return "text-rose-300";
|
||||
return "text-syntax-tag";
|
||||
case "attr":
|
||||
return "text-yellow-200"; // matches "function" colour by design
|
||||
return "text-syntax-function"; // matches "function" colour by design
|
||||
case "variable":
|
||||
return "text-amber-300";
|
||||
return "text-syntax-function"; // amber family, same as functions/attrs
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
return "text-violet-300";
|
||||
return "text-syntax-keyword";
|
||||
case "plain":
|
||||
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
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
@@ -9617,7 +9617,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
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>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,43 @@
|
||||
* @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} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
@@ -37,12 +74,29 @@ export default {
|
||||
secondary: "rgb(var(--fg-secondary) / <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: {
|
||||
success: "rgb(var(--status-success) / <alpha-value>)",
|
||||
danger: "rgb(var(--status-danger) / <alpha-value>)",
|
||||
warning: "rgb(var(--status-warning) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
backgroundColor: tinted,
|
||||
borderColor: tinted,
|
||||
fontFamily: {
|
||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
**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`.
|
||||
|
||||
### Finding or adopting a lane: `POST /api/lanes/ensure`
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
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.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user