Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 |
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file One lane's card: progress bar, branch/CI/PR facts, the "needs you"
|
* @file One lane's card: branch/CI/PR facts, the "needs you"
|
||||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||||
* it should have been working) is called out loudly — that is the failure
|
* it should have been working) is called out loudly — that is the failure
|
||||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||||
* NOT repeated here — this card only ever renders inside the Workspace
|
* NOT repeated here — this card only ever renders inside the Workspace
|
||||||
* lane-detail section, whose header above it already shows them. The repo
|
* lane-detail section, whose header above it already shows them. Neither is
|
||||||
|
* progress: the pipeline map directly below this card draws the same number
|
||||||
|
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
||||||
|
* second telling of it. The repo
|
||||||
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
* card also carries the lane's AI Router picker — the Add-lane modal is the
|
||||||
* only other place one is chosen, and a lane outlives its creation. The repo
|
* only other place one is chosen, and a lane outlives its creation. The repo
|
||||||
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
|
||||||
@@ -427,23 +430,6 @@ export default function LaneCard({
|
|||||||
data-testid={`lane-card-${lane.id}`}
|
data-testid={`lane-card-${lane.id}`}
|
||||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||||
>
|
>
|
||||||
{/* Progress line: how far through the pipeline. The stage's own name
|
|
||||||
and how long it's been running are already in the Workspace
|
|
||||||
header above; not repeated here. Omitted at 0% — an empty bar
|
|
||||||
is not information, just a blank row above a dead/idle lane. */}
|
|
||||||
{lane.progress > 0 && (
|
|
||||||
<div className="mb-1.5 flex items-center gap-2 text-xs">
|
|
||||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-surface-2">
|
|
||||||
<div
|
|
||||||
data-testid="lane-progress-fill"
|
|
||||||
className="h-full rounded-full bg-blue-600 transition-[width]"
|
|
||||||
style={{ width: `${lane.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(lane.ci_status || lane.tool === "codex") && (
|
{(lane.ci_status || lane.tool === "codex") && (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||||
{lane.ci_status && (
|
{lane.ci_status && (
|
||||||
@@ -553,7 +539,20 @@ export default function LaneCard({
|
|||||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||||
{(git.dirty > 0 || git.untracked > 0) && (
|
{(git.dirty > 0 || git.untracked > 0) && (
|
||||||
<span className="shrink-0 text-status-warning/80">
|
<span
|
||||||
|
className="shrink-0 cursor-help text-status-warning/80"
|
||||||
|
/* Native title: the counts' own file list, capped server-side.
|
||||||
|
A hover card would need a portal to escape this truncating
|
||||||
|
row — the browser's tooltip already escapes it for free. */
|
||||||
|
title={
|
||||||
|
git.files?.length
|
||||||
|
? git.files.join("\n") +
|
||||||
|
(git.dirty + git.untracked > git.files.length
|
||||||
|
? `\n… +${git.dirty + git.untracked - git.files.length}`
|
||||||
|
: "")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -155,14 +155,12 @@ describe("LaneCard rebuilt layout", () => {
|
|||||||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the progress percentage", () => {
|
// The pipeline map below this card draws the same progress as named nodes,
|
||||||
|
// so the card no longer repeats it as a bar and a percent.
|
||||||
|
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||||||
expect(screen.getByText("48%")).toBeInTheDocument();
|
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||||||
});
|
expect(screen.queryByText("48%")).toBeNull();
|
||||||
|
|
||||||
it("gives the progress bar a width matching the lane's progress", () => {
|
|
||||||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces a needs-you message", () => {
|
it("surfaces a needs-you message", () => {
|
||||||
@@ -209,6 +207,7 @@ describe("LaneCard git block", () => {
|
|||||||
subject: "free-text rule mode in the form",
|
subject: "free-text rule mode in the form",
|
||||||
dirty: 2,
|
dirty: 2,
|
||||||
untracked: 1,
|
untracked: 1,
|
||||||
|
files: [" M server/lib/alerts.js", " M client/src/lib/api.ts", "?? scratch.txt"],
|
||||||
};
|
};
|
||||||
|
|
||||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||||
@@ -220,6 +219,10 @@ describe("LaneCard git block", () => {
|
|||||||
expect(block.textContent).toContain("9b3e74a");
|
expect(block.textContent).toContain("9b3e74a");
|
||||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||||
expect(block.textContent).toContain("2");
|
expect(block.textContent).toContain("2");
|
||||||
|
// Hovering the counts names the files behind them.
|
||||||
|
expect(screen.getByText(/modified/).title).toBe(
|
||||||
|
" M server/lib/alerts.js\n M client/src/lib/api.ts\n?? scratch.txt"
|
||||||
|
);
|
||||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -426,13 +426,17 @@ export function LaneConsolePane({
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<header className="flex items-start gap-3">
|
{/* Title, live badge and blurb on ONE line: the blurb is a standing
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0">
|
description of the panel, not a message, so it does not earn the
|
||||||
<Play className="w-4.5 h-4.5 text-accent" />
|
second row it used to wrap onto. It drops out entirely below `sm`,
|
||||||
|
where the width would wrap it again. */}
|
||||||
|
<header className="flex items-center gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Play className="w-3.5 h-3.5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<h1 className="text-lg font-semibold text-fg-primary">
|
<h1 className="text-base font-semibold text-fg-primary">
|
||||||
{t("title", { tool: toolLabel })}
|
{t("title", { tool: toolLabel })}
|
||||||
</h1>
|
</h1>
|
||||||
{wsConnected ? (
|
{wsConnected ? (
|
||||||
@@ -447,7 +451,9 @@ export function LaneConsolePane({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle", { tool: toolLabel })}</p>
|
<p className="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block">
|
||||||
|
{t("subtitle", { tool: toolLabel })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ActiveRunsSwitcher
|
<ActiveRunsSwitcher
|
||||||
activeRuns={activeRuns}
|
activeRuns={activeRuns}
|
||||||
|
|||||||
@@ -147,9 +147,83 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface-1">
|
<div className="rounded-xl border border-border bg-surface-1">
|
||||||
{/* Fresh vs resume source picker */}
|
{/* Run settings and the fresh-vs-resume source picker share one row:
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]">
|
settings left, source right. They used to be two rows with a divider
|
||||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
between them, which spent a band of height on four labelled selects
|
||||||
|
that are each one line tall. The resume picker below is the only part
|
||||||
|
that still gets a row of its own, and only while resume is chosen. */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3">
|
||||||
|
<div className="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label={t("fields.cwd")}>
|
||||||
|
{isResume && props.resumeSession ? (
|
||||||
|
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
||||||
|
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||||
|
<span className="truncate">{props.resumeSession.cwd}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div title={t("fields.cwdHint")}>
|
||||||
|
<CwdAutocomplete
|
||||||
|
value={props.cwd}
|
||||||
|
onChange={props.onCwdChange}
|
||||||
|
suggestions={props.cwdSuggestions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.model")}>
|
||||||
|
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||||
|
</Field>
|
||||||
|
{props.tool === "codex" ? (
|
||||||
|
<>
|
||||||
|
<Field label={t("fields.sandbox")}>
|
||||||
|
<Select<CodexSandbox>
|
||||||
|
value={props.sandbox}
|
||||||
|
onChange={props.onSandboxChange}
|
||||||
|
options={[
|
||||||
|
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
||||||
|
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
||||||
|
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.approval")}>
|
||||||
|
<Select<CodexApproval>
|
||||||
|
value={props.approval}
|
||||||
|
onChange={props.onApprovalChange}
|
||||||
|
options={[
|
||||||
|
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
||||||
|
{ value: "never", label: t("fields.approvalNever") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Field label={t("fields.permissionMode")}>
|
||||||
|
<Select<PermissionMode>
|
||||||
|
value={props.permissionMode}
|
||||||
|
onChange={props.onPermissionModeChange}
|
||||||
|
options={[
|
||||||
|
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
||||||
|
{ value: "default", label: t("fields.permissionDefault") },
|
||||||
|
{ value: "plan", label: t("fields.permissionPlan") },
|
||||||
|
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("fields.effort")}>
|
||||||
|
<Select<EffortLevel>
|
||||||
|
value={props.effort}
|
||||||
|
onChange={props.onEffortChange}
|
||||||
|
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.label,
|
||||||
|
hint: c.hint,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||||
<Seg
|
<Seg
|
||||||
active={!resumePicked}
|
active={!resumePicked}
|
||||||
label={t("resume.freshOption")}
|
label={t("resume.freshOption")}
|
||||||
@@ -166,8 +240,11 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
onClick={() => setResumePicked(true)}
|
onClick={() => setResumePicked(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{resumePicked && (
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
|
{resumePicked && (
|
||||||
|
<div className="border-b border-border px-4 py-2 text-[11.5px]">
|
||||||
|
<div className="min-w-0">
|
||||||
{props.tool === "claude" ? (
|
{props.tool === "claude" ? (
|
||||||
<SessionPicker
|
<SessionPicker
|
||||||
selected={props.resumeSession}
|
selected={props.resumeSession}
|
||||||
@@ -192,8 +269,8 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* Prompt */}
|
||||||
<div className="px-4 py-3 border-b border-border">
|
<div className="px-4 py-3 border-b border-border">
|
||||||
@@ -214,78 +291,6 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced fields */}
|
|
||||||
<div className="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Field label={t("fields.cwd")}>
|
|
||||||
{isResume && props.resumeSession ? (
|
|
||||||
<div className="bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] font-mono text-fg-secondary flex items-center gap-2">
|
|
||||||
<Lock className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
|
||||||
<span className="truncate">{props.resumeSession.cwd}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div title={t("fields.cwdHint")}>
|
|
||||||
<CwdAutocomplete
|
|
||||||
value={props.cwd}
|
|
||||||
onChange={props.onCwdChange}
|
|
||||||
suggestions={props.cwdSuggestions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label={t("fields.model")}>
|
|
||||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
|
||||||
</Field>
|
|
||||||
{props.tool === "codex" ? (
|
|
||||||
<>
|
|
||||||
<Field label={t("fields.sandbox")}>
|
|
||||||
<Select<CodexSandbox>
|
|
||||||
value={props.sandbox}
|
|
||||||
onChange={props.onSandboxChange}
|
|
||||||
options={[
|
|
||||||
{ value: "read-only", label: t("fields.sandboxReadOnly") },
|
|
||||||
{ value: "workspace-write", label: t("fields.sandboxWorkspaceWrite") },
|
|
||||||
{ value: "danger-full-access", label: t("fields.sandboxFullAccess") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={t("fields.approval")}>
|
|
||||||
<Select<CodexApproval>
|
|
||||||
value={props.approval}
|
|
||||||
onChange={props.onApprovalChange}
|
|
||||||
options={[
|
|
||||||
{ value: "on-request", label: t("fields.approvalOnRequest") },
|
|
||||||
{ value: "never", label: t("fields.approvalNever") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Field label={t("fields.permissionMode")}>
|
|
||||||
<Select<PermissionMode>
|
|
||||||
value={props.permissionMode}
|
|
||||||
onChange={props.onPermissionModeChange}
|
|
||||||
options={[
|
|
||||||
{ value: "acceptEdits", label: t("fields.permissionAcceptEdits") },
|
|
||||||
{ value: "default", label: t("fields.permissionDefault") },
|
|
||||||
{ value: "plan", label: t("fields.permissionPlan") },
|
|
||||||
{ value: "bypassPermissions", label: t("fields.permissionBypass") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
<Field label={t("fields.effort")}>
|
|
||||||
<Select<EffortLevel>
|
|
||||||
value={props.effort}
|
|
||||||
onChange={props.onEffortChange}
|
|
||||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
|
||||||
value: c.id,
|
|
||||||
label: c.label,
|
|
||||||
hint: c.hint,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
"permissionMode": "Mode",
|
"permissionMode": "Mode",
|
||||||
"session": "Session"
|
"session": "Session"
|
||||||
},
|
},
|
||||||
"subtitle": "Spin up a {{tool}} session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.",
|
"subtitle": "Streamed output, multi-turn, same hooks and analytics as your terminal.",
|
||||||
"title": "Run {{tool}}",
|
"title": "Run {{tool}}",
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"cacheRead": "Cache hit",
|
"cacheRead": "Cache hit",
|
||||||
|
|||||||
@@ -152,7 +152,7 @@
|
|||||||
"permissionMode": "Mode",
|
"permissionMode": "Mode",
|
||||||
"session": "Session"
|
"session": "Session"
|
||||||
},
|
},
|
||||||
"subtitle": "Khởi chạy một phiên {{tool}} ngay trong dashboard. Stream output trực tiếp, hội thoại đa lượt, và chia sẻ cùng pipeline hooks/analytics với phiên ở terminal.",
|
"subtitle": "Stream trực tiếp, hội thoại đa lượt, cùng hooks và analytics như ở terminal.",
|
||||||
"title": "Chạy {{tool}}",
|
"title": "Chạy {{tool}}",
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"cacheRead": "Cache hit",
|
"cacheRead": "Cache hit",
|
||||||
|
|||||||
@@ -2592,6 +2592,8 @@ export type LaneGitFacts =
|
|||||||
dirty: number;
|
dirty: number;
|
||||||
/** Files git has never seen. */
|
/** Files git has never seen. */
|
||||||
untracked: number;
|
untracked: number;
|
||||||
|
/** Raw `git status --porcelain` lines, capped; the counts stay exact. */
|
||||||
|
files: string[];
|
||||||
}
|
}
|
||||||
| { available: false };
|
| { available: false };
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* @file Workspace.tsx
|
* @file Workspace.tsx
|
||||||
* @description Merged workspace page combining lanes (agent work units) and runs
|
* @description Merged workspace page combining lanes (agent work units) and runs
|
||||||
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
* (CLI spawns: Claude Code or Codex, determined per lane). Displays a horizontal
|
||||||
* lane strip at the top with counters, the selected lane's pipeline map, and run
|
* lane strip at the top (its first tile creates a lane), the selected lane's
|
||||||
* configuration/console/history below. Runs are tied to lanes: starting a run
|
* pipeline map, and run configuration/console/history below. Runs are tied to lanes: starting a run
|
||||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||||
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
* isn't owned by any lane, calls POST /api/lanes/ensure first. Polls GET
|
||||||
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
* /api/sessions?status=active alongside the run list so Claude Code sessions
|
||||||
@@ -42,18 +42,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Square, Columns2, Grid2x2 } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||||
import type {
|
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||||
Lane,
|
|
||||||
LaneFeature,
|
|
||||||
LaneCounts,
|
|
||||||
ProofFeature,
|
|
||||||
Session,
|
|
||||||
WSMessage,
|
|
||||||
LaneTool,
|
|
||||||
} from "../lib/types";
|
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
@@ -72,6 +64,14 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
|||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One pane, two side by side, four in a grid — the conventional icons for
|
||||||
|
* each, so the toggle reads at a glance without spending three words on it. */
|
||||||
|
const LAYOUT_CHOICES = [
|
||||||
|
{ n: 1 as const, Icon: Square },
|
||||||
|
{ n: 2 as const, Icon: Columns2 },
|
||||||
|
{ n: 4 as const, Icon: Grid2x2 },
|
||||||
|
];
|
||||||
|
|
||||||
function SplitLayoutToggle({
|
function SplitLayoutToggle({
|
||||||
splitView,
|
splitView,
|
||||||
setLayout,
|
setLayout,
|
||||||
@@ -83,21 +83,29 @@ function SplitLayoutToggle({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{([1, 2, 4] as const).map((n) => (
|
{LAYOUT_CHOICES.map(({ n, Icon }) => {
|
||||||
<button
|
// The count still reaches assistive tech and the tooltip — only the
|
||||||
key={n}
|
// rendered word is gone, and the icon says the same thing in the
|
||||||
type="button"
|
// shape every other app uses for it.
|
||||||
aria-pressed={splitView.layout === n}
|
const label = tLanes("splitView.paneCount", { count: n });
|
||||||
onClick={() => setLayout(n)}
|
return (
|
||||||
className={`rounded border px-2 py-1 text-xs ${
|
<button
|
||||||
splitView.layout === n
|
key={n}
|
||||||
? "border-accent bg-accent/15 text-accent"
|
type="button"
|
||||||
: "border-border text-fg-secondary hover:border-border-light"
|
aria-pressed={splitView.layout === n}
|
||||||
}`}
|
aria-label={label}
|
||||||
>
|
title={label}
|
||||||
{tLanes("splitView.paneCount", { count: n })}
|
onClick={() => setLayout(n)}
|
||||||
</button>
|
className={`rounded border p-1.5 ${
|
||||||
))}
|
splitView.layout === n
|
||||||
|
? "border-accent bg-accent/15 text-accent"
|
||||||
|
: "border-border text-fg-secondary hover:border-border-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -187,7 +195,6 @@ export function Workspace() {
|
|||||||
|
|
||||||
// Lane state
|
// Lane state
|
||||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||||
const [counts, setCounts] = useState<LaneCounts>({ total: 0, running: 0, needs_you: 0, dead: 0 });
|
|
||||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||||
@@ -247,7 +254,6 @@ export function Workspace() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.lanes.list();
|
const r = await api.lanes.list();
|
||||||
setLanes(keepIfSame(r.lanes));
|
setLanes(keepIfSame(r.lanes));
|
||||||
setCounts(keepIfSame(r.counts));
|
|
||||||
setSelectedLaneId((cur) =>
|
setSelectedLaneId((cur) =>
|
||||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||||
);
|
);
|
||||||
@@ -499,60 +505,25 @@ export function Workspace() {
|
|||||||
: "space-y-5"
|
: "space-y-5"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Page header: what this screen is, and the four numbers that say
|
|
||||||
whether anything needs a human right now. */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
|
|
||||||
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
|
|
||||||
{tLanes("title")}
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
||||||
<span
|
|
||||||
data-testid="count-total"
|
|
||||||
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
|
||||||
>
|
|
||||||
{counts.total} {tLanes("countTotal")}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
data-testid="count-running"
|
|
||||||
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
|
||||||
>
|
|
||||||
{counts.running} {tLanes("countRunning")}
|
|
||||||
</span>
|
|
||||||
{counts.needs_you > 0 && (
|
|
||||||
<span
|
|
||||||
data-testid="count-needs-you"
|
|
||||||
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
|
|
||||||
>
|
|
||||||
{counts.needs_you} {tLanes("countNeedsYou")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{counts.dead > 0 && (
|
|
||||||
<span
|
|
||||||
data-testid="count-dead"
|
|
||||||
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
|
|
||||||
>
|
|
||||||
{counts.dead} {tLanes("countDead")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
{currentLane && <LaneToolsMenu lane={currentLane} />}
|
|
||||||
<button
|
|
||||||
onClick={() => setAddLaneOpen(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
|
||||||
title={tLanes("addLane")}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{tLanes("add")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
{/* Lane strip + detail stack vertically: the strip is a horizontally
|
||||||
scrolling row (holds any number of lanes), the detail/console area
|
scrolling row (holds any number of lanes), the detail/console area
|
||||||
below it gets the full width and never competes for it. */}
|
below it gets the full width and never competes for it. */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
<div data-testid="lane-strip" className="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1">
|
||||||
|
{/* "Add lane" is a tile in the strip rather than a header button:
|
||||||
|
creating a lane is the same kind of act as picking one, so it
|
||||||
|
reads as the empty slot at the head of the row. Dashed border and
|
||||||
|
no liveness dot keep it from being mistaken for a lane. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-tile-add"
|
||||||
|
onClick={() => setAddLaneOpen(true)}
|
||||||
|
title={tLanes("addLane")}
|
||||||
|
className="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{tLanes("add")}
|
||||||
|
</button>
|
||||||
{lanes.map((l) => (
|
{lanes.map((l) => (
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
key={l.id}
|
key={l.id}
|
||||||
@@ -674,6 +645,9 @@ export function Workspace() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||||
|
belong beside the lane they act on, not in a page header. */}
|
||||||
|
<LaneToolsMenu lane={currentLane} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-detail-toggle"
|
data-testid="lane-detail-toggle"
|
||||||
|
|||||||
@@ -328,19 +328,11 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace page — lane integration", () => {
|
describe("Workspace page — lane integration", () => {
|
||||||
it("lane strip lists lanes and counters match the API response", async () => {
|
it("lane strip lists the lanes the API returned", async () => {
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
|
|
||||||
// Verify lanes are rendered
|
|
||||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||||
|
|
||||||
// Verify counters are rendered with correct values
|
|
||||||
const counterTexts = screen.getAllByText((_, element) => {
|
|
||||||
if (!element) return false;
|
|
||||||
return element.textContent?.includes("2 lanes") || false;
|
|
||||||
});
|
|
||||||
expect(counterTexts.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selecting a lane switches the pipeline and run id", async () => {
|
it("selecting a lane switches the pipeline and run id", async () => {
|
||||||
@@ -497,22 +489,16 @@ describe("Workspace page — lane integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace layout", () => {
|
describe("Workspace layout", () => {
|
||||||
it("shows the four counters with the values the API reported", async () => {
|
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||||
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
|
const user = userEvent.setup();
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
|
|
||||||
expect(screen.getByTestId("count-total").textContent).toContain("2");
|
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||||
expect(screen.getByTestId("count-running").textContent).toContain("1");
|
const strip = screen.getByTestId("lane-strip");
|
||||||
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
|
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||||
expect(screen.getByTestId("count-dead").textContent).toContain("4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the needs-you and dead counters when they are zero", async () => {
|
await user.click(screen.getByTestId("lane-tile-add"));
|
||||||
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
|
expect(await screen.findByText("Create a lane from a working directory")).toBeInTheDocument();
|
||||||
await renderWorkspace();
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("count-needs-you")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("count-dead")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives every lane a tile in the carousel", async () => {
|
it("gives every lane a tile in the carousel", async () => {
|
||||||
|
|||||||
@@ -5097,42 +5097,20 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-3 border-b border-border pb-3"
|
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||||
>
|
>
|
||||||
<h2
|
|
||||||
class="text-base font-semibold tracking-tight text-fg-primary"
|
|
||||||
>
|
|
||||||
Lanes
|
|
||||||
</h2>
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-1.5 text-xs"
|
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
||||||
>
|
data-testid="lane-strip"
|
||||||
<span
|
|
||||||
class="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
|
|
||||||
data-testid="count-total"
|
|
||||||
>
|
|
||||||
0
|
|
||||||
|
|
||||||
lanes
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
|
|
||||||
data-testid="count-running"
|
|
||||||
>
|
|
||||||
0
|
|
||||||
|
|
||||||
running
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="ml-auto flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
|
class="flex w-56 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-dashed border-border-light p-3 text-[13px] font-medium text-fg-secondary transition-colors hover:border-accent hover:bg-accent/5 hover:text-fg-primary"
|
||||||
|
data-testid="lane-tile-add"
|
||||||
title="Create a lane from a working directory"
|
title="Create a lane from a working directory"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-plus h-3.5 w-3.5"
|
class="lucide lucide-plus h-4 w-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -5152,15 +5130,6 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
Add lane
|
Add lane
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex shrink-0 flex-row gap-2 overflow-x-auto pb-1"
|
|
||||||
data-testid="lane-strip"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
class="text-sm text-fg-muted"
|
class="text-sm text-fg-muted"
|
||||||
>
|
>
|
||||||
@@ -5182,25 +5151,97 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
aria-label="1 pane"
|
||||||
aria-pressed="true"
|
aria-pressed="true"
|
||||||
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||||
|
title="1 pane"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
1 pane
|
<svg
|
||||||
|
class="lucide lucide-square h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
width="18"
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="2 pane"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||||
|
title="2 pane"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
2 pane
|
<svg
|
||||||
|
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
width="18"
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 3v18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
aria-label="4 pane"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||||
|
title="4 pane"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
4 pane
|
<svg
|
||||||
|
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 3v18"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 12h18"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
width="18"
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -5208,13 +5249,13 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
data-testid="console-body"
|
data-testid="console-body"
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
class="flex items-start gap-3"
|
class="flex items-center gap-2.5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
class="lucide lucide-play w-3.5 h-3.5 text-accent"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -5231,13 +5272,13 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="min-w-0 flex-1"
|
class="flex min-w-0 flex-1 items-center gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2"
|
class="flex shrink-0 items-center gap-2"
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
class="text-lg font-semibold text-fg-primary"
|
class="text-base font-semibold text-fg-primary"
|
||||||
>
|
>
|
||||||
Run Claude
|
Run Claude
|
||||||
</h1>
|
</h1>
|
||||||
@@ -5251,9 +5292,9 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
class="text-xs text-fg-muted max-w-3xl"
|
class="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||||
>
|
>
|
||||||
Spin up a Claude session right inside the dashboard. Live streaming output, multi-turn conversation, and the same hooks-driven analytics as your terminal sessions.
|
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -5298,10 +5339,172 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
class="rounded-xl border border-border bg-surface-1"
|
class="rounded-xl border border-border bg-surface-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
class="flex flex-wrap items-end gap-3 border-b border-border px-4 py-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Working directory
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
||||||
|
placeholder="Type to search or paste an absolute path…"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="space-y-1.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
Inherit from settings
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m6 9 6 6 6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Permission mode
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
acceptEdits (recommended)
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m6 9 6 6 6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
|
>
|
||||||
|
Thinking effort
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
Default (model decides)
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m6 9 6 6 6-6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-pressed="true"
|
aria-pressed="true"
|
||||||
@@ -5341,168 +5544,6 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
· / for slash commands · @ for file references
|
· / for slash commands · @ for file references
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-2 lg:grid-cols-4"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Working directory
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-folder-open absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-fg-muted pointer-events-none"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
autocomplete="off"
|
|
||||||
class="w-full bg-surface-2 border border-border rounded-md pl-7 pr-3 py-1.5 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
|
||||||
placeholder="Type to search or paste an absolute path…"
|
|
||||||
spellcheck="false"
|
|
||||||
type="text"
|
|
||||||
value=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Model
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="space-y-1.5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
Inherit from settings
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 9 6 6 6-6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Permission mode
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
acceptEdits (recommended)
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 9 6 6 6-6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
||||||
>
|
|
||||||
Thinking effort
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate"
|
|
||||||
>
|
|
||||||
Default (model decides)
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m6 9 6 6 6-6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||||
>
|
>
|
||||||
@@ -9537,7 +9578,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.6.5 (rebuild client to match)
|
UI build v1.6.7 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+13
-5
@@ -370,11 +370,13 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
|
|||||||
|
|
||||||
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
The dashboard web UI merges lanes and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
|
||||||
|
|
||||||
- **Header** — the page title and four counters (`lanes`, `running`, `needs you`, `dead`), plus Add lane. The `needs you` and `dead` counters appear only when they are non-zero, so a quiet header means nothing is waiting on a human.
|
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the lane strip below already shows every lane with its own liveness dot and its ⚠ marker, so the counters restated it a row higher and cost height on a screen whose point is the terminal. `GET /api/lanes` still returns `counts` for other callers.
|
||||||
|
- **Lane strip** — the horizontally scrolling row of lane tiles. Its FIRST tile is **Add lane** (dashed border, no liveness dot), not a header button: creating a lane is the same kind of act as picking one, so it reads as the empty slot at the head of the row.
|
||||||
|
- **Lane-detail header row** — one row, and every per-lane control lives on it: liveness + status, the lane's name, time-on-stage, the pipeline picker, the Model (AI Router) select, stage badges, the feature picker, and on the right the **Tools** menu (agents install / mcp sync / sync-base check, hidden when the lane declares no runtime profile), the info toggle, the split-view toggle and Remove. Tools sits here rather than in Settings because all three of its actions run against one lane's `cwd` — in Settings it would need a lane picker just to say which.
|
||||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling.
|
||||||
- **Split view** — a layout toggle (1 / 2 / 4 panes) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage with a progress bar and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||||
|
|
||||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||||
|
|
||||||
@@ -442,7 +444,8 @@ curl http://localhost:4820/api/lanes/5/git
|
|||||||
"head": "9b3e74a",
|
"head": "9b3e74a",
|
||||||
"subject": "free-text rule mode in the form",
|
"subject": "free-text rule mode in the form",
|
||||||
"dirty": 2,
|
"dirty": 2,
|
||||||
"untracked": 1
|
"untracked": 1,
|
||||||
|
"files": [" M server/lib/alerts.js", " M client/src/lib/api.ts", "?? scratch.txt"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -451,6 +454,11 @@ curl http://localhost:4820/api/lanes/5/git
|
|||||||
is a normal state, not a fault, and the card simply renders without a git row.
|
is a normal state, not a fault, and the card simply renders without a git row.
|
||||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||||
prettier invention.
|
prettier invention.
|
||||||
|
- `files` holds the raw `git status --porcelain` lines the two counts came from,
|
||||||
|
**capped at 20** — the card hovers them as a tooltip on the
|
||||||
|
`N modified · N untracked` text, and a lane mid-`npm install` would otherwise
|
||||||
|
ship thousands of paths through a polled endpoint. The counts are never
|
||||||
|
capped, so the tooltip appends `… +N` when it is showing less than all of it.
|
||||||
- An unknown lane id is a `404`.
|
- An unknown lane id is a `404`.
|
||||||
|
|
||||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||||
@@ -617,7 +625,7 @@ agenthub stage implement --evidence "Schema created, migrations run"
|
|||||||
This command:
|
This command:
|
||||||
- **Declares the stage** the work is now at
|
- **Declares the stage** the work is now at
|
||||||
- **Records evidence** (optional, but highly encouraged) — a short text description of what was completed
|
- **Records evidence** (optional, but highly encouraged) — a short text description of what was completed
|
||||||
- **Moves the progress bar** forward in the pipeline
|
- **Advances the pipeline map** to that node
|
||||||
- **Broadcasts to the UI** so connected dashboards see the update in real-time
|
- **Broadcasts to the UI** so connected dashboards see the update in real-time
|
||||||
|
|
||||||
### Full command signature
|
### Full command signature
|
||||||
|
|||||||
+2
-2
@@ -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.6.5
|
version: 1.6.7
|
||||||
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ĩ
|
||||||
@@ -10700,7 +10700,7 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
|
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files holds the raw `git status --porcelain` lines behind those counts, capped at 20; the counts themselves are never capped.
|
||||||
'404':
|
'404':
|
||||||
description: Lane not found.
|
description: Lane not found.
|
||||||
/api/lanes/{id}/runtime:
|
/api/lanes/{id}/runtime:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.5",
|
"version": "1.6.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.5",
|
"version": "1.6.7",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "1.6.5",
|
"version": "1.6.7",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ describe("worktree", () => {
|
|||||||
assert.equal(s.dirty, 1);
|
assert.equal(s.dirty, 1);
|
||||||
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
||||||
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
||||||
|
// The same lines the counts came from, so a caller can name the files.
|
||||||
|
assert.deepEqual(s.files.sort(), [" M README.md", "?? scratch.txt"]);
|
||||||
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
// The fixture has no remotes. Given the lane's base branch, unpushedCount
|
||||||
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
// measures base..HEAD — this worktree has committed nothing of its own, so 0.
|
||||||
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
assert.equal(await wt.unpushedCount(dir, "main"), 0);
|
||||||
|
|||||||
+11
-3
@@ -16,6 +16,8 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
const LANES_ROOT = process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "agenthub-lanes");
|
||||||
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
||||||
|
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
||||||
|
const STATUS_FILE_CAP = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
|
||||||
@@ -549,11 +551,16 @@ async function removeWorktree(lane) {
|
|||||||
/**
|
/**
|
||||||
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
* Parse git status --porcelain to count dirty, untracked, and get HEAD commit.
|
||||||
* Lines starting with ?? are untracked; others are dirty.
|
* Lines starting with ?? are untracked; others are dirty.
|
||||||
|
*
|
||||||
|
* `files` carries the same lines back verbatim so a caller can show WHICH
|
||||||
|
* files the counts mean, capped because this rides a polled endpoint and a
|
||||||
|
* lane mid-`npm install` can report thousands.
|
||||||
*/
|
*/
|
||||||
async function statusCounts(dir) {
|
async function statusCounts(dir) {
|
||||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||||
let dirty = 0;
|
let dirty = 0;
|
||||||
let untracked = 0;
|
let untracked = 0;
|
||||||
|
const files = [];
|
||||||
|
|
||||||
for (const line of result.stdout.split("\n")) {
|
for (const line of result.stdout.split("\n")) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
@@ -562,13 +569,14 @@ async function statusCounts(dir) {
|
|||||||
} else {
|
} else {
|
||||||
dirty++;
|
dirty++;
|
||||||
}
|
}
|
||||||
|
if (files.length < STATUS_FILE_CAP) files.push(line.trimEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get short commit hash
|
// Get short commit hash
|
||||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||||
const head = headResult.stdout.trim();
|
const head = headResult.stdout.trim();
|
||||||
|
|
||||||
return { dirty, untracked, head };
|
return { dirty, untracked, head, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -581,10 +589,10 @@ async function statusCounts(dir) {
|
|||||||
* caller shows what git says rather than inventing a nicer word for it.
|
* caller shows what git says rather than inventing a nicer word for it.
|
||||||
*/
|
*/
|
||||||
async function gitFacts(dir) {
|
async function gitFacts(dir) {
|
||||||
const { dirty, untracked, head } = await statusCounts(dir);
|
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||||
return { branch, head, subject, dirty, untracked };
|
return { branch, head, subject, dirty, untracked, files };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const paths = {
|
|||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description:
|
description:
|
||||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files holds the raw `git status --porcelain` lines behind those counts, capped at 20; the counts themselves are never capped.",
|
||||||
},
|
},
|
||||||
404: { description: "Lane not found." },
|
404: { description: "Lane not found." },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user