fix(workspace): stop the console remounting, and the idle polling re-renders
The Workspace page rendered `ConsoleArea` from two sites, picked by `currentLane`. Every flip of that value unmounted the whole console subtree and mounted it again at the other site: the terminal died, the run history went with it, and `LaneConsolePane`'s lane-switch effect wiped a half-typed prompt. It flipped on every page load (lanes arrive, `refreshLanes` selects `lanes[0]`), on a lane being removed, and on any refetch that re-picked the selection. `lane-detail` is now always rendered and holds the only `ConsoleArea`; the header and info block are the conditional parts. Same rule the RunConsole already followed one level down — collapse with CSS, never by unmounting — now enforced above it too. Second half: every poller fed `setState` a brand-new object graph each tick, re-rendering the lane strip, the card and the console area several times a minute on a completely idle dashboard. `keepIfSame` keeps the previous reference when the payload is value-identical, applied to the 5 s run/session poll, the lane list and counts, the `lane_update` WS branch, and LaneCard's git/runtime/locks pollers. Verified: npm run test:client (44 files / 454 tests), tsc --noEmit clean, check-headers.sh clean. The Run screen snapshot was regenerated — the diff is the added `<section>` wrapper and one indent level, no content change.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: 8a812a1 (2026-09-03) + codex auto-advance._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
_Last synced: e109d28 (2026-09-04) + Workspace single console render site._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
@@ -27,7 +27,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
|||||||
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `ccam feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
||||||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
||||||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently.
|
||||||
- **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history.
|
- **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
|
||||||
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||||||
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||||||
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
|
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||||
|
|
||||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||||
@@ -49,12 +50,12 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
|||||||
api.lanes
|
api.lanes
|
||||||
.git(laneId)
|
.git(laneId)
|
||||||
.then((f) => {
|
.then((f) => {
|
||||||
if (alive) setFacts(f);
|
if (alive) setFacts(keepIfSame(f));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Silent: a card that cannot read git shows the rest of itself. An
|
// Silent: a card that cannot read git shows the rest of itself. An
|
||||||
// error banner here would fire on every lane on every server blip.
|
// error banner here would fire on every lane on every server blip.
|
||||||
if (alive) setFacts({ available: false });
|
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
@@ -85,10 +86,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
|
|||||||
api.lanes
|
api.lanes
|
||||||
.runtime(laneId)
|
.runtime(laneId)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (alive) setRuntime(r);
|
if (alive) setRuntime(keepIfSame(r));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (alive) setRuntime({ available: false });
|
if (alive) setRuntime(keepIfSame<LaneRuntime>({ available: false }));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
@@ -116,7 +117,7 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|||||||
api.locks
|
api.locks
|
||||||
.list()
|
.list()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
|
if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* fails silently, same contract as the git/runtime pollers */
|
/* fails silently, same contract as the git/runtime pollers */
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @file keepIfSame.test.ts
|
||||||
|
* @description Covers the identity contract `keepIfSame` exists for: a polled
|
||||||
|
* payload that is value-identical must keep the PREVIOUS reference (so React
|
||||||
|
* bails out of the re-render), and any real change must yield the new one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { keepIfSame } from "../keepIfSame";
|
||||||
|
|
||||||
|
describe("keepIfSame", () => {
|
||||||
|
it("keeps the previous reference when the fetched payload is value-identical", () => {
|
||||||
|
const prev = [{ id: 1, stage: "implement" }];
|
||||||
|
const fetched = [{ id: 1, stage: "implement" }];
|
||||||
|
|
||||||
|
expect(keepIfSame(fetched)(prev)).toBe(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields the new value when anything actually changed", () => {
|
||||||
|
const prev = [{ id: 1, stage: "implement" }];
|
||||||
|
const fetched = [{ id: 1, stage: "review" }];
|
||||||
|
|
||||||
|
expect(keepIfSame(fetched)(prev)).toBe(fetched);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yields the new value out of the null a poller starts from", () => {
|
||||||
|
const fetched = { available: false };
|
||||||
|
|
||||||
|
expect(keepIfSame(fetched)(null)).toBe(fetched);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps null when the payload is still null", () => {
|
||||||
|
expect(keepIfSame(null)(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @file keepIfSame.ts
|
||||||
|
* @description State-updater helper for polled data: keeps the previous value's
|
||||||
|
* identity when the freshly fetched payload is value-identical, so a background
|
||||||
|
* poll that found nothing new does not re-render the tree it feeds.
|
||||||
|
*
|
||||||
|
* Every poller in this app (`api.lanes.list`, `api.run.list`, `api.lanes.git`,
|
||||||
|
* …) returns a brand-new object graph on each tick. Passing that straight into
|
||||||
|
* `setState` re-renders every consumer several times a minute even when the
|
||||||
|
* dashboard is completely idle — visible as the whole page "refreshing".
|
||||||
|
*
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a freshly fetched value as a functional state update that returns the
|
||||||
|
* previous state unchanged when the two are value-equal.
|
||||||
|
*
|
||||||
|
* Usage: `setLanes(keepIfSame(r.lanes))` instead of `setLanes(r.lanes)`.
|
||||||
|
*
|
||||||
|
* `prev` is typed wider than `T` so the same helper works for the `T | null`
|
||||||
|
* states the pollers start out in; the cast is sound because it only happens on
|
||||||
|
* the branch where `prev` serializes identically to `next`.
|
||||||
|
*
|
||||||
|
* @param next The value just fetched.
|
||||||
|
* @returns An updater for `setState`, yielding `prev` when nothing changed.
|
||||||
|
*/
|
||||||
|
export function keepIfSame<T>(next: T): (prev: T | null | undefined) => T {
|
||||||
|
// ponytail: JSON.stringify comparison — fine for the payloads this app polls
|
||||||
|
// (lane lists, run lists, a git-facts object: tens of small records, key order
|
||||||
|
// is stable because it comes from the same serializer every time). Swap for a
|
||||||
|
// structural deep-equal if a poller ever returns something big or key-unstable.
|
||||||
|
return (prev) => (JSON.stringify(prev) === JSON.stringify(next) ? (prev as T) : next);
|
||||||
|
}
|
||||||
+163
-167
@@ -55,6 +55,7 @@ import type {
|
|||||||
LaneTool,
|
LaneTool,
|
||||||
} from "../lib/types";
|
} from "../lib/types";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import PipelineMap from "../components/lanes/PipelineMap";
|
import PipelineMap from "../components/lanes/PipelineMap";
|
||||||
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
|
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||||
@@ -240,8 +241,8 @@ export function Workspace() {
|
|||||||
const refreshLanes = useCallback(async () => {
|
const refreshLanes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.lanes.list();
|
const r = await api.lanes.list();
|
||||||
setLanes(r.lanes);
|
setLanes(keepIfSame(r.lanes));
|
||||||
setCounts(r.counts);
|
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)
|
||||||
);
|
);
|
||||||
@@ -298,6 +299,10 @@ export function Workspace() {
|
|||||||
void refreshLanes();
|
void refreshLanes();
|
||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
// The server re-broadcasts a lane on hook activity that often changed
|
||||||
|
// nothing this page renders; replacing the array anyway would re-render
|
||||||
|
// the strip and the console on every such event.
|
||||||
|
if (JSON.stringify(cur[i]) === JSON.stringify(lane)) return cur;
|
||||||
const next = [...cur];
|
const next = [...cur];
|
||||||
next[i] = lane;
|
next[i] = lane;
|
||||||
return next;
|
return next;
|
||||||
@@ -332,14 +337,17 @@ export function Workspace() {
|
|||||||
});
|
});
|
||||||
}, [lanes]);
|
}, [lanes]);
|
||||||
|
|
||||||
|
// Both setters go through `keepIfSame`: these fire every 5 s below, and a
|
||||||
|
// fresh-but-identical payload would otherwise re-render the lane strip, the
|
||||||
|
// lane card and the whole console area on every tick for nothing.
|
||||||
const refreshList = useCallback(() => {
|
const refreshList = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
.then(setActiveRuns)
|
.then((r) => setActiveRuns(keepIfSame(r)))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.sessions
|
api.sessions
|
||||||
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
|
.list({ status: "active", limit: 50, sort_by: "updated_at", sort_desc: true })
|
||||||
.then((r) => setExternalSessions(r.sessions))
|
.then((r) => setExternalSessions(keepIfSame(r.sessions)))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -556,12 +564,19 @@ export function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The selected lane's pipeline — the thing you actually come to this
|
{/* The selected lane's pipeline — the thing you actually come to this
|
||||||
page to read, beside the list rather than stacked under it. */}
|
page to read, beside the list rather than stacked under it.
|
||||||
{currentLane && (
|
The section itself is ALWAYS rendered, even with no lane selected:
|
||||||
<section
|
`ConsoleArea` lives inside it, and a conditional wrapper would give
|
||||||
data-testid="lane-detail"
|
the console two render sites, so every flip of `currentLane` (lanes
|
||||||
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
arriving on mount, a lane removed, a refetch re-picking the
|
||||||
>
|
selection) unmounted the terminal and wiped a half-typed prompt.
|
||||||
|
Same rule as RunConsole's own collapse — hide with CSS, never by
|
||||||
|
unmounting. */}
|
||||||
|
<section
|
||||||
|
data-testid="lane-detail"
|
||||||
|
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
|
||||||
|
>
|
||||||
|
{currentLane && (
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||||
@@ -594,171 +609,151 @@ export function Workspace() {
|
|||||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showInfo && (
|
)}
|
||||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
{currentLane && showInfo && (
|
||||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||||
<select
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||||
data-testid="pipeline-picker"
|
<select
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
data-testid="pipeline-picker"
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
value={currentLane.pipeline}
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||||
disabled={!!viewedFeature}
|
value={currentLane.pipeline}
|
||||||
title={
|
disabled={!!viewedFeature}
|
||||||
viewedFeature
|
title={
|
||||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
viewedFeature
|
||||||
: undefined
|
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||||
}
|
: undefined
|
||||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
}
|
||||||
>
|
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||||
{(pipelineTemplates.length
|
>
|
||||||
? pipelineTemplates
|
{(pipelineTemplates.length
|
||||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
? pipelineTemplates
|
||||||
).map((p) => (
|
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||||
<option key={p.id} value={p.id}>
|
).map((p) => (
|
||||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
<option key={p.id} value={p.id}>
|
||||||
</option>
|
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
</select>
|
||||||
|
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||||
session ever calls `ccam stage` — that string collides with
|
session ever calls `ccam stage` — that string collides with
|
||||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||||
actively running but has never declared a stage read as if it
|
actively running but has never declared a stage read as if it
|
||||||
were sitting idle. Show a distinct label instead of the raw
|
were sitting idle. Show a distinct label instead of the raw
|
||||||
sentinel whenever it doesn't match any node this pipeline
|
sentinel whenever it doesn't match any node this pipeline
|
||||||
actually has. */}
|
actually has. */}
|
||||||
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
|
||||||
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
|
||||||
? currentLane.stage
|
? currentLane.stage
|
||||||
: tLanes("stageUndeclared")}
|
: tLanes("stageUndeclared")}
|
||||||
</span>
|
</span>
|
||||||
{currentLane.detected_stage && (
|
{currentLane.detected_stage && (
|
||||||
<span
|
<span
|
||||||
data-testid="detail-auto-stage"
|
data-testid="detail-auto-stage"
|
||||||
title={currentLane.detected_signal || undefined}
|
title={currentLane.detected_signal || undefined}
|
||||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||||
>
|
|
||||||
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{features.length > 0 && (
|
|
||||||
<select
|
|
||||||
data-testid="feature-picker"
|
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
|
||||||
value={viewedFeatureSlug ?? ""}
|
|
||||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
|
||||||
>
|
|
||||||
<option value="">{tLanes("features.live")}</option>
|
|
||||||
{features.map((f) => (
|
|
||||||
<option key={f.slug} value={f.slug}>
|
|
||||||
{f.slug}
|
|
||||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="lane-action-remove"
|
|
||||||
onClick={() => setRemoveConfirmOpen(true)}
|
|
||||||
className="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
|
|
||||||
>
|
>
|
||||||
{currentLane.kind === "adopted"
|
{tLanes("autoStage", { stage: currentLane.detected_stage })}
|
||||||
? tLanes("action.forget")
|
</span>
|
||||||
: tLanes("action.remove")}
|
)}
|
||||||
</button>
|
{features.length > 0 && (
|
||||||
</div>
|
<select
|
||||||
<div className="mb-3">
|
data-testid="feature-picker"
|
||||||
<LaneCard
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||||
lane={currentLane}
|
value={viewedFeatureSlug ?? ""}
|
||||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||||
childWorktrees={lanes.filter(
|
>
|
||||||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
<option value="">{tLanes("features.live")}</option>
|
||||||
)}
|
{features.map((f) => (
|
||||||
onSelectLane={setSelectedLaneId}
|
<option key={f.slug} value={f.slug}>
|
||||||
/>
|
{f.slug}
|
||||||
</div>
|
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||||
<div className="mb-3">
|
</option>
|
||||||
<PipelineMap
|
))}
|
||||||
nodes={
|
</select>
|
||||||
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
)}
|
||||||
}
|
<button
|
||||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
type="button"
|
||||||
/>
|
data-testid="lane-action-remove"
|
||||||
{viewedFeature && (
|
onClick={() => setRemoveConfirmOpen(true)}
|
||||||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
className="ml-auto rounded px-2 py-0.5 text-status-danger transition-colors hover:bg-status-danger/15"
|
||||||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
>
|
||||||
</p>
|
{currentLane.kind === "adopted"
|
||||||
)}
|
? tLanes("action.forget")
|
||||||
</div>
|
: tLanes("action.remove")}
|
||||||
{proofFeature &&
|
</button>
|
||||||
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
|
||||||
<div data-testid="proof-gallery" className="mt-2">
|
|
||||||
{proofFeature.ticket_report && (
|
|
||||||
<a
|
|
||||||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-xs text-fg-muted"
|
|
||||||
>
|
|
||||||
{tLanes("proof.ticketReport")}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
|
||||||
<div key={group} className="mt-1">
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{group} · {images.length}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{images.slice(0, 8).map((img) => (
|
|
||||||
<img
|
|
||||||
key={img}
|
|
||||||
loading="lazy"
|
|
||||||
className="h-16 w-16 rounded object-cover"
|
|
||||||
src={api.lanes.proof.imageUrl(
|
|
||||||
currentLane.id,
|
|
||||||
proofFeature.slug,
|
|
||||||
group,
|
|
||||||
img
|
|
||||||
)}
|
|
||||||
alt={img}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{images.length > 8 && (
|
|
||||||
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mb-3">
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
<LaneCard
|
||||||
<ConsoleArea
|
lane={currentLane}
|
||||||
lanes={lanes}
|
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||||
selectedLaneId={selectedLaneId}
|
childWorktrees={lanes.filter(
|
||||||
splitView={splitView}
|
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||||
setLayout={setLayout}
|
)}
|
||||||
setPaneLaneId={setPaneLaneId}
|
onSelectLane={setSelectedLaneId}
|
||||||
binaryStatus={binaryStatus}
|
/>
|
||||||
cwdSuggestions={cwdSuggestions}
|
</div>
|
||||||
activeRuns={activeRuns}
|
<div className="mb-3">
|
||||||
externalSessions={externalSessions}
|
<PipelineMap
|
||||||
wsConnected={wsConnected}
|
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
||||||
defaultCwd={defaultCwd}
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||||
onHasActiveRunChange={setPaneHasActiveRun}
|
/>
|
||||||
onLaneCreated={(lane) =>
|
{viewedFeature && (
|
||||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||||||
}
|
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||||||
onLaneIdChange={setSelectedLaneId}
|
</p>
|
||||||
showToggle={false}
|
)}
|
||||||
/>
|
</div>
|
||||||
|
{proofFeature &&
|
||||||
|
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
||||||
|
<div data-testid="proof-gallery" className="mt-2">
|
||||||
|
{proofFeature.ticket_report && (
|
||||||
|
<a
|
||||||
|
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
{tLanes("proof.ticketReport")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
||||||
|
<div key={group} className="mt-1">
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{group} · {images.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{images.slice(0, 8).map((img) => (
|
||||||
|
<img
|
||||||
|
key={img}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-16 w-16 rounded object-cover"
|
||||||
|
src={api.lanes.proof.imageUrl(
|
||||||
|
currentLane.id,
|
||||||
|
proofFeature.slug,
|
||||||
|
group,
|
||||||
|
img
|
||||||
|
)}
|
||||||
|
alt={img}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{images.length > 8 && (
|
||||||
|
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
)}
|
<div
|
||||||
|
className={`flex min-h-0 flex-1 flex-col gap-2 ${
|
||||||
{!currentLane && (
|
currentLane ? "border-t border-border pt-3" : ""
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
}`}
|
||||||
|
>
|
||||||
|
{/* `showToggle`: the lane header carries its own split toggle; with
|
||||||
|
no lane there is no header, so the console shows one instead. */}
|
||||||
<ConsoleArea
|
<ConsoleArea
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
@@ -776,9 +771,10 @@ export function Workspace() {
|
|||||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||||
}
|
}
|
||||||
onLaneIdChange={setSelectedLaneId}
|
onLaneIdChange={setSelectedLaneId}
|
||||||
|
showToggle={!currentLane}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentLane && removeConfirmOpen && (
|
{currentLane && removeConfirmOpen && (
|
||||||
|
|||||||
@@ -584,6 +584,19 @@ describe("Workspace — the console is its own section", () => {
|
|||||||
const body = screen.getByTestId("console-body");
|
const body = screen.getByTestId("console-body");
|
||||||
expect(detail.contains(body)).toBe(true);
|
expect(detail.contains(body)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A SECOND render site for the console is what let `currentLane` flipping
|
||||||
|
// (lanes arriving on mount, the selected lane removed, a refetch re-picking
|
||||||
|
// the selection) unmount the terminal and wipe a half-typed prompt. It must
|
||||||
|
// sit inside `lane-detail` in BOTH states, so React never has to move it.
|
||||||
|
it("keeps the console at one render site whether or not a lane is selected", async () => {
|
||||||
|
lanesToReturn = [];
|
||||||
|
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
|
||||||
|
await renderWorkspace();
|
||||||
|
|
||||||
|
const detail = screen.getByTestId("lane-detail");
|
||||||
|
expect(detail.contains(screen.getByTestId("console-body"))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace — proof gallery", () => {
|
describe("Workspace — proof gallery", () => {
|
||||||
|
|||||||
@@ -5064,192 +5064,283 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<section
|
||||||
class="flex min-h-0 flex-1 flex-col gap-2"
|
class="flex min-h-0 min-w-0 flex-1 flex-col "
|
||||||
|
data-testid="lane-detail"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1.5"
|
class="flex min-h-0 flex-1 flex-col gap-2 "
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
aria-pressed="true"
|
|
||||||
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
1 pane
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-pressed="false"
|
|
||||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
2 pane
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-pressed="false"
|
|
||||||
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
4 pane
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
|
||||||
data-testid="console-body"
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
class="flex items-start gap-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<polygon
|
|
||||||
points="6 3 20 12 6 21 6 3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
class="text-lg font-semibold text-fg-primary"
|
|
||||||
>
|
|
||||||
Run Claude
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
|
||||||
/>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-xs text-fg-muted max-w-3xl"
|
|
||||||
>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
|
||||||
disabled=""
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-list-ordered w-3.5 h-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="M10 12h11"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 18h11"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M10 6h11"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M4 10h2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M4 6h1v4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Active runs
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div
|
<div
|
||||||
class="rounded-xl border border-border bg-surface-1"
|
class="flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
class="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-border px-3 py-2 text-[11.5px]"
|
aria-pressed="true"
|
||||||
|
class="rounded border px-2 py-1 text-xs border-accent bg-accent/15 text-accent"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
1 pane
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed="false"
|
||||||
|
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
2 pane
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed="false"
|
||||||
|
class="rounded border px-2 py-1 text-xs border-border text-fg-secondary hover:border-border-light"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
4 pane
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||||
|
data-testid="console-body"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="flex items-start gap-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
class="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||||
>
|
>
|
||||||
<button
|
<svg
|
||||||
aria-pressed="true"
|
class="lucide lucide-play w-4.5 h-4.5 text-accent"
|
||||||
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
fill="none"
|
||||||
title="Start a fresh Claude Code session."
|
height="24"
|
||||||
type="button"
|
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"
|
||||||
>
|
>
|
||||||
New session
|
<polygon
|
||||||
</button>
|
points="6 3 20 12 6 21 6 3"
|
||||||
<button
|
/>
|
||||||
aria-pressed="false"
|
</svg>
|
||||||
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
|
|
||||||
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Resume existing session
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-4 py-3 border-b border-border"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
|
||||||
>
|
|
||||||
Prompt
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
|
||||||
placeholder="Ask Claude anything…"
|
|
||||||
rows="5"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
class="mt-1 text-[10px] text-fg-muted"
|
class="min-w-0 flex-1"
|
||||||
>
|
>
|
||||||
Cmd+Enter / Ctrl+Enter to send
|
|
||||||
· / for slash commands · @ for file references
|
|
||||||
</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
|
<div
|
||||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<h1
|
||||||
|
class="text-lg font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
|
Run Claude
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot"
|
||||||
|
/>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-xs text-fg-muted max-w-3xl"
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||||
|
disabled=""
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-list-ordered w-3.5 h-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="M10 12h11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 18h11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 6h11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 10h2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 6h1v4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Active runs
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-border bg-surface-1"
|
||||||
|
>
|
||||||
|
<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]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded-md border border-border bg-surface-2 p-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-pressed="true"
|
||||||
|
class="rounded px-2 py-0.5 font-medium transition-colors bg-accent/20 text-accent"
|
||||||
|
title="Start a fresh Claude Code session."
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
New session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed="false"
|
||||||
|
class="rounded px-2 py-0.5 font-medium transition-colors text-fg-secondary hover:text-fg-primary"
|
||||||
|
title="Pick a session from your history and continue the conversation. Cwd is locked to the original."
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Resume existing session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 border-b border-border"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
||||||
|
>
|
||||||
|
Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||||
|
placeholder="Ask Claude anything…"
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mt-1 text-[10px] text-fg-muted"
|
||||||
|
>
|
||||||
|
Cmd+Enter / Ctrl+Enter to send
|
||||||
|
· / for slash commands · @ for file references
|
||||||
|
</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
|
<div
|
||||||
class="relative"
|
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative"
|
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
|
<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"
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
fill="none"
|
fill="none"
|
||||||
height="24"
|
height="24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -5261,30 +5352,18 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
d="m6 9 6 6 6-6"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
</button>
|
||||||
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>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label
|
||||||
<label
|
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
>
|
||||||
>
|
Thinking effort
|
||||||
Model
|
</label>
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
class="space-y-1.5"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
>
|
>
|
||||||
@@ -5295,7 +5374,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="truncate"
|
class="truncate"
|
||||||
>
|
>
|
||||||
Inherit from settings
|
Default (model decides)
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
|
||||||
@@ -5317,113 +5396,39 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
disabled=""
|
|
||||||
>
|
>
|
||||||
<svg
|
<div
|
||||||
class="lucide lucide-play w-3.5 h-3.5"
|
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||||
fill="none"
|
/>
|
||||||
height="24"
|
<button
|
||||||
stroke="currentColor"
|
class="inline-flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
stroke-linecap="round"
|
disabled=""
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<polygon
|
<svg
|
||||||
points="6 3 20 12 6 21 6 3"
|
class="lucide lucide-play w-3.5 h-3.5"
|
||||||
/>
|
fill="none"
|
||||||
</svg>
|
height="24"
|
||||||
Run
|
stroke="currentColor"
|
||||||
</button>
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="6 3 20 12 6 21 6 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user