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
|
||||
|
||||
_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
|
||||
- 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** 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
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(f);
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// 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.
|
||||
if (alive) setFacts({ available: false });
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
@@ -85,10 +86,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
|
||||
api.lanes
|
||||
.runtime(laneId)
|
||||
.then((r) => {
|
||||
if (alive) setRuntime(r);
|
||||
if (alive) setRuntime(keepIfSame(r));
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setRuntime({ available: false });
|
||||
if (alive) setRuntime(keepIfSame<LaneRuntime>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
@@ -116,7 +117,7 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
api.locks
|
||||
.list()
|
||||
.then((data) => {
|
||||
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
|
||||
if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder)));
|
||||
})
|
||||
.catch(() => {
|
||||
/* 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,
|
||||
} from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since, LIVENESS_DOT, LaneToolsMenu } from "../components/lanes/LaneCard";
|
||||
@@ -240,8 +241,8 @@ export function Workspace() {
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.lanes.list();
|
||||
setLanes(r.lanes);
|
||||
setCounts(r.counts);
|
||||
setLanes(keepIfSame(r.lanes));
|
||||
setCounts(keepIfSame(r.counts));
|
||||
setSelectedLaneId((cur) =>
|
||||
cur !== null && r.lanes.some((l) => l.id === cur) ? cur : (r.lanes[0]?.id ?? null)
|
||||
);
|
||||
@@ -298,6 +299,10 @@ export function Workspace() {
|
||||
void refreshLanes();
|
||||
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];
|
||||
next[i] = lane;
|
||||
return next;
|
||||
@@ -332,14 +337,17 @@ export function Workspace() {
|
||||
});
|
||||
}, [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(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then(setActiveRuns)
|
||||
.then((r) => setActiveRuns(keepIfSame(r)))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.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);
|
||||
}, []);
|
||||
|
||||
@@ -556,12 +564,19 @@ export function Workspace() {
|
||||
</div>
|
||||
|
||||
{/* The selected lane's pipeline — the thing you actually come to this
|
||||
page to read, beside the list rather than stacked under it. */}
|
||||
{currentLane && (
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
||||
>
|
||||
page to read, beside the list rather than stacked under it.
|
||||
The section itself is ALWAYS rendered, even with no lane selected:
|
||||
`ConsoleArea` lives inside it, and a conditional wrapper would give
|
||||
the console two render sites, so every flip of `currentLane` (lanes
|
||||
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="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">
|
||||
@@ -594,171 +609,151 @@ export function Workspace() {
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
</div>
|
||||
</div>
|
||||
{showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
viewedFeature
|
||||
? tLanes("features.viewingArchived", { slug: viewedFeature.slug })
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => void handlePipelineChange(currentLane.id, e.target.value)}
|
||||
>
|
||||
{(pipelineTemplates.length
|
||||
? pipelineTemplates
|
||||
: [{ id: currentLane.pipeline, name: currentLane.pipeline_name, nodes: [] }]
|
||||
).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
session ever calls `ccam stage` — that string collides with
|
||||
`status`'s own "idle"/"running" vocabulary, so a lane that is
|
||||
actively running but has never declared a stage read as if it
|
||||
were sitting idle. Show a distinct label instead of the raw
|
||||
sentinel whenever it doesn't match any node this pipeline
|
||||
actually has. */}
|
||||
<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.stage
|
||||
: tLanes("stageUndeclared")}
|
||||
</span>
|
||||
{currentLane.detected_stage && (
|
||||
<span
|
||||
data-testid="detail-auto-stage"
|
||||
title={currentLane.detected_signal || undefined}
|
||||
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"
|
||||
<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.stage
|
||||
: tLanes("stageUndeclared")}
|
||||
</span>
|
||||
{currentLane.detected_stage && (
|
||||
<span
|
||||
data-testid="detail-auto-stage"
|
||||
title={currentLane.detected_signal || undefined}
|
||||
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
|
||||
>
|
||||
{currentLane.kind === "adopted"
|
||||
? tLanes("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<LaneCard
|
||||
lane={currentLane}
|
||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||
childWorktrees={lanes.filter(
|
||||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||
)}
|
||||
onSelectLane={setSelectedLaneId}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<PipelineMap
|
||||
nodes={
|
||||
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||
}
|
||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||
/>
|
||||
{viewedFeature && (
|
||||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{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("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
splitView={splitView}
|
||||
setLayout={setLayout}
|
||||
setPaneLaneId={setPaneLaneId}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
externalSessions={externalSessions}
|
||||
wsConnected={wsConnected}
|
||||
defaultCwd={defaultCwd}
|
||||
onHasActiveRunChange={setPaneHasActiveRun}
|
||||
onLaneCreated={(lane) =>
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={false}
|
||||
/>
|
||||
<div className="mb-3">
|
||||
<LaneCard
|
||||
lane={currentLane}
|
||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||
childWorktrees={lanes.filter(
|
||||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||
)}
|
||||
onSelectLane={setSelectedLaneId}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<PipelineMap
|
||||
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||
/>
|
||||
{viewedFeature && (
|
||||
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
||||
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!currentLane && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
)}
|
||||
<div
|
||||
className={`flex min-h-0 flex-1 flex-col gap-2 ${
|
||||
currentLane ? "border-t border-border pt-3" : ""
|
||||
}`}
|
||||
>
|
||||
{/* `showToggle`: the lane header carries its own split toggle; with
|
||||
no lane there is no header, so the console shows one instead. */}
|
||||
<ConsoleArea
|
||||
lanes={lanes}
|
||||
selectedLaneId={selectedLaneId}
|
||||
@@ -776,9 +771,10 @@ export function Workspace() {
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||
}
|
||||
onLaneIdChange={setSelectedLaneId}
|
||||
showToggle={!currentLane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{currentLane && removeConfirmOpen && (
|
||||
|
||||
@@ -584,6 +584,19 @@ describe("Workspace — the console is its own section", () => {
|
||||
const body = screen.getByTestId("console-body");
|
||||
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", () => {
|
||||
|
||||
@@ -5064,192 +5064,283 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||
<section
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col "
|
||||
data-testid="lane-detail"
|
||||
>
|
||||
<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
|
||||
class="rounded-xl border border-border bg-surface-1"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<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]"
|
||||
<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="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
|
||||
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"
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
</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"
|
||||
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
|
||||
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
|
||||
class="relative"
|
||||
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-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"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
@@ -5261,30 +5352,18 @@ exports[`screen snapshots > Run 1`] = `
|
||||
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"
|
||||
d="m6 9 6 6 6-6"
|
||||
/>
|
||||
</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>
|
||||
</button>
|
||||
</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>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Thinking effort
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
@@ -5295,7 +5374,7 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<span
|
||||
class="truncate"
|
||||
>
|
||||
Inherit from settings
|
||||
Default (model decides)
|
||||
</span>
|
||||
<svg
|
||||
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>
|
||||
<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
|
||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||
/>
|
||||
<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=""
|
||||
class="border-t border-border px-4 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play 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"
|
||||
<div
|
||||
class="flex items-center gap-3 text-[11px] min-w-0"
|
||||
/>
|
||||
<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=""
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
<svg
|
||||
class="lucide lucide-play 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"
|
||||
>
|
||||
<polygon
|
||||
points="6 3 20 12 6 21 6 3"
|
||||
/>
|
||||
</svg>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user