Files
kandev/apps/web/components/session/prepare-progress.tsx
T

523 lines
18 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import {
IconCheck,
IconX,
IconLoader2,
IconAlertTriangle,
IconTerminal2,
IconFolder,
IconGitBranch,
} from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { ExpandableRow } from "@/components/task/chat/messages/expandable-row";
import { isFallbackNoticeStep } from "@/lib/prepare/summarize";
import { cn } from "@/lib/utils";
import { stripAnsi } from "@/lib/utils/ansi";
import { isSetupScriptMessage } from "@/hooks/use-processed-messages";
import type { Message } from "@/lib/types/http";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import type {
PrepareStepInfo,
SessionPrepareState,
} from "@/lib/state/slices/session-runtime/types";
const debug = createDebugLogger("chat:prepare-progress");
function formatStepDuration(startedAt?: string, endedAt?: string): string | null {
if (!startedAt || !endedAt) return null;
const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
if (ms < 0 || Number.isNaN(ms)) return null;
if (ms < 1000) return `${ms}ms`;
const secs = Math.round(ms / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const remSecs = secs % 60;
return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`;
}
type PrepareProgressProps = {
sessionId: string;
};
type EffectiveStatus =
| "preparing"
| "failed"
| "completed"
| "completed_with_error"
| "completed_with_warnings";
function StepIcon({ status, hasWarning }: { status: string; hasWarning?: boolean }) {
if (status === "completed" && hasWarning) {
return <IconAlertTriangle className="h-3.5 w-3.5 text-amber-500" />;
}
if (status === "completed") {
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
}
if (status === "failed") {
return <IconX className="h-3.5 w-3.5 text-destructive" />;
}
if (status === "running") {
return <IconLoader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />;
}
return <div className="h-3.5 w-3.5 rounded-full border border-muted-foreground/30" />;
}
/** True when the command is short enough to display inline next to the step name. */
function isInlineCommand(cmd: string): boolean {
return !cmd.includes("\n") && cmd.length <= 60;
}
function StepDetails({ step, blockCommand }: { step: PrepareStepInfo; blockCommand?: string }) {
return (
<div className="border-muted-foreground/20 mt-1 ml-0.5 border-l pl-3">
{blockCommand && (
<pre className="bg-muted/50 text-muted-foreground/60 mb-1 max-h-24 overflow-auto rounded px-2 py-1 font-mono text-[10px] whitespace-pre-wrap">
{blockCommand}
</pre>
)}
{step.output && (
<pre className="text-muted-foreground/60 max-h-48 overflow-auto whitespace-pre text-xs">
{stripAnsi(step.output)}
</pre>
)}
</div>
);
}
function StepWarning({ warning, warningDetail }: { warning: string; warningDetail?: string }) {
const [detailExpanded, setDetailExpanded] = useState(false);
return (
<div data-testid="prepare-warning-banner">
<span className="text-amber-500 mt-0.5 block text-xs">{warning}</span>
{warningDetail && (
<>
<button
type="button"
className="text-amber-500/70 hover:text-amber-500 mt-0.5 flex cursor-pointer items-center gap-0.5 text-xs"
onClick={() => setDetailExpanded(!detailExpanded)}
>
Details
</button>
{detailExpanded && (
<pre className="text-amber-500/60 mt-0.5 overflow-x-auto whitespace-pre text-xs">
{warningDetail}
</pre>
)}
</>
)}
</div>
);
}
function StepMessages({ step }: { step: PrepareStepInfo }) {
return (
<>
{step.warning && <StepWarning warning={step.warning} warningDetail={step.warningDetail} />}
{step.error && (
<pre className="text-destructive mt-0.5 overflow-x-auto whitespace-pre text-xs">
{step.error}
</pre>
)}
</>
);
}
function StepRow({ step }: { step: PrepareStepInfo }) {
const inlineCommand = step.command && isInlineCommand(step.command) ? step.command : undefined;
const blockCommand = step.command && !isInlineCommand(step.command) ? step.command : undefined;
const hasExpandable = Boolean(step.output) || Boolean(blockCommand);
const [detailsExpanded, setDetailsExpanded] = useState(
step.status === "running" || step.status === "failed",
);
const duration = formatStepDuration(step.startedAt, step.endedAt);
const nameClass = cn(
"text-muted-foreground",
step.status === "completed" && !step.warning && "line-through text-muted-foreground/60",
);
return (
<div className="text-xs">
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
<StepIcon status={step.status} hasWarning={Boolean(step.warning)} />
</div>
<span className={nameClass}>{step.name || "Preparing..."}</span>
{inlineCommand && (
<code className="text-muted-foreground/50 font-mono text-[10px]">{inlineCommand}</code>
)}
{duration && <span className="text-muted-foreground/40 text-[10px]">{duration}</span>}
{hasExpandable && (
<button
type="button"
className="text-muted-foreground/40 hover:text-muted-foreground/70 cursor-pointer text-[10px]"
onClick={() => setDetailsExpanded(!detailsExpanded)}
>
{detailsExpanded ? "[-]" : "[+]"}
</button>
)}
</div>
{/* Content below the step header, indented past the icon */}
<div className="ml-[22px]">
{detailsExpanded && <StepDetails step={step} blockCommand={blockCommand} />}
<StepMessages step={step} />
</div>
</div>
);
}
// deriveStatus maps the raw prepare state + ambient signals into the effective
// UI status. Kept small/pure so the hook stays a thin Zustand selector wrapper.
type DeriveStatusInput = {
prepareStatus: string;
sessionState: string | undefined;
agentctlStatus: string | undefined;
hasFailedStep: boolean;
hasWarnings: boolean;
hasRunningStep: boolean;
};
function deriveStatus(input: DeriveStatusInput): EffectiveStatus {
const {
prepareStatus,
sessionState,
agentctlStatus,
hasFailedStep,
hasWarnings,
hasRunningStep,
} = input;
if (prepareStatus === "failed") return "failed";
if (prepareStatus === "completed") {
if (hasFailedStep) return "completed_with_error";
if (hasWarnings) return "completed_with_warnings";
return "completed";
}
// A step is still running — stay in preparing regardless of agentctl status.
if (hasRunningStep) return "preparing";
// prepareStatus === "preparing" from here on.
// Agentctl ready implies preparation succeeded — treat as completed even if
// the completed event hasn't arrived yet.
if (agentctlStatus === "ready" && !hasFailedStep && !hasWarnings) return "completed";
// If the session reached a terminal state but prepare is still "preparing",
// treat it as failed — the completed event may have been lost.
const isSessionTerminal =
sessionState === "FAILED" || sessionState === "COMPLETED" || sessionState === "CANCELLED";
return isSessionTerminal ? "failed" : "preparing";
}
function usePrepareStatus(sessionId: string) {
const prepareState = useAppStore((state) => state.prepareProgress.bySessionId[sessionId] ?? null);
const sessionState = useAppStore((state) => state.taskSessions.items[sessionId]?.state);
const agentctlStatus = useAppStore(
(state) => state.sessionAgentctl.itemsBySessionId[sessionId]?.status,
);
if (!prepareState) {
// No live or hydrated prepare state. If the session has moved past the
// initial state, preparation must have completed — show a minimal panel
// so it doesn't vanish on refresh.
const sessionStarted =
sessionState && sessionState !== "CREATED" && sessionState !== "STARTING";
if (sessionStarted) {
return {
status: "completed" as EffectiveStatus,
prepareState: { sessionId, status: "completed", steps: [] } as SessionPrepareState,
};
}
return { status: "preparing" as const, prepareState: null };
}
const status = deriveStatus({
prepareStatus: prepareState.status,
sessionState,
agentctlStatus,
hasFailedStep: prepareState.steps.some((s) => s.status === "failed"),
hasWarnings: prepareState.steps.some((s) => s.warning),
hasRunningStep: prepareState.steps.some((s) => s.status === "running"),
});
return { status, prepareState };
}
// Tracks whether the panel is expanded. Starts from the status-derived default
// (`autoExpand`) and flips back to that default whenever the status transitions
// or the session changes — the "new context" rule. User's manual toggles stick
// within a single status window. Prev-value pattern (not useEffect) avoids a
// cascading re-render.
function useExpandedFlag(sessionId: string, autoExpand: boolean) {
const [expanded, setExpanded] = useState(autoExpand);
const [prevSessionId, setPrevSessionId] = useState(sessionId);
const [prevAutoExpand, setPrevAutoExpand] = useState(autoExpand);
if (sessionId !== prevSessionId) {
setPrevSessionId(sessionId);
setExpanded(autoExpand);
} else if (autoExpand !== prevAutoExpand) {
setPrevAutoExpand(autoExpand);
setExpanded(autoExpand);
}
return [expanded, setExpanded] as const;
}
function HeaderIcon({ status }: { status: EffectiveStatus }) {
if (status === "preparing") {
return (
<IconLoader2
data-testid="prepare-progress-header-spinner"
className="h-4 w-4 text-muted-foreground animate-spin"
/>
);
}
if (status === "failed" || status === "completed_with_error") {
return <IconX className="h-4 w-4 text-destructive" />;
}
if (status === "completed_with_warnings") {
return <IconAlertTriangle className="h-4 w-4 text-amber-500" />;
}
return <IconTerminal2 className="h-4 w-4 text-muted-foreground" />;
}
function getHeaderLabel(status: EffectiveStatus, prepareState: SessionPrepareState): string {
if (status === "preparing") return "Preparing environment...";
if (status === "failed") return prepareState.errorMessage || "Environment preparation failed";
if (status === "completed_with_error") {
const failed = prepareState.steps.find((s) => s.status === "failed");
// `||` (not `??`) so an empty-string error also falls through to the name fallback.
return failed?.error || `${failed?.name ?? "Environment prepared"} — step failed`;
}
if (status === "completed_with_warnings") {
const warningSteps = prepareState.steps.filter((s) => s.warning);
if (warningSteps.length === 1 && isFallbackNoticeStep(warningSteps[0])) {
return "Environment prepared on a fresh sandbox";
}
return "Environment prepared with warnings";
}
return "Environment prepared";
}
function InfoLine({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="text-muted-foreground/70 flex items-center gap-2 text-xs">
<div className="flex-shrink-0">{icon}</div>
<span>{children}</span>
</div>
);
}
function Mono({ children }: { children: React.ReactNode }) {
return <code className="text-muted-foreground font-mono text-[11px]">{children}</code>;
}
function hasStepDetails(step: PrepareStepInfo): boolean {
return Boolean(step.command || step.output || step.error || step.warning || step.warningDetail);
}
function isVisibleStep(step: PrepareStepInfo): boolean {
if (step.status === "skipped" && !hasStepDetails(step)) return false;
return step.name.trim() !== "" || hasStepDetails(step);
}
type ScriptStatus = "starting" | "running" | "exited" | "failed";
// Map the script_execution status vocabulary (starting/running/exited/failed)
// onto the PrepareStep status vocabulary (running/completed/failed).
function scriptStatusToStepStatus(
status: ScriptStatus | undefined,
exitCode: number | undefined,
): string {
if (status === "starting" || status === "running") return "running";
if (status === "failed") return "failed";
if (status === "exited") {
if (exitCode === undefined || exitCode === 0) return "completed";
return "failed";
}
return status ?? "running";
}
type ScriptMetadata = {
script_type?: string;
command?: string;
status?: ScriptStatus;
exit_code?: number;
error?: string;
started_at?: string;
completed_at?: string;
};
// Convert a setup-script `script_execution` message into the same step shape
// the existing prepare panel renders, so it slots in alongside Validate/Sync/
// Create-worktree without a parallel render path.
function setupScriptMessageToStep(message: Message): PrepareStepInfo {
const meta = (message.metadata ?? {}) as ScriptMetadata;
const command = meta.command ?? "";
const status = scriptStatusToStepStatus(meta.status, meta.exit_code);
const stepError =
status === "failed"
? meta.error ||
(meta.exit_code !== undefined && meta.exit_code !== 0
? `Script exited with code ${meta.exit_code}`
: "Script failed")
: undefined;
return {
name: "Run repository setup script",
command,
status,
output: message.content || undefined,
error: stepError,
startedAt: meta.started_at,
endedAt: meta.completed_at,
};
}
function useSetupScriptSteps(sessionId: string): PrepareStepInfo[] {
const messages = useAppStore((state) => state.messages.bySession[sessionId]);
if (!messages || messages.length === 0) return [];
return messages.filter(isSetupScriptMessage).map(setupScriptMessageToStep);
}
function SessionInfo({ sessionId }: { sessionId: string }) {
const session = useAppStore((state) => state.taskSessions.items[sessionId]);
if (!session) return null;
const { worktree_id, worktree_path, worktree_branch, base_branch } = session;
const isWorktree = Boolean(worktree_id);
// Nothing useful to show for sessions without workspace info.
if (!worktree_path && !worktree_branch) return null;
return (
<div className="border-muted mt-2 space-y-1 border-t pt-2">
{isWorktree && worktree_path && (
<InfoLine icon={<IconFolder className="h-3 w-3" />}>
Isolated worktree at <Mono>{worktree_path}</Mono>
</InfoLine>
)}
{!isWorktree && worktree_path && (
<InfoLine icon={<IconFolder className="h-3 w-3" />}>
Working in <Mono>{worktree_path}</Mono>
</InfoLine>
)}
{worktree_branch && (
<InfoLine icon={<IconGitBranch className="h-3 w-3" />}>
{base_branch ? (
<>
Branch <Mono>{worktree_branch}</Mono>, based on <Mono>{base_branch}</Mono>
</>
) : (
<>
On branch <Mono>{worktree_branch}</Mono>
</>
)}
</InfoLine>
)}
</div>
);
}
type PrepareSnapshot = {
status: string;
autoExpand: boolean;
expanded: boolean;
visibleSteps: number;
hasPrepareState: boolean;
};
function prepareSnapshotChanged(prev: PrepareSnapshot | null, next: PrepareSnapshot): boolean {
if (!prev) return true;
return (
prev.status !== next.status ||
prev.autoExpand !== next.autoExpand ||
prev.expanded !== next.expanded ||
prev.visibleSteps !== next.visibleSteps ||
prev.hasPrepareState !== next.hasPrepareState
);
}
function logPrepareSnapshotChange(
prev: PrepareSnapshot | null,
next: PrepareSnapshot,
sessionId: string,
rawPrepareStatus: string,
sessionState: string,
) {
debug(prev ? "transition" : "init", {
sessionId,
...next,
prevStatus: prev?.status ?? "-",
prevAutoExpand: prev?.autoExpand ?? null,
prevExpanded: prev?.expanded ?? null,
prevVisibleSteps: prev?.visibleSteps ?? -1,
rawPrepareStatus,
sessionState,
});
}
export function PrepareProgress({ sessionId }: PrepareProgressProps) {
const { status, prepareState } = usePrepareStatus(sessionId);
const session = useAppStore((state) => state.taskSessions.items[sessionId]);
const setupScriptSteps = useSetupScriptSteps(sessionId);
// Only the clean-success status auto-collapses. Anything with running/error/
// warning context stays open by default so the user sees what's happening.
const autoExpand = status !== "completed";
const [expanded, setExpanded] = useExpandedFlag(sessionId, autoExpand);
// Track status/expand transitions over the panel's lifetime — the
// remote-executor bug suspects the panel staying expanded (tall) while the
// env is still "preparing" combined with a Virtuoso initial-scroll race.
const prevSnapshotRef = useRef<PrepareSnapshot | null>(null);
useEffect(() => {
if (!IS_DEBUG) return;
const snapshot: PrepareSnapshot = {
status,
autoExpand,
expanded,
visibleSteps: prepareState ? prepareState.steps.filter(isVisibleStep).length : 0,
hasPrepareState: Boolean(prepareState),
};
const prev = prevSnapshotRef.current;
if (!prepareSnapshotChanged(prev, snapshot)) return;
logPrepareSnapshotChange(
prev,
snapshot,
sessionId,
prepareState?.status ?? "-",
session?.state ?? "-",
);
prevSnapshotRef.current = snapshot;
}, [sessionId, status, autoExpand, expanded, prepareState, session?.state]);
if (!prepareState) return null;
const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch);
// Per-repo setup scripts run inside worktree.Manager.Create, so visually they
// belong after Create-worktree finishes. Appending keeps the existing prepare
// step order intact.
const visibleSteps = [...prepareState.steps.filter(isVisibleStep), ...setupScriptSteps];
const headerLabel = getHeaderLabel(status, prepareState);
const isErrorStatus = status === "failed" || status === "completed_with_error";
const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground");
return (
<div data-testid="prepare-progress-panel" data-status={status} data-expanded={expanded}>
<ExpandableRow
icon={<HeaderIcon status={status} />}
header={
<span data-testid="prepare-progress-toggle" className={headerClass}>
{headerLabel}
</span>
}
hasExpandableContent={visibleSteps.length > 0 || hasSessionInfo}
isExpanded={expanded}
onToggle={() => setExpanded(!expanded)}
>
<div className="space-y-1">
{visibleSteps.map((step, i) => (
<StepRow key={i} step={step} />
))}
</div>
<SessionInfo sessionId={sessionId} />
</ExpandableRow>
</div>
);
}