feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
This commit is contained in:
@@ -169,7 +169,7 @@ function ChartTooltip({ x, y, children }: { x: number; y: number; children: Reac
|
||||
const nearRight = x > window.innerWidth - 200;
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-2 py-1.5 text-xs bg-[#12121f] border border-[#2a2a4a] rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
className="fixed z-50 px-2 py-1.5 text-xs bg-surface-2 border border-border rounded shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? x - 14 : x + 14,
|
||||
top: y - 10,
|
||||
|
||||
@@ -369,7 +369,7 @@ function PricingInfoTooltip() {
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top, width: 320 }}
|
||||
>
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
||||
|
||||
@@ -435,10 +435,10 @@ function ChartInfoPopover({ infoKey, title }: { infoKey: string; title: string }
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3.5 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
className="fixed z-50 p-3.5 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
|
||||
>
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-[#2a2a4a]">
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2.5 pb-2 border-b border-border">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
|
||||
+378
-237
@@ -43,7 +43,17 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Square, Columns2, Grid2x2, Pencil, Layers } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Square,
|
||||
Columns2,
|
||||
Grid2x2,
|
||||
Pencil,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
ChevronsLeftRight,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session, WSMessage, LaneTool } from "../lib/types";
|
||||
@@ -65,10 +75,36 @@ import LaneCard, {
|
||||
} from "../components/lanes/LaneCard";
|
||||
import LaneStripCard from "../components/lanes/LaneStripCard";
|
||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
import { readSplitViewState, writeSplitViewState } from "../lib/splitViewStorage";
|
||||
import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
||||
|
||||
/** localStorage key for the Changes panel's open/closed state. */
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
* narrow → 50/50 → 70/30 → closed, rather than a width button beside a
|
||||
* collapse button that both live in the panel's tightest corner. */
|
||||
const CHANGES_SIZES = ["compact", "half", "wide"] as const;
|
||||
type ChangesSize = (typeof CHANGES_SIZES)[number];
|
||||
const CHANGES_WIDTH: Record<ChangesSize, string> = {
|
||||
compact: "w-[28rem] xl:w-[34rem]",
|
||||
half: "w-1/2",
|
||||
wide: "w-[70%]",
|
||||
};
|
||||
/** The icon the one resize control shows AT each size: what the next click
|
||||
* gives you — an even split, a wider panel, then closed. */
|
||||
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
|
||||
compact: Columns2,
|
||||
half: ChevronsLeftRight,
|
||||
wide: PanelRightClose,
|
||||
};
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** One pane, two side by side, four in a grid — the conventional icons for
|
||||
@@ -206,6 +242,12 @@ export function Workspace() {
|
||||
const [selectedLaneId, setSelectedLaneId] = useState<number | null>(null);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
const [addLaneOpen, setAddLaneOpen] = useState(false);
|
||||
// Fields the Add-lane modal opens with when it was opened from a lane's git
|
||||
// bar ("new lane here"): one provisioning path for every lane, prefilled.
|
||||
const [addLanePrefill, setAddLanePrefill] = useState<{
|
||||
sourceRepo?: string;
|
||||
branch?: string;
|
||||
} | null>(null);
|
||||
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
|
||||
const [features, setFeatures] = useState<LaneFeature[]>([]);
|
||||
const [pipelineTemplates, setPipelineTemplates] = useState<
|
||||
@@ -239,6 +281,44 @@ export function Workspace() {
|
||||
// single-pane view and collapsed in split view, but the toggle below can
|
||||
// override that in either mode — the user decides, not the layout.
|
||||
const [infoExpanded, setInfoExpanded] = useState(() => splitView.layout === 1);
|
||||
// Whether the Changes panel is open. A viewer preference, so it lives in
|
||||
// localStorage and never reaches the server — same rule as the lane strip's
|
||||
// order (client/src/lib/laneOrderStorage.ts).
|
||||
const [changesOpen, setChangesOpen] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode / disabled storage: the panel just forgets between visits */
|
||||
}
|
||||
}, [changesOpen]);
|
||||
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
|
||||
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
|
||||
} catch {
|
||||
return "compact";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
|
||||
} catch {
|
||||
/* same as above: a forgotten width, not an error */
|
||||
}
|
||||
}, [changesSize]);
|
||||
const nextChangesSize =
|
||||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
|
||||
const changesNextLabel =
|
||||
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
const showInfo = infoExpanded;
|
||||
|
||||
const setLayout = useCallback((layout: SplitLayout) => {
|
||||
@@ -579,15 +659,19 @@ export function Workspace() {
|
||||
which reads as a half-rendered row rather than a state. One banner
|
||||
instead — and a compact one on purpose: the run setup form below
|
||||
is what the user actually needs the height for. */
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-empty"
|
||||
className="card flex shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
|
||||
<Layers className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-medium text-fg-primary">{tLanes("empty.title")}</h2>
|
||||
<span className="block text-sm font-medium text-fg-primary">
|
||||
{tLanes("empty.title")}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{tLanes("empty.body")}{" "}
|
||||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
||||
@@ -595,16 +679,11 @@ export function Workspace() {
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-tile-add"
|
||||
onClick={() => setAddLaneOpen(true)}
|
||||
className="btn-primary shrink-0"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Plus className="h-4 w-4" />
|
||||
{tLanes("add")}
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
data-testid="lane-strip"
|
||||
@@ -646,11 +725,17 @@ export function Workspace() {
|
||||
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" : ""}`}
|
||||
>
|
||||
{/* One header row, not two. The pipeline picker, the stage badges,
|
||||
{/* Detail and the Changes panel share one row: the panel is a reading
|
||||
surface for the same lane the console is driving, so it sits beside
|
||||
it rather than under it. It collapses to a rail instead of
|
||||
unmounting nothing — there is no live subscription inside it, so
|
||||
unmounting is safe and keeps a closed panel from polling. */}
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
|
||||
>
|
||||
{/* One header row, not two. The pipeline picker, the stage badges,
|
||||
the feature picker and the router select used to sit on a second
|
||||
row below this one; every one of them is a per-lane control that
|
||||
belongs beside the lane's own name, and stacking them cost a whole
|
||||
@@ -658,245 +743,297 @@ export function Workspace() {
|
||||
down. The pipeline's NAME is no longer interpolated into
|
||||
`laneHeader` either — the picker right next to it already says it.
|
||||
They stay gated on `showInfo`, same as before the merge. */}
|
||||
{currentLane && (
|
||||
<div className="mb-3 flex flex-wrap 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={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
||||
{currentLane.liveness === "dead"
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
{currentLane && (
|
||||
<div className="mb-3 flex flex-wrap 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={`h-2 w-2 rounded-full ${LIVENESS_DOT[currentLane.liveness]}`} />
|
||||
{currentLane.liveness === "dead"
|
||||
? tLanes("statusDead")
|
||||
: tLanes(`status.${currentLane.status}`, { defaultValue: currentLane.status })}
|
||||
</span>
|
||||
{/* The lane's name, and the one place it can be set. No "Lane 23 ·"
|
||||
prefix any more: the id is on the strip tile, where the CLI's
|
||||
`--lane` reader looks for it, and repeating it here only ate
|
||||
the width the name needed. Submitting an empty name clears
|
||||
`title`, which puts the cwd back in its place. */}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
{showInfo && (
|
||||
<>
|
||||
<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)}
|
||||
{renamingLaneId === currentLane.id ? (
|
||||
<input
|
||||
data-testid="lane-rename-input"
|
||||
aria-label={tLanes("renameLabel")}
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
// Blur CANCELS, it does not save — same convention as the
|
||||
// rename field in CcConfig. Committing here too would fire
|
||||
// the PATCH twice for one Enter: the commit unmounts the
|
||||
// focused input, and removing a focused element raises a
|
||||
// native blur before the refetch has moved `lanes`, so the
|
||||
// "unchanged title" guard would not catch the second call.
|
||||
onBlur={() => setRenamingLaneId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(currentLane.id);
|
||||
if (e.key === "Escape") setRenamingLaneId(null);
|
||||
}}
|
||||
maxLength={200}
|
||||
className="min-w-0 flex-1 rounded border border-accent bg-surface-1 px-2 py-0.5 text-sm font-semibold text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-rename"
|
||||
onClick={() => {
|
||||
setRenameDraft(currentLane.title || "");
|
||||
setRenamingLaneId(currentLane.id);
|
||||
}}
|
||||
title={`${currentLane.cwd}\n${tLanes("renameLabel")}`}
|
||||
className="group flex min-w-0 items-center gap-1 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{(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>
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* `stage` defaults to the DB sentinel "idle" until the driving
|
||||
session ever calls `agenthub 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 && (
|
||||
<span className="min-w-0 truncate">{currentLane.title || currentLane.cwd}</span>
|
||||
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||
{since(currentLane.stage_seconds)}
|
||||
</span>
|
||||
{showInfo && (
|
||||
<>
|
||||
<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)}
|
||||
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)}
|
||||
>
|
||||
<option value="">{tLanes("features.live")}</option>
|
||||
{features.map((f) => (
|
||||
<option key={f.slug} value={f.slug}>
|
||||
{f.slug}
|
||||
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
||||
{(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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
<LaneRouterRow lane={currentLane} />
|
||||
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||
PipelineMap right below this header already draws both
|
||||
— the declared node as the current ring, the detected
|
||||
one as its amber badge — so the chips were a second,
|
||||
smaller copy of the same two facts. */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Per-lane actions (agents install, mcp sync, sync-base check)
|
||||
belong beside the lane they act on, not in a page header. */}
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-detail-toggle"
|
||||
aria-expanded={infoExpanded}
|
||||
onClick={() => setInfoExpanded((v) => !v)}
|
||||
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||
>
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
{showInfo && (
|
||||
<LaneToolsMenu lane={currentLane} />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
data-testid="lane-detail-toggle"
|
||||
aria-expanded={infoExpanded}
|
||||
onClick={() => setInfoExpanded((v) => !v)}
|
||||
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||
>
|
||||
{/* One label for both kinds: removal forgets the lane's row
|
||||
and never deletes a worktree, a branch or a database. */}
|
||||
{tLanes("action.forget")}
|
||||
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<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
|
||||
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||
{showInfo && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-action-remove"
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{/* One label for both kinds: removal forgets the lane's row
|
||||
and never deletes a worktree, a branch or a database. */}
|
||||
{tLanes("action.forget")}
|
||||
</button>
|
||||
)}
|
||||
onSelectLane={setSelectedLaneId}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<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
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
onSelectLane={setSelectedLaneId}
|
||||
onNewLaneFromRepo={(repo, branch) => {
|
||||
setAddLanePrefill({ sourceRepo: repo, branch });
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
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={!currentLane}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<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={!currentLane}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{currentLane &&
|
||||
(changesOpen ? (
|
||||
<aside
|
||||
id="lane-changes-panel"
|
||||
data-testid="lane-changes-card"
|
||||
className={`card flex shrink-0 flex-col p-3 ${CHANGES_WIDTH[changesSize]}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-resize"
|
||||
onClick={() => {
|
||||
// Past the widest step the next state is "gone", which
|
||||
// is what the rail button reopens at `compact`.
|
||||
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
|
||||
setChangesOpen(false);
|
||||
setChangesSize("compact");
|
||||
} else {
|
||||
setChangesSize(nextChangesSize);
|
||||
}
|
||||
}}
|
||||
// The control names the same region the rail button does,
|
||||
// so a screen reader reads one panel that is open or
|
||||
// closed rather than two unrelated buttons.
|
||||
aria-expanded
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={changesNextLabel}
|
||||
title={changesNextLabel}
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
{/* Icon-only, and it draws the width the click LANDS on,
|
||||
not the current one — the label the tooltip carries. */}
|
||||
{(() => {
|
||||
const Next = CHANGES_NEXT_ICON[changesSize];
|
||||
return <Next className="h-4 w-4" />;
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<LaneChangesPanel laneId={currentLane.id} />
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-expand"
|
||||
onClick={() => setChangesOpen(true)}
|
||||
aria-expanded={false}
|
||||
aria-controls="lane-changes-panel"
|
||||
aria-label={tLanes("changes.expand")}
|
||||
title={tLanes("changes.expand")}
|
||||
className="card flex w-9 shrink-0 flex-col items-center gap-2 py-3 text-fg-muted transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
<span className="text-[10px] uppercase tracking-wide [writing-mode:vertical-rl]">
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentLane && removeConfirmOpen && (
|
||||
@@ -915,7 +1052,11 @@ export function Workspace() {
|
||||
<AddLaneModal
|
||||
open={addLaneOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
onClose={() => setAddLaneOpen(false)}
|
||||
prefill={addLanePrefill}
|
||||
onClose={() => {
|
||||
setAddLaneOpen(false);
|
||||
setAddLanePrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]));
|
||||
setSelectedLaneId(lane.id);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file Workspace.changesPanel.test.tsx
|
||||
* @description The Changes panel is ONE control: it steps narrow → 50/50 →
|
||||
* 70/30 → closed, and the rail reopens it narrow. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
|
||||
// fixtures it reads have to be lifted with it.
|
||||
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
|
||||
const laneStub = (id: number, cwd: string) => ({
|
||||
id,
|
||||
title: `lane-${id}`,
|
||||
cwd,
|
||||
branch: null,
|
||||
kind: "adopted",
|
||||
source_repo: null,
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: "idle",
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [],
|
||||
progress: 0,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
slot: null,
|
||||
ports: {},
|
||||
active_feature_id: null,
|
||||
});
|
||||
return {
|
||||
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
|
||||
LANE_A: laneStub(1, "/workspace/alpha"),
|
||||
LANE_B: laneStub(2, "/workspace/beta"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: {
|
||||
list: r({ items: [] }),
|
||||
history: r({ items: [] }),
|
||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: r({ items: [HOME] }),
|
||||
files: r({ items: [] }),
|
||||
start: r({ id: "run-1", status: "running" }),
|
||||
get: r({ id: "run-1", status: "running" }),
|
||||
},
|
||||
lanes: {
|
||||
list: r({
|
||||
lanes: [LANE_A, LANE_B],
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
},
|
||||
aiRouters: { list: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — the Changes panel's width", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
async function openPanel() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
// Open/closed is itself remembered, so the second mount of a test comes up
|
||||
// already open — clicking the rail then would close it.
|
||||
const rail = screen.queryByTestId("lane-changes-expand");
|
||||
if (rail) {
|
||||
fireEvent.click(rail);
|
||||
await settle();
|
||||
}
|
||||
}
|
||||
|
||||
it("steps narrow → 50/50 → 70/30 → closed on one control", async () => {
|
||||
await openPanel();
|
||||
const card = () => screen.getByTestId("lane-changes-card");
|
||||
expect(card().className).toContain("w-[28rem]");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-1/2");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-[70%]");
|
||||
|
||||
// Past the widest step the panel closes — and reopens narrow, not back at
|
||||
// 70/30, which is why closing resets the width.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(card().className).toContain("w-[28rem]");
|
||||
});
|
||||
|
||||
it("remembers the width across a reload, and never asks the server", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
|
||||
|
||||
cleanup();
|
||||
await openPanel();
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
|
||||
});
|
||||
|
||||
it("keeps the rail as the only way back in", async () => {
|
||||
await openPanel();
|
||||
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
|
||||
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5099,9 +5099,10 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="card flex shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4"
|
||||
<button
|
||||
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
||||
data-testid="lane-empty"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
|
||||
@@ -5132,15 +5133,15 @@ exports[`screen snapshots > Run 1`] = `
|
||||
<div
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<h2
|
||||
class="text-sm font-medium text-fg-primary"
|
||||
<span
|
||||
class="block text-sm font-medium text-fg-primary"
|
||||
>
|
||||
No lanes yet
|
||||
</h2>
|
||||
</span>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Add one here, or from a terminal:
|
||||
A lane is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
|
||||
|
||||
<code
|
||||
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
|
||||
@@ -5149,10 +5150,8 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary shrink-0"
|
||||
data-testid="lane-tile-add"
|
||||
type="button"
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus h-4 w-4"
|
||||
@@ -5174,229 +5173,319 @@ exports[`screen snapshots > Run 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
Add lane
|
||||
</button>
|
||||
</div>
|
||||
<section
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col "
|
||||
data-testid="lane-detail"
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 gap-4"
|
||||
>
|
||||
<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-label="1 pane"
|
||||
aria-pressed="true"
|
||||
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||
title="1 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-square h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="2 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="2 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="4 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="4 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<header
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-play w-3.5 h-3.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="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-base 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="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
>
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
</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-end gap-3 border-b border-border px-4 py-3"
|
||||
<button
|
||||
aria-label="1 pane"
|
||||
aria-pressed="true"
|
||||
class="rounded border p-1.5 border-accent bg-accent/15 text-accent"
|
||||
title="1 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-square h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="2 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="2 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-columns2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="4 pane"
|
||||
aria-pressed="false"
|
||||
class="rounded border p-1.5 border-border text-fg-secondary hover:border-border-light"
|
||||
title="4 pane"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-grid2x2 h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 3v18"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
/>
|
||||
<rect
|
||||
height="18"
|
||||
rx="2"
|
||||
width="18"
|
||||
x="3"
|
||||
y="3"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-5"
|
||||
data-testid="console-body"
|
||||
>
|
||||
<header
|
||||
class="flex items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
class="w-7 h-7 rounded-lg bg-accent/15 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
<svg
|
||||
class="lucide lucide-play w-3.5 h-3.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="flex min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<h1
|
||||
class="text-base font-semibold text-fg-primary"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
title="Absolute path. Defaults to the dashboard's own cwd."
|
||||
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="hidden min-w-0 truncate text-[11px] text-fg-muted sm:block"
|
||||
>
|
||||
Streamed output, multi-turn, same hooks and analytics as your terminal.
|
||||
</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-end gap-3 border-b border-border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="grid min-w-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
>
|
||||
Working directory
|
||||
</label>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
auto (recommended - asks only for risky actions)
|
||||
</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"
|
||||
@@ -5408,30 +5497,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"
|
||||
>
|
||||
@@ -5442,7 +5519,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"
|
||||
@@ -5464,155 +5541,81 @@ exports[`screen snapshots > Run 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-[10px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
||||
<div
|
||||
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
Permission mode
|
||||
</label>
|
||||
<div
|
||||
class="relative"
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
auto (recommended - asks only for risky actions)
|
||||
</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>
|
||||
Resume existing session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]"
|
||||
class="px-4 py-3 border-b border-border"
|
||||
>
|
||||
<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"
|
||||
<label
|
||||
class="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1.5"
|
||||
>
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Resume existing session
|
||||
Cmd+Enter / Ctrl+Enter to send
|
||||
· / for slash commands · @ for file references
|
||||
</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=""
|
||||
>
|
||||
<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
|
||||
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="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=""
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9614,7 +9617,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.7.0 (rebuild client to match)
|
||||
UI build v1.8.1 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user