Files
kandev/apps/web/components/session/prepare-progress.tsx
T
João Salavisa f09e938673 feat(i18n): localize the last batch of live surfaces before Office (#2357)
Migrates the eleven remaining files that `i18next/no-literal-string` flagged
outside `app/office`, `app/demo` and the task-create surfaces owned by #2326:
the session prepare panel, the onboarding agents step, the host system-metrics
readout, the system-health issues dialog, the shared panel search bar, the
mermaid diagram block, the settings agent card, both release-notes surfaces,
and the shared data-table pair.

Findings drop 884 -> 826 (-58) and the guard allowlist grows 442 -> 453, file
by file rather than by directory since every one of these directories still
holds un-migrated files.

Defects repaired beyond the codemod's output:

- `data-table-pagination.tsx` rendered `{count} results` as a bare JSX
  fragment, invisible to both plural gates. It is now one interpolated key
  with `_one`/`_other`, as are the health-dialog issue count and the
  release-notes "N new releases" helper.
- Two mixed literal+expression blocks were half-converted, rendering
  identically in English while being structurally untranslatable. Both were
  rebuilt as a single message.
- The codemod re-sorted `settings.json` into `localeCompare` order and
  re-encoded every non-ASCII character to `\uXXXX` under a comment claiming it
  matched the existing files. No catalog is stored escaped and only `common`
  is in that order, so both halves were reverted; all four catalogs are
  additions-only.
- Copy in default parameters and plain helpers is invisible to the lint rule,
  so `Search…`, the six prepare-panel header labels, the setup-script step name
  and error, the metric labels, `lastUpdatedText` and `buildDescription` were
  migrated by hand.

`task:preparingEnvironment` was already taken by #2326 holding "Preparing
environment" without the ellipsis this surface renders, so the prepare panel
uses `preparingEnvironmentEllipsis` rather than silently changing its copy.

Units, wire ids, the `{workspaceId}` URL token, the `"ellipsis"` page sentinel
and every mermaid theme/CSS value stay English as protocol.
2026-08-07 11:34:35 +02:00

585 lines
21 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import {
IconCheck,
IconX,
IconLoader2,
IconAlertTriangle,
IconTerminal2,
IconFolder,
IconGitBranch,
} from "@tabler/icons-react";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
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, isDebug } 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 max-w-full overflow-y-auto whitespace-pre-wrap break-words text-xs">
{stripAnsi(step.output)}
</pre>
)}
</div>
);
}
function StepWarning({ warning, warningDetail }: { warning: string; warningDetail?: string }) {
const { t } = useTranslation();
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)}
>
{t("task:details")}
</button>
{detailExpanded && (
<pre className="text-amber-500/60 mt-0.5 max-w-full whitespace-pre-wrap break-words 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 max-w-full whitespace-pre-wrap break-words text-xs">
{step.error}
</pre>
)}
</>
);
}
function StepRow({ step }: { step: PrepareStepInfo }) {
const { t } = useTranslation();
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 min-w-0 flex-wrap items-center gap-2">
<div className="flex-shrink-0">
<StepIcon status={step.status} hasWarning={Boolean(step.warning)} />
</div>
<span className={nameClass}>{step.name || t("task:preparingEllipsis")}</span>
{inlineCommand && (
<code className="text-muted-foreground/50 min-w-0 break-all 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(
t: TFunction,
status: EffectiveStatus,
prepareState: SessionPrepareState,
): string {
if (status === "preparing") return t("task:preparingEnvironmentEllipsis");
if (status === "failed") return t("task:environmentSetupFailed");
if (status === "completed_with_error") {
return t("task:environmentSetupFinishedWithErrors");
}
if (status === "completed_with_warnings") {
const warningSteps = prepareState.steps.filter((s) => s.warning);
if (warningSteps.length === 1 && isFallbackNoticeStep(warningSteps[0])) {
return t("task:environmentPreparedOnFreshSandbox");
}
return t("task:environmentPreparedWithWarnings");
}
return t("task:environmentPrepared");
}
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(t: TFunction, 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
? t("task:scriptExitedWithCode", { code: meta.exit_code })
: t("task:scriptFailed"))
: undefined;
return {
name: t("task:runRepositorySetupScript"),
command,
status,
output: message.content || undefined,
error: stepError,
startedAt: meta.started_at,
endedAt: meta.completed_at,
};
}
function useSetupScriptSteps(sessionId: string): PrepareStepInfo[] {
const { t } = useTranslation();
const messages = useAppStore((state) => state.messages.bySession[sessionId]);
if (!messages || messages.length === 0) return [];
return messages.filter(isSetupScriptMessage).map((m) => setupScriptMessageToStep(t, m));
}
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" />}>
<Trans i18nKey="task:isolatedWorktreeAt" values={{ path: worktree_path }}>
Isolated worktree at <Mono>{worktree_path}</Mono>
</Trans>
</InfoLine>
)}
{!isWorktree && worktree_path && (
<InfoLine icon={<IconFolder className="h-3 w-3" />}>
<Trans i18nKey="task:workingIn" values={{ path: worktree_path }}>
Working in <Mono>{worktree_path}</Mono>
</Trans>
</InfoLine>
)}
{worktree_branch && (
<InfoLine icon={<IconGitBranch className="h-3 w-3" />}>
{base_branch ? (
<Trans
i18nKey="task:branchBasedOn"
values={{ branch: worktree_branch, base: base_branch }}
>
Branch <Mono>{worktree_branch}</Mono>, based on <Mono>{base_branch}</Mono>
</Trans>
) : (
<Trans i18nKey="task:onBranch" values={{ branch: worktree_branch }}>
On branch <Mono>{worktree_branch}</Mono>
</Trans>
)}
</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,
});
}
function hasPrepareDetails(
visibleStepCount: number,
hasSessionInfo: boolean,
hasFailureDetails: boolean,
): boolean {
return visibleStepCount > 0 || hasSessionInfo || hasFailureDetails;
}
export function PrepareProgress({ sessionId }: PrepareProgressProps) {
const { t } = useTranslation();
const { status, prepareState } = usePrepareStatus(sessionId);
const session = useAppStore((state) => state.taskSessions.items[sessionId]);
const setupScriptSteps = useSetupScriptSteps(sessionId);
// Keep active progress and warnings visible. Failure diagnostics remain
// secondary until the user explicitly asks to inspect them.
const autoExpand = status === "preparing" || status === "completed_with_warnings";
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 an initial-scroll race.
const prevSnapshotRef = useRef<PrepareSnapshot | null>(null);
useEffect(() => {
if (!isDebug()) 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(t, status, prepareState);
const isErrorStatus = status === "failed" || status === "completed_with_error";
const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground");
const hasFailureDetails = isErrorStatus && Boolean(prepareState.errorMessage);
const hasExpandableContent = hasPrepareDetails(
visibleSteps.length,
hasSessionInfo,
hasFailureDetails,
);
return (
<div
data-testid="prepare-progress-panel"
data-status={status}
data-expanded={expanded}
className="min-w-0 max-w-full"
>
<ExpandableRow
icon={<HeaderIcon status={status} />}
header={
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span
data-testid="prepare-progress-toggle"
className={cn(headerClass, "min-w-0 break-words font-medium")}
>
{headerLabel}
</span>
{hasExpandableContent && (
<button
type="button"
aria-expanded={expanded}
aria-label={
expanded ? t("task:hidePreparationDetails") : t("task:showPreparationDetails")
}
className="min-h-11 cursor-pointer text-xs text-muted-foreground underline-offset-4 hover:underline sm:min-h-0"
onClick={(event) => {
event.stopPropagation();
setExpanded(!expanded);
}}
>
{expanded ? t("task:hideDetails") : t("task:showDetails")}
</button>
)}
</div>
}
hasExpandableContent={hasExpandableContent}
isExpanded={expanded}
onToggle={() => setExpanded(!expanded)}
>
{hasFailureDetails && (
<pre className="mb-2 max-h-48 max-w-full overflow-y-auto whitespace-pre-wrap break-words rounded bg-muted/40 p-2 text-xs text-muted-foreground">
{prepareState.errorMessage}
</pre>
)}
<div className="space-y-1">
{visibleSteps.map((step, i) => (
<StepRow key={i} step={step} />
))}
</div>
<SessionInfo sessionId={sessionId} />
</ExpandableRow>
</div>
);
}