Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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ĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user