b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
/**
|
|
* @file One lane's card: CI/PR facts, the "needs you"
|
|
* banner sourced from Claude Code's Notification hook, and (for managed
|
|
* lanes only) a ⋯ menu — also on a right-click — holding the rare `reset worktree` action behind
|
|
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
|
* it should have been working) is called out loudly — that is the failure
|
|
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
|
* NOT repeated here — this card only ever renders inside the Workspace
|
|
* lane-detail section, whose header above it already shows them. Neither is
|
|
* progress: the pipeline map directly below this card draws the same number
|
|
* as named, coloured nodes, so a bar and a percent beside it were a worse
|
|
* second telling of it. The working copy — branch, uncommitted counts, the
|
|
* git action bar — is not here either: it heads the Changes panel
|
|
* (`LaneGitHeader`), beside the diff it describes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
|
import { api } from "../../lib/api";
|
|
import { eventBus } from "../../lib/eventBus";
|
|
import { keepIfSame } from "../../lib/keepIfSame";
|
|
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
|
|
|
|
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
|
|
* git refresh because a stack dying is exactly what the user needs to see, and slower than
|
|
* the lane poll because each call opens a socket per declared port. */
|
|
const RUNTIME_REFRESH_MS = 10_000;
|
|
|
|
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
|
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
|
|
const LOCKS_REFRESH_MS = 30_000;
|
|
|
|
/**
|
|
* The lane's own application stack. Same shape and same silence as the git
|
|
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
|
|
* is the common case and not an error worth a banner.
|
|
*
|
|
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
|
* poll interval.
|
|
*/
|
|
export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
|
|
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const read = () => {
|
|
api.lanes
|
|
.runtime(laneId)
|
|
.then((r) => {
|
|
if (alive) setRuntime(keepIfSame(r));
|
|
})
|
|
.catch(() => {
|
|
if (alive) setRuntime(keepIfSame<LaneRuntime>({ available: false }));
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(
|
|
() => document.visibilityState === "visible" && read(),
|
|
RUNTIME_REFRESH_MS
|
|
);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneId, bump]);
|
|
|
|
return runtime;
|
|
}
|
|
|
|
/**
|
|
* Locks held by THIS lane, polled the same way runtime/git facts are.
|
|
*/
|
|
function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|
const [locks, setLocks] = useState<NamedLock[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!laneSlot) return;
|
|
let alive = true;
|
|
const holder = `lane${laneSlot}`;
|
|
const read = () => {
|
|
api.locks
|
|
.list()
|
|
.then((data) => {
|
|
if (alive) setLocks(keepIfSame(data.locks.filter((l) => l.holder === holder)));
|
|
})
|
|
.catch(() => {
|
|
/* fails silently, same contract as the git/runtime pollers */
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(
|
|
() => document.visibilityState === "visible" && read(),
|
|
LOCKS_REFRESH_MS
|
|
);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneSlot]);
|
|
|
|
return locks;
|
|
}
|
|
|
|
export function since(sec: number | null): string {
|
|
if (sec === null) return "—";
|
|
if (sec < 60) return `${sec}s`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
|
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
|
}
|
|
|
|
export default function LaneCard({
|
|
lane,
|
|
onAction,
|
|
childWorktrees,
|
|
onSelectLane,
|
|
}: {
|
|
lane: Lane;
|
|
onAction: (action: string, body?: Record<string, unknown>) => void;
|
|
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
|
|
* when this lane is itself a source repo (typically an adopted one) that
|
|
* other lanes were provisioned as worktrees from. */
|
|
childWorktrees?: Lane[];
|
|
/** Jumps the Workspace page's selection to another lane's card. */
|
|
onSelectLane?: (id: number) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
|
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
|
|
const [runtimeBump, setRuntimeBump] = useState(0);
|
|
const [bootLine, setBootLine] = useState<string | null>(null);
|
|
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
|
const locks = useLaneLocks(lane.slot);
|
|
|
|
/**
|
|
* Live boot feedback. A build can run for minutes, and a card showing only a
|
|
* disabled button through all of it reads as a hang. The hook's own output
|
|
* lines are the honest progress indicator.
|
|
*/
|
|
useEffect(
|
|
() =>
|
|
eventBus.subscribe((msg) => {
|
|
const data = msg.data as { laneId?: number; line?: string } | undefined;
|
|
if (!data || data.laneId !== lane.id) return;
|
|
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
|
|
setBootLine(data.line);
|
|
}
|
|
if (msg.type === "lane_runtime") {
|
|
setBootLine(null);
|
|
setRuntimeBump((n) => n + 1);
|
|
}
|
|
}),
|
|
[lane.id]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
data-testid={`lane-card-${lane.id}`}
|
|
// Right-click: the ⋯ menu's actions at the pointer (managed only).
|
|
onContextMenu={lane.kind === "managed" ? (e) => setMenuAt(menuPoint(e)) : undefined}
|
|
// Every row below is conditional: a lane with nothing to report
|
|
// renders no frame at all rather than an empty box.
|
|
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
|
>
|
|
{lane.ci_status && (
|
|
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
|
CI {lane.ci_status}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* No needs-you banner here: the strip card already flags it, and the
|
|
console directly below is where the prompt actually waits. */}
|
|
|
|
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
|
declare a profile. A port that drifted from `base + slot` is called
|
|
out — the number is otherwise predictable from the slot, and silently
|
|
serving on a different one is exactly the surprise worth flagging. */}
|
|
{runtime?.available && runtime.provisioned && (
|
|
<div
|
|
data-testid={`lane-runtime-${lane.id}`}
|
|
className="mb-2 rounded border border-border bg-surface-2/50 px-2 py-1 font-mono text-[11px]"
|
|
>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
|
|
<span
|
|
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
|
|
data-testid="lane-runtime-state"
|
|
>
|
|
{runtime.healthy
|
|
? t("runtime.healthy")
|
|
: runtime.up
|
|
? t("runtime.partial")
|
|
: t("runtime.down")}
|
|
</span>
|
|
</div>
|
|
{Object.entries(runtime.ports).map(([name, info]) => (
|
|
<div key={name} className="flex items-center gap-1.5 truncate">
|
|
<span
|
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
info.listening ? "bg-status-success" : "border border-fg-muted"
|
|
}`}
|
|
aria-hidden
|
|
/>
|
|
<span className="sr-only">
|
|
{info.listening ? t("runtime.listening") : t("runtime.notListening")}
|
|
</span>
|
|
<span className="text-fg-secondary">{name}</span>
|
|
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
|
{info.port !== null && info.port !== info.expected && (
|
|
<span
|
|
className="truncate text-status-warning/80"
|
|
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
|
|
>
|
|
⚠ {info.expected}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{/* While a boot is in flight the hook's own latest line IS the
|
|
progress bar — a build can take minutes, and a disabled button
|
|
with nothing moving reads as a hang. */}
|
|
{bootLine !== null && (
|
|
<div
|
|
data-testid="lane-runtime-bootline"
|
|
className="mt-1 truncate text-fg-muted"
|
|
title={bootLine}
|
|
>
|
|
{bootLine}
|
|
</div>
|
|
)}
|
|
{bootLine === null && runtime.lastError && (
|
|
<div
|
|
className="mt-1 truncate text-status-danger/90"
|
|
title={runtime.lastError.message}
|
|
>
|
|
{runtime.lastError.code || "error"}: {runtime.lastError.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{locks.length > 0 && (
|
|
<div
|
|
className="mb-2 flex items-center gap-1 text-xs text-status-warning"
|
|
data-testid={`lane-locks-${lane.id}`}
|
|
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
|
>
|
|
🔒 {t("locks.held", { count: locks.length })}
|
|
</div>
|
|
)}
|
|
|
|
{childWorktrees && childWorktrees.length > 0 && (
|
|
<div
|
|
data-testid="lane-child-worktrees"
|
|
className="mb-2 space-y-1 text-[11px] text-fg-secondary"
|
|
>
|
|
<div className="text-fg-muted">
|
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
|
</div>
|
|
<ul className="space-y-0.5">
|
|
{childWorktrees.map((w) => (
|
|
<li key={w.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectLane?.(w.id)}
|
|
className="truncate text-left text-accent hover:underline"
|
|
title={w.cwd}
|
|
>
|
|
#{w.id} {w.title || w.cwd} · {w.status}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* mt-auto pins the controls to the bottom so cards of differing height
|
|
in one grid row still line their buttons up. Repo tooling and the
|
|
remove button live in the Workspace page header/pipeline row now —
|
|
`reset` is the one destructive action left here, since it only
|
|
applies to managed lanes and is rare enough to stay behind ⋯. */}
|
|
{lane.kind === "managed" && (
|
|
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
|
|
{/* The shared menu shell: outside click, Escape and focus. */}
|
|
<button
|
|
type="button"
|
|
data-testid="lane-more"
|
|
aria-haspopup="menu"
|
|
aria-expanded={!!menuAt}
|
|
aria-label={t("moreActions")}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
|
|
}}
|
|
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
|
title={t("moreActions")}
|
|
>
|
|
⋯
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{menuAt && (
|
|
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
|
|
<MenuItem
|
|
danger
|
|
testId="lane-action-reset"
|
|
onSelect={() => {
|
|
setMenuAt(null);
|
|
setDestructiveAction("reset");
|
|
}}
|
|
>
|
|
{t("action.reset")}
|
|
</MenuItem>
|
|
</ContextMenu>
|
|
)}
|
|
{destructiveAction && (
|
|
<DestructiveLaneModal
|
|
lane={lane}
|
|
action={destructiveAction}
|
|
open
|
|
onClose={() => setDestructiveAction(null)}
|
|
onConfirm={(body) => {
|
|
setDestructiveAction(null);
|
|
onAction(destructiveAction, body);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|