import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { Channel } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type {
AppConfig,
ChoiceDto,
ConfigEntry,
PlanEvent,
Session,
SessionPhase,
SkippableStepDto,
WorkflowEvent,
WorkspaceMode,
} from "./types";
import {
approveSession,
askSession,
cancelSession,
clearNewSessionDraft,
createDraftSession,
createSession,
deleteSession,
fixSession,
generatePlanForDraft,
getAppConfig,
getNewSessionConfigDefaults,
getNewSessionDraft,
getNewSessionHistorySummary,
getSession,
getSessionLog,
getSessionPlan,
listConfigs,
resetSession,
respondToOption,
runAllSessions,
runSession,
saveNewSessionDraft,
updateAppConfig,
} from "./lib/commands";
import { notifyDesktop } from "./lib/desktopNotifications";
import { AskUserPanel } from "./components/AskUserPanel";
import { DirectoryPicker } from "./components/DirectoryPicker";
import { ImageAttachments } from "./components/ImageAttachments";
import { RepoPicker } from "./components/RepoPicker";
import { isValidRepoSpec } from "./lib/repoSpec";
import { MarkdownViewer } from "./components/MarkdownViewer";
import { PhaseBadge } from "./components/PhaseBadge";
import { SessionConfigEditor } from "./components/SessionConfigEditor";
import { ConfirmDialog } from "./components/ConfirmDialog";
import { SessionSidebar } from "./components/SessionSidebar";
import { WorkflowDagPanel } from "./components/WorkflowDagPanel";
import { Spinner } from "./components/Spinner";
import { getSessionActions, isApprovalReady, type RunStatus } from "./lib/sessionActions";
import { collectExpandedStepIds } from "./lib/stepUtils";
import { useSplitPane } from "./lib/useSplitPane";
import {
formatLocalTime,
workflowEventLogLine,
runAllCompletedLogLine,
runAllSessionStartedLogLine,
runAllStartedLogLine,
runAllStepLogLine,
runAllWorkflowEventLogLine,
PHASE_ICON,
} from "./lib/format";
// --- OptionDialog ----------------------------------------------------------------
interface OptionDialogProps {
choices: ChoiceDto[];
plan?: string;
onRespond: (result: { nextStep?: string; textInput?: string }) => void;
}
function OptionDialog({ choices, plan, onRespond }: OptionDialogProps) {
const [textValues, setTextValues] = useState<Record<string, string>>({});
const titleId = useId();
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-xl border border-gray-300 dark:border-gray-700 p-6 max-w-lg w-full space-y-4"
>
<h2 id={titleId} className="text-lg font-semibold text-gray-900 dark:text-gray-100">Choose an option</h2>
{plan && (
<div className="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded overflow-auto max-h-48">
<MarkdownViewer content={plan} className="p-3" />
</div>
)}
<div className="space-y-2">
{choices.map((choice) =>
choice.kind === "selector" ? (
<button
key={choice.label}
type="button"
onClick={() => onRespond({ nextStep: choice.next })}
className="w-full text-left px-4 py-2 border border-gray-300 dark:border-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-800 text-sm text-gray-800 dark:text-gray-200 transition-colors"
>
{choice.label}
</button>
) : (
<div key={choice.label} className="space-y-1">
<label htmlFor={`text-input-${choice.label}`} className="text-sm text-gray-500 dark:text-gray-400">{choice.label}</label>
<div className="flex gap-2">
<input
id={`text-input-${choice.label}`}
type="text"
value={textValues[choice.label] ?? ""}
onChange={(e) =>
setTextValues((prev) => ({
...prev,
[choice.label]: e.target.value,
}))
}
className="flex-1 border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 rounded px-3 py-1.5 text-sm text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 outline-none focus:border-blue-500"
placeholder="Type here..."
onKeyDown={(e) => {
if (e.key === "Enter")
onRespond({
nextStep: choice.next,
textInput: textValues[choice.label] ?? "",
});
}}
/>
<button
type="button"
onClick={() =>
onRespond({
nextStep: choice.next,
textInput: textValues[choice.label] ?? "",
})
}
className="px-3 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Submit
</button>
</div>
</div>
)
)}
</div>
</div>
</div>
);
}
// --- WorkflowToastStack ------------------------------------------------------------
type ToastKind = "input-required" | "completed" | "failed" | "plan-ready";
export interface WorkflowToast {
id: number;
kind: ToastKind;
sessionInput: string;
detail?: string;
}
const TOAST_STYLE: Record<ToastKind, string> = {
"input-required": "border-amber-300 dark:border-amber-700 bg-amber-100/80 dark:bg-amber-900/80 text-amber-900 dark:text-amber-100",
"completed": "border-green-300 dark:border-green-700 bg-green-100/80 dark:bg-green-900/80 text-green-900 dark:text-green-100",
"failed": "border-red-300 dark:border-red-700 bg-red-100/80 dark:bg-red-900/80 text-red-900 dark:text-red-100",
"plan-ready": "border-blue-300 dark:border-blue-700 bg-blue-100/80 dark:bg-blue-900/80 text-blue-900 dark:text-blue-100",
};
const TOAST_LABEL: Record<ToastKind, string> = {
"input-required": "Action required",
"completed": "Completed",
"failed": "Failed",
"plan-ready": "Plan ready",
};
const TOAST_DURATION_MS: Record<ToastKind, number> = {
"input-required": 10_000,
"completed": 5_000,
"failed": 5_000,
"plan-ready": 8_000,
};
export function WorkflowToastStack({
toasts,
onDismiss,
}: {
toasts: WorkflowToast[];
onDismiss: (id: number) => void;
}) {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
className={`flex items-start gap-3 px-4 py-3 rounded-lg border shadow-xl text-sm pointer-events-auto ${TOAST_STYLE[t.kind]}`}
>
<div className="flex-1 min-w-0">
<div className="font-medium">{TOAST_LABEL[t.kind]}</div>
<div className="text-xs opacity-75 truncate mt-0.5">{t.sessionInput}</div>
{t.detail !== undefined && (
<div data-testid="toast-detail" className="text-xs opacity-60 truncate mt-0.5">{t.detail}</div>
)}
</div>
<button
type="button"
aria-label="Dismiss"
onClick={() => onDismiss(t.id)}
className="opacity-60 hover:opacity-100 flex-shrink-0 text-xs mt-0.5"
>
x
</button>
</div>
))}
</div>
);
}
// --- SettingsModal ----------------------------------------------------------------
interface SettingsModalProps {
initialParallelism: number;
onSave: (config: AppConfig) => Promise<void>;
onClose: () => void;
}
function SettingsModal({ initialParallelism, onSave, onClose }: SettingsModalProps) {
const [value, setValue] = useState<number>(initialParallelism);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const titleId = useId();
async function handleSave() {
if (Number.isNaN(value) || value <= 0) {
setError("Must be at least 1");
return;
}
setSaving(true);
setError("");
try {
await onSave({ runAllParallelism: value });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-xl border border-gray-300 dark:border-gray-700 p-6 max-w-sm w-full space-y-4"
>
<h2 id={titleId} className="text-lg font-semibold text-gray-900 dark:text-gray-100">Settings</h2>
<div className="space-y-1.5">
<label htmlFor="run-all-parallelism" className="text-sm text-gray-500 dark:text-gray-400">
Run All Parallelism
</label>
<input
id="run-all-parallelism"
type="number"
min={1}
value={Number.isNaN(value) ? "" : value}
onChange={(e) => setValue(e.target.valueAsNumber)}
className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-3 py-1.5 text-sm text-gray-800 dark:text-gray-200 outline-none focus:border-blue-500"
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
disabled={saving}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
);
}
// --- AskEditor --------------------------------------------------------------------
interface AskEditorProps {
question: string;
onQuestionChange: (value: string) => void;
phase: "idle" | "editing" | "submitting";
error: string;
onSubmit: () => void;
onCancel: () => void;
className?: string;
}
function AskEditor({ question, onQuestionChange, phase, error, onSubmit, onCancel, className = "space-y-2" }: AskEditorProps) {
return (
<div className={className}>
<textarea
value={question}
onChange={(e) => onQuestionChange(e.target.value)}
rows={3}
autoFocus
placeholder="Ask a question about the plan..."
className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 focus:border-blue-500 outline-none resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void onSubmit();
}}
/>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void onSubmit()}
disabled={phase === "submitting" || !question.trim()}
className="px-4 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{phase === "submitting" ? "Asking..." : "Submit"}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-1.5 border border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Cancel
</button>
</div>
</div>
);
}
// --- Workflow panel sub-components (shared across layout variants) ----------------
interface WorkflowInfoPanelProps {
session: Session;
panelInfoId: string;
tabInfoId: string;
className?: string;
}
function WorkflowInfoPanel({ session, panelInfoId, tabInfoId, className = "" }: WorkflowInfoPanelProps) {
return (
<div id={panelInfoId} role="tabpanel" aria-labelledby={tabInfoId} className={`p-6 space-y-3 text-sm text-gray-500 dark:text-gray-400 ${className}`}>
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">Config</span>
<p className="font-mono text-gray-700 dark:text-gray-300 mt-0.5">{session.configSource}</p>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">
{session.repo ? "Repository" : "Base dir"}
</span>
<p className="font-mono text-gray-700 dark:text-gray-300 mt-0.5">{session.repo ?? session.baseDir}</p>
</div>
{session.worktreeBranch && (
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">Branch</span>
<p className="font-mono text-gray-700 dark:text-gray-300 mt-0.5">{session.worktreeBranch}</p>
</div>
)}
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">Created</span>
<p className="text-gray-700 dark:text-gray-300 mt-0.5">{formatLocalTime(session.createdAt)}</p>
</div>
{session.completedAt && (
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">Completed</span>
<p className="text-gray-700 dark:text-gray-300 mt-0.5">{formatLocalTime(session.completedAt)}</p>
</div>
)}
{session.phaseError && (
<div>
<span className="text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">Error</span>
<p className="text-red-600 dark:text-red-400 mt-0.5 font-mono text-xs">{session.phaseError}</p>
</div>
)}
</div>
);
}
interface WorkflowPlanPanelProps {
panelPlanId: string;
tabPlanId: string;
askResponse: string;
planLoading: boolean;
planContent: string;
className?: string;
}
function WorkflowPlanPanel({ panelPlanId, tabPlanId, askResponse, planLoading, planContent, className = "" }: WorkflowPlanPanelProps) {
return (
<div id={panelPlanId} role="tabpanel" aria-labelledby={tabPlanId} className={className}>
{askResponse && (
<div className="border-b border-gray-200 dark:border-gray-800 px-6 py-4 bg-gray-50/50 dark:bg-gray-900/50">
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Answer</div>
<MarkdownViewer content={askResponse} className="" />
</div>
)}
{planLoading ? (
<p className="p-4 text-xs text-gray-500 dark:text-gray-400">Loading plan...</p>
) : planContent ? (
<MarkdownViewer content={planContent} className="p-6" />
) : (
<p className="p-4 text-xs text-gray-500 dark:text-gray-400">No plan available.</p>
)}
</div>
);
}
export interface WorkflowLogPanelProps {
panelLogId: string;
tabLogId: string;
status: RunStatus;
logContent: string;
logEndRef: React.RefObject<HTMLSpanElement | null>;
preRef: React.RefObject<HTMLPreElement | null>;
onScroll: () => void;
className?: string;
}
export function WorkflowLogPanel({ panelLogId, tabLogId, status, logContent, logEndRef, preRef, onScroll, className = "" }: WorkflowLogPanelProps) {
return (
<div id={panelLogId} role="tabpanel" aria-labelledby={tabLogId} className={`h-full flex flex-col ${className}`}>
{logContent ? (
<pre ref={preRef} onScroll={onScroll} className="flex-1 text-xs font-mono bg-white dark:bg-gray-950 text-gray-700 dark:text-gray-300 p-4 overflow-auto whitespace-pre-wrap leading-relaxed">
{logContent}
<span ref={logEndRef} />
</pre>
) : (
<p className="p-4 text-xs text-gray-500 dark:text-gray-400">
{status === "idle" ? "Generate a plan or run the session to see logs here." : "No log entries yet."}
</p>
)}
</div>
);
}
// --- WorkflowRunner ---------------------------------------------------------------
interface WorkflowRunnerProps {
session: Session;
activeTab: ActiveTab;
onActiveTabChange: (tab: ActiveTab) => void;
onSessionUpdated: (session: Session) => void;
onDeleteConfirmed: (sessionId: string) => void;
onToast: (toast: Omit<WorkflowToast, "id">) => void;
onFixingChange: (sessionId: string, fixing: boolean) => void;
}
interface PendingOption {
requestId: string;
choices: ChoiceDto[];
plan?: string;
}
type ActiveTab = "info" | "dag" | "plan" | "log";
export function WorkflowRunner({ session, activeTab, onActiveTabChange, onSessionUpdated, onDeleteConfirmed, onToast, onFixingChange }: WorkflowRunnerProps) {
const uid = useId();
const tabInfoId = `${uid}-tab-info`;
const tabDagId = `${uid}-tab-dag`;
const tabPlanId = `${uid}-tab-plan`;
const tabLogId = `${uid}-tab-log`;
const panelInfoId = `${uid}-panel-info`;
const panelDagId = `${uid}-panel-dag`;
const panelPlanId = `${uid}-panel-plan`;
const panelLogId = `${uid}-panel-log`;
const [status, setStatus] = useState<RunStatus>("idle");
const [currentStep, setCurrentStep] = useState<string | null>(null);
const [liveLog, setLiveLog] = useState<string[]>([]);
const [savedLog, setSavedLog] = useState<string>("");
const [planContent, setPlanContent] = useState<string>("");
const [planLoading, setPlanLoading] = useState(false);
const [pendingOption, setPendingOption] = useState<PendingOption | null>(null);
const [replanFeedback, setReplanFeedback] = useState("");
const [replanPhase, setReplanPhase] = useState<"idle" | "editing" | "generating">("idle");
const [planProgress, setPlanProgress] = useState<string[]>([]);
const [replanError, setReplanError] = useState("");
const [askQuestion, setAskQuestion] = useState("");
const [askPhase, setAskPhase] = useState<"idle" | "editing" | "submitting">("idle");
const [askResponse, setAskResponse] = useState("");
const [askError, setAskError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isConfigRegenerating, setIsConfigRegenerating] = useState(false);
const logEndRef = useRef<HTMLSpanElement | null>(null);
const preRef = useRef<HTMLPreElement | null>(null);
const stickToBottomRef = useRef(true);
const loadSavedLog = useCallback(async () => {
try {
const content = await getSessionLog(session.id);
setSavedLog(content);
} catch (e) {
setSavedLog(`(failed to load log: ${e})`);
}
}, [session.id]);
const handleLogScroll = useCallback(() => {
const el = preRef.current;
if (!el) return;
stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}, []);
const loadPlan = useCallback(async () => {
setPlanLoading(true);
try {
const content = await getSessionPlan(session.id);
setPlanContent(content);
} catch (e) {
setPlanContent(`(failed to load plan: ${e})`);
} finally {
setPlanLoading(false);
}
}, [session.id]);
// Reset transient state when the selected session changes.
// activeTab is intentionally NOT reset here -- it is owned by App and persists
// per session across navigation. Lazy-load is triggered by the effect below.
useEffect(() => {
setStatus("idle");
setCurrentStep(null);
setLiveLog([]);
setSavedLog("");
stickToBottomRef.current = true;
setPlanContent("");
setPendingOption(null);
setReplanFeedback("");
setReplanPhase("idle");
setReplanError("");
setPlanProgress([]);
setAskQuestion("");
setAskPhase("idle");
setAskResponse("");
setAskError("");
setShowDeleteConfirm(false);
setIsConfigRegenerating(false);
}, [session.id]);
// Clear the ephemeral fixingSessionIds entry from App when this runner unmounts.
// The sidebar's "Fixing" badge now persists correctly via session.fixInProgress
// (DTO field), so this only keeps the parent's in-memory set tidy.
useEffect(() => {
return () => {
onFixingChange(session.id, false);
};
}, [session.id, onFixingChange]);
useEffect(() => {
if (activeTab !== "log" || status === "running") return;
void loadSavedLog();
const id = setInterval(() => {
void loadSavedLog();
}, 1000);
return () => clearInterval(id);
}, [activeTab, loadSavedLog, status]);
useEffect(() => {
if (!planContent && session.planAvailable) {
if (activeTab === "plan" || session.phase === "Awaiting Approval") {
void loadPlan();
}
}
}, [activeTab, loadPlan, planContent, session.planAvailable, session.phase]);
useEffect(() => {
if ((!savedLog && liveLog.length === 0) || !stickToBottomRef.current) return;
logEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [savedLog, liveLog]);
async function refreshSession() {
const updated = await getSession(session.id);
onSessionUpdated(updated);
}
async function handleApproveSession() {
try {
await approveSession(session.id);
await refreshSession();
} catch (e) {
onToast({ kind: "failed", sessionInput: session.input, detail: `Approve error: ${e}` });
}
}
async function startRun(workspaceMode: WorkspaceMode) {
setStatus("running");
setCurrentStep(null);
setLiveLog([]);
onActiveTabChange("log");
const channel = new Channel<WorkflowEvent>();
channel.onmessage = (event) => {
if (event.event === "stepStarted") {
setCurrentStep(event.data.step);
setLiveLog((prev) => [...prev, event.data.step]);
} else if (event.event === "logChunk") {
setLiveLog((prev) => [...prev, event.data.line]);
} else if (event.event === "optionRequired") {
setPendingOption({
requestId: event.data.requestId,
choices: event.data.choices,
plan: event.data.plan,
});
onToast({ kind: "input-required", sessionInput: session.input });
} else if (event.event === "workflowCompleted") {
setStatus("completed");
setLiveLog((prev) => [...prev, workflowEventLogLine(event)]);
} else if (event.event === "workflowFailed") {
setStatus("failed");
setLiveLog((prev) => [...prev, workflowEventLogLine(event)]);
} else if (event.event === "workflowCancelled") {
setStatus("cancelled");
setLiveLog((prev) => [...prev, workflowEventLogLine(event)]);
}
};
try {
await runSession(session.id, workspaceMode, channel);
} catch (e) {
setStatus("failed");
setLiveLog((prev) => [...prev, `Error: ${e}`]);
}
// Re-fetch session state after run resolves. The log tab effect reloads
// persisted log content once the run leaves the "running" state.
refreshSession().catch((e) => {
setLiveLog((prev) => [...prev, `Session refresh error: ${e}`]);
});
}
async function handleCancel() {
try {
await cancelSession();
} catch (e) {
setLiveLog((prev) => [...prev, `Cancel error: ${e}`]);
}
}
async function handleReset() {
try {
const updated = await resetSession(session.id);
onSessionUpdated(updated);
setStatus("idle");
setCurrentStep(null);
setLiveLog([]);
} catch (e) {
setLiveLog((prev) => [...prev, `Reset error: ${e}`]);
}
}
function handleDelete() {
setShowDeleteConfirm(false);
onDeleteConfirmed(session.id);
}
async function handleOptionRespond(result: {
nextStep?: string;
textInput?: string;
}) {
const sessionId = pendingOption?.requestId;
setPendingOption(null);
if (!sessionId) return;
try {
await respondToOption(sessionId, result);
// Re-sync after awaiting_input = false is saved
await refreshSession();
} catch (e) {
setLiveLog((prev) => [...prev, `Option response error: ${e}`]);
}
}
async function handleReplan() {
const trimmed = replanFeedback.trim();
if (!trimmed) return;
setReplanPhase("generating");
setPlanProgress([]);
onFixingChange(session.id, true);
setReplanError("");
const channel = new Channel<PlanEvent>();
channel.onmessage = (event) => {
if (event.event === "planGenerating") {
void refreshSession();
} else if (event.event === "planChunk") {
setPlanProgress((prev) => [...prev, event.data.line]);
} else if (event.event === "askUserRequired") {
void refreshSession();
} else if (event.event === "planGenerated") {
setPlanContent(event.data.content);
setReplanPhase("idle");
setPlanProgress([]);
onFixingChange(session.id, false);
setReplanFeedback("");
setAskResponse("");
onActiveTabChange("plan");
void refreshSession();
} else if (event.event === "planFailed") {
setReplanError(event.data.error);
setReplanPhase("editing");
setPlanProgress([]);
onFixingChange(session.id, false);
}
};
try {
await fixSession({ sessionId: session.id, feedback: trimmed }, channel);
} catch (e) {
setReplanError(String(e));
setReplanPhase("editing");
onFixingChange(session.id, false);
}
}
async function handleGeneratePlan() {
setReplanPhase("generating");
setPlanProgress([]);
onFixingChange(session.id, true);
let handledFailure = false;
const channel = new Channel<PlanEvent>();
channel.onmessage = async (event) => {
if (event.event === "planGenerating") {
void refreshSession();
} else if (event.event === "planChunk") {
setPlanProgress((prev) => [...prev, event.data.line]);
} else if (event.event === "askUserRequired") {
void refreshSession();
} else if (event.event === "planGenerated") {
setPlanContent(event.data.content);
setReplanPhase("idle");
setPlanProgress([]);
onFixingChange(session.id, false);
// Await the session refresh so that session.phase is AwaitingApproval
// before switching to the plan tab; otherwise the Approve button is
// momentarily absent because the phase is still Draft.
await refreshSession();
onActiveTabChange("plan");
} else if (event.event === "planFailed") {
handledFailure = true;
setReplanPhase("idle");
setPlanProgress([]);
onFixingChange(session.id, false);
onToast({ kind: "failed", sessionInput: session.input, detail: event.data.error });
void refreshSession();
}
};
try {
await generatePlanForDraft(session.id, channel);
} catch (e) {
if (!handledFailure) {
setReplanPhase("idle");
onFixingChange(session.id, false);
onToast({ kind: "failed", sessionInput: session.input, detail: `Plan generation error: ${String(e)}` });
}
}
}
async function handleAsk() {
const trimmed = askQuestion.trim();
if (!trimmed) return;
setAskPhase("submitting");
setAskError("");
try {
const answer = await askSession(session.id, trimmed);
setAskResponse(answer);
setAskPhase("idle");
onActiveTabChange("plan");
} catch (e) {
setAskError(String(e));
setAskPhase("editing");
}
}
// isFixing drives the PhaseBadge header; includes the DTO field so the badge
// stays correct after navigating away and back (replanPhase resets to "idle").
const isFixing = replanPhase === "generating" || !!session.fixInProgress;
// isApprovalReady() already checks !session.fixInProgress, so only the local
// ephemeral flag needs to be forwarded to suppress buttons while the request
// is in flight within this component instance.
const actions = getSessionActions(session, status, replanPhase === "generating" || isConfigRegenerating);
const notBusy = replanPhase === "idle" && askPhase === "idle" && !isConfigRegenerating;
const canShowFix = actions.showFix && notBusy;
const canShowAsk = actions.showAsk && notBusy;
// Decide which log content to show
const showLive = status === "running" || (status !== "idle" && liveLog.length > 0);
const logContent = showLive ? liveLog.join("\n") : savedLog;
const isAwaitingApproval = session.phase === "Awaiting Approval";
const sessionConfigEditorCommonProps = {
sessionId: session.id,
baseDir: session.baseDir,
configPath: session.configPath,
skippedSteps: session.skippedSteps,
phase: session.phase,
currentStep: session.currentStep,
onSessionUpdated,
onPlanRegenerated: setPlanContent,
onRegeneratingChange: setIsConfigRegenerating,
onError: (error: string) => { onToast({ kind: "failed", sessionInput: session.input, detail: error }); },
onAskUserRequired: () => void refreshSession(),
disabled: replanPhase === "generating",
};
return (
<div className="@container h-full flex flex-col">
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-gray-200 dark:border-gray-800 space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold font-mono text-gray-900 dark:text-gray-100">{session.id}</h2>
<PhaseBadge phase={session.phase} planAvailable={session.planAvailable} fixing={isFixing} />
</div>
{session.prUrl && (
<button
type="button"
onClick={() => void openUrl(session.prUrl!)}
aria-label="Open Pull Request in browser"
className="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 hover:underline"
>
PR: {session.prUrl.split("/").slice(-2).join(" #")}
<span className="text-xs">-></span>
</button>
)}
<div className="text-sm text-gray-500 dark:text-gray-400 italic">{session.input}</div>
{session.phase === "Awaiting Input" && session.pendingAskQuestion && (
<AskUserPanel
sessionId={session.id}
question={session.pendingAskQuestion}
onAnswered={() => void refreshSession()}
/>
)}
{/* Controls */}
<div className="flex gap-2">
{actions.showGeneratePlan && (
<button
type="button"
onClick={() => void handleGeneratePlan()}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Generate Plan
</button>
)}
{actions.showApprove && (
<button
type="button"
onClick={() => void handleApproveSession()}
className="px-4 py-2 bg-green-700 text-white rounded text-sm hover:bg-green-600"
>
Approve
</button>
)}
{canShowFix && (
<button
type="button"
onClick={() => setReplanPhase("editing")}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Fix
</button>
)}
{canShowAsk && (
<button
type="button"
onClick={() => setAskPhase("editing")}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Ask
</button>
)}
{actions.showCreateWorktree && (
session.repo ? (
// Repo-backed sessions always run in a fresh clone and create a
// PR; current-branch (no-PR) mode is not available.
<button
type="button"
onClick={() => void startRun("Worktree")}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Run (clone & create PR)
</button>
) : (
<>
<button
type="button"
onClick={() => void startRun("Worktree")}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Create worktree (new branch)
</button>
<button
type="button"
onClick={() => void startRun("CurrentBranch")}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Use current branch
</button>
</>
)
)}
{actions.showRun && (
<button
type="button"
onClick={() => void startRun(session.workspaceMode)}
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
{actions.runLabel}
</button>
)}
{actions.showCancel && (
<button
type="button"
onClick={() => void handleCancel()}
className="px-4 py-2 bg-red-600 text-white rounded text-sm hover:bg-red-700"
>
Cancel
</button>
)}
{actions.showReset && (
<button
type="button"
onClick={() => void handleReset()}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-orange-600 dark:text-orange-400 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Reset to Planned
</button>
)}
{actions.showReplan && replanPhase !== "generating" && (
<button
type="button"
onClick={() => setReplanPhase("editing")}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Replan
</button>
)}
{actions.showDelete && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-4 py-2 border border-gray-300 dark:border-gray-700 text-red-600 dark:text-red-400 rounded text-sm hover:bg-red-100/30 dark:hover:bg-red-900/30"
>
Delete
</button>
)}
</div>
{(session.phase === "Planned" || session.phase === "Failed" || session.phase === "Suspended") && (
<div className="border-b border-gray-200 dark:border-gray-800 pb-4">
<h3 className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">Session Settings</h3>
<SessionConfigEditor {...sessionConfigEditorCommonProps} />
</div>
)}
{/* Replan / Fix feedback */}
{replanPhase === "editing" && (
<div className="space-y-2">
<textarea
aria-label="Replan instructions"
value={replanFeedback}
onChange={(e) => setReplanFeedback(e.target.value)}
rows={3}
autoFocus
placeholder="Describe the changes needed..."
className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 focus:border-blue-500 outline-none resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void handleReplan();
}}
/>
{replanError && (
<p className="text-sm text-red-600 dark:text-red-400">{replanError}</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleReplan()}
disabled={!replanFeedback.trim()}
className="px-4 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Apply
</button>
<button
type="button"
onClick={() => { setReplanPhase("idle"); setReplanFeedback(""); setReplanError(""); }}
className="px-4 py-1.5 border border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800"
>
Cancel
</button>
</div>
</div>
)}
{replanPhase === "generating" && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Spinner />
{session.phase === "Draft" ? "Generating plan..." : "Regenerating plan..."}
</div>
{planProgress.length > 0 && (
<pre className="text-xs text-gray-500 dark:text-gray-400 max-h-48 overflow-auto bg-gray-50 dark:bg-gray-900 rounded p-2">
{planProgress.join("\n")}
</pre>
)}
</div>
)}
{/* Ask editor */}
{askPhase !== "idle" && (
<AskEditor
question={askQuestion}
onQuestionChange={setAskQuestion}
phase={askPhase}
error={askError}
onSubmit={handleAsk}
onCancel={() => { setAskPhase("idle"); setAskQuestion(""); setAskError(""); }}
/>
)}
{/* Progress indicator */}
{status === "running" && currentStep && (
<div className="text-sm text-gray-500 dark:text-gray-400">
Step:{" "}
<span className="font-medium text-gray-800 dark:text-gray-200">{currentStep}</span>
</div>
)}
</div>
<div className={isAwaitingApproval ? "flex-1 flex flex-col @4xl:flex-row min-h-0 overflow-hidden" : "flex-1"}>
{isAwaitingApproval && (
<aside
aria-label="Session settings"
className="@4xl:w-[360px] @4xl:flex-shrink-0 @4xl:border-r @4xl:border-gray-800 @4xl:overflow-auto border-b @4xl:border-b-0 border-gray-200 dark:border-gray-800 px-6 py-4 flex flex-col gap-3"
>
<h3 className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Session Settings</h3>
<SessionConfigEditor {...sessionConfigEditorCommonProps} />
</aside>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div role="tablist" className="flex border-b border-gray-200 dark:border-gray-800">
<button
type="button"
role="tab"
id={tabInfoId}
aria-selected={activeTab === "info"}
aria-controls={panelInfoId}
onClick={() => onActiveTabChange("info")}
className={`px-4 py-2 text-xs font-medium transition-colors ${
activeTab === "info"
? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-500"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Info
</button>
<button
type="button"
role="tab"
id={tabDagId}
aria-selected={activeTab === "dag"}
aria-controls={panelDagId}
onClick={() => onActiveTabChange("dag")}
className={`px-4 py-2 text-xs font-medium transition-colors ${
activeTab === "dag"
? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-500"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
DAG
</button>
<button
type="button"
role="tab"
id={tabPlanId}
aria-selected={activeTab === "plan"}
aria-controls={panelPlanId}
onClick={() => onActiveTabChange("plan")}
className={`${isAwaitingApproval ? "@4xl:hidden " : ""}px-4 py-2 text-xs font-medium transition-colors ${
activeTab === "plan"
? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-500"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Plan
</button>
<button
type="button"
role="tab"
id={tabLogId}
aria-selected={activeTab === "log"}
aria-controls={panelLogId}
onClick={() => onActiveTabChange("log")}
className={`px-4 py-2 text-xs font-medium transition-colors ${
activeTab === "log"
? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-500"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Log
{status === "running" && (
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
)}
</button>
</div>
<div className="flex-1 overflow-auto">
{activeTab === "info" && (
<WorkflowInfoPanel session={session} panelInfoId={panelInfoId} tabInfoId={tabInfoId} className={isAwaitingApproval ? "@4xl:hidden" : undefined} />
)}
{activeTab === "dag" && (
<WorkflowDagPanel sessionId={session.id} panelId={panelDagId} tabId={tabDagId} className={isAwaitingApproval ? "@4xl:hidden" : undefined} />
)}
<WorkflowPlanPanel
panelPlanId={panelPlanId}
tabPlanId={tabPlanId}
askResponse={askResponse}
planLoading={planLoading}
planContent={planContent}
className={
activeTab === "plan"
? "h-full overflow-auto"
: isAwaitingApproval
? "hidden @4xl:block @4xl:h-full @4xl:overflow-auto"
: "hidden"
}
/>
{activeTab === "log" && (
<WorkflowLogPanel panelLogId={panelLogId} tabLogId={tabLogId} status={status} logContent={logContent} logEndRef={logEndRef} preRef={preRef} onScroll={handleLogScroll} className={isAwaitingApproval ? "@4xl:hidden" : undefined} />
)}
</div>
</div>
</div>
{/* Option dialog */}
{pendingOption && (
<OptionDialog
choices={pendingOption.choices}
plan={pendingOption.plan}
onRespond={(result) => void handleOptionRespond(result)}
/>
)}
{/* Delete confirmation dialog */}
{showDeleteConfirm && (
<ConfirmDialog
title="Delete Session"
message={`Delete session "${session.id}" and its worktree? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}
// --- EmptyState -------------------------------------------------------------------
function EmptyState() {
return (
<div className="h-full flex items-center justify-center">
<p className="text-gray-500 dark:text-gray-400 text-sm">Select a session from the sidebar</p>
</div>
);
}
// --- NewSessionForm ---------------------------------------------------------------
type NewSessionSourceKind = "directory" | "repository";
interface NewSessionDraft {
input: string;
configPath: string;
baseDir: string;
/** Where the session's working copy comes from: a local directory or a GitHub repository clone. */
sourceKind: NewSessionSourceKind;
/** GitHub repository (owner/repo) used when sourceKind === "repository". */
repo: string;
useInputAsPlan: boolean;
/** "Grill me" planning: interview before writing the plan (SDK backend only). */
grill: boolean;
/** Disable interactive planning tools; agent writes plan.md directly. */
noInteractivePlanning: boolean;
/** Absolute paths of image files attached to the planning input. */
imageAttachments: string[];
isGenerating: boolean;
error: string | null;
}
function createInitialNewSessionDraft(): NewSessionDraft {
return {
input: "",
configPath: "",
baseDir: "",
sourceKind: "directory",
repo: "",
useInputAsPlan: false,
grill: false,
noInteractivePlanning: false,
imageAttachments: [],
isGenerating: false,
error: null,
};
}
interface NewSessionFormProps {
draft: NewSessionDraft;
onDraftChange: (updater: (prev: NewSessionDraft) => NewSessionDraft) => void;
onRefreshSidebar: () => void;
}
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function NewSessionForm({ draft, onDraftChange, onRefreshSidebar }: NewSessionFormProps) {
const [configs, setConfigs] = useState<ConfigEntry[]>([]);
const [configSteps, setConfigSteps] = useState<SkippableStepDto[]>([]);
const [afterPrSteps, setAfterPrSteps] = useState<SkippableStepDto[]>([]);
const [skippedSteps, setSkippedSteps] = useState<Set<string>>(new Set());
const [recentWorkingDirs, setRecentWorkingDirs] = useState<string[]>([]);
// Distinguishes which button is in flight so each shows its own progress
// (both share `isGenerating` for disabling inputs).
const [savingDraft, setSavingDraft] = useState(false);
const isMountedRef = useRef(true);
const { input, configPath, baseDir, sourceKind, repo, useInputAsPlan, grill, noInteractivePlanning, imageAttachments, isGenerating, error } = draft;
const isRepoMode = sourceKind === "repository";
// Repository clones don't exist yet at form time, so config lookup falls
// back to the user-level / builtin configs in repository mode.
// Repo clones don't exist at form time; use "." as placeholder so the Rust side
// resolves config from user-level / builtin paths. For directory mode, always pass
// the actual baseDir so history scope lookups match stored absolute-path entries.
const configLookupBaseDir = isRepoMode ? "." : (baseDir || ".");
const repoSpecValid = isValidRepoSpec(repo);
// Delays config-defaults refetch while the user types, preventing skippedSteps
// from being reset on every keystroke.
const debouncedRepo = useDebounce(repo, 300);
function set<K extends keyof NewSessionDraft>(key: K, value: NewSessionDraft[K]) {
onDraftChange((prev) => ({ ...prev, [key]: value }));
}
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const refreshHistorySummary = useCallback(async () => {
try {
const summary = await getNewSessionHistorySummary();
if (isMountedRef.current) {
setRecentWorkingDirs(summary.recentWorkingDirs);
}
return summary;
} catch {
if (isMountedRef.current) {
setRecentWorkingDirs([]);
}
return null;
}
}, []);
useEffect(() => {
let active = true;
const params = isRepoMode
// baseDir is unused for repo-mode config resolution; "." is a safe placeholder.
? { baseDir: ".", configPath: configPath || undefined, repo: debouncedRepo.trim() || undefined }
: { baseDir: configLookupBaseDir, configPath: configPath || undefined };
void getNewSessionConfigDefaults(params)
.then((defaults) => {
if (active) {
const validStepIds = new Set([
...collectExpandedStepIds(defaults.steps),
...collectExpandedStepIds(defaults.afterPrSteps),
]);
setConfigSteps(defaults.steps);
setAfterPrSteps(defaults.afterPrSteps);
setSkippedSteps(
new Set(defaults.defaultSkippedSteps.filter((id) => validStepIds.has(id))),
);
}
})
.catch(() => {
if (active) {
setConfigSteps([]);
setAfterPrSteps([]);
setSkippedSteps(new Set());
}
});
return () => { active = false; };
}, [configLookupBaseDir, configPath, isRepoMode, debouncedRepo]);
useEffect(() => {
if (isGenerating) return;
const timer = setTimeout(() => {
void saveNewSessionDraft({
input: draft.input,
configPath: draft.configPath || undefined,
baseDir: draft.baseDir,
repo: draft.sourceKind === "repository" ? draft.repo || undefined : undefined,
skippedSteps: Array.from(skippedSteps),
});
}, 500);
return () => clearTimeout(timer);
}, [draft.input, draft.configPath, draft.baseDir, draft.sourceKind, draft.repo, skippedSteps, isGenerating]);
function isParentChecked(node: SkippableStepDto): boolean {
return node.expandedStepIds.every((id) => skippedSteps.has(id));
}
function isParentIndeterminate(node: SkippableStepDto): boolean {
const selected = node.expandedStepIds.filter((id) => skippedSteps.has(id));
return selected.length > 0 && selected.length < node.expandedStepIds.length;
}
function toggleStepIds(ids: Iterable<string>, checked: boolean) {
setSkippedSteps((prev) => {
const next = new Set(prev);
for (const id of ids) {
if (checked) {
next.add(id);
} else {
next.delete(id);
}
}
return next;
});
}
function stepNodeLabel(node: SkippableStepDto, isChild: boolean): string {
if (!isChild) {
return node.id;
}
const slash = node.id.lastIndexOf("/");
return slash === -1 ? node.id : node.id.slice(slash + 1);
}
function renderStepNode(node: SkippableStepDto, isChild: boolean): React.ReactElement {
const label = stepNodeLabel(node, isChild);
if (node.children.length === 0) {
return (
<label key={node.id} className={`flex items-center gap-2 cursor-pointer${isChild ? " pl-6" : ""}`}>
<input
type="checkbox"
checked={skippedSteps.has(node.id)}
onChange={(e) => toggleStepIds([node.id], e.target.checked)}
disabled={isGenerating}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{label}</span>
</label>
);
}
return (
<div key={node.id}>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isParentChecked(node)}
ref={(el) => {
if (el) el.indeterminate = isParentIndeterminate(node);
}}
onChange={(e) => toggleStepIds(node.expandedStepIds, e.target.checked)}
disabled={isGenerating}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300 font-medium">{label}</span>
</label>
<div className="space-y-1 ml-4">
{node.children.map((child) => renderStepNode(child, true))}
</div>
</div>
);
}
// Reactive listConfigs effect: re-fetch when source or working directory changes.
// Initial render and isRepoMode changes (in either direction) fire immediately;
// subsequent baseDir-only changes are debounced (200 ms) to avoid a round-trip
// on every keystroke.
const listConfigsIsFirstRef = useRef(true);
const listConfigsPrevRepoModeRef = useRef<boolean | null>(null);
useEffect(() => {
let active = true;
const fetchConfigs = () => {
void listConfigs(isRepoMode ? {} : { baseDir: baseDir || "." })
.then((newConfigs) => {
if (!active) return;
setConfigs(newConfigs);
onDraftChange((prev) => {
if (prev.configPath !== "" && !newConfigs.some((c) => c.path === prev.configPath)) {
return { ...prev, configPath: "" };
}
return prev;
});
})
.catch(() => {
if (active) setConfigs([]);
});
};
const isFirst = listConfigsIsFirstRef.current;
const prevRepoMode = listConfigsPrevRepoModeRef.current;
listConfigsIsFirstRef.current = false;
listConfigsPrevRepoModeRef.current = isRepoMode;
if (isFirst || prevRepoMode !== isRepoMode) {
fetchConfigs();
return () => { active = false; };
}
const timer = setTimeout(fetchConfigs, 200);
return () => { active = false; clearTimeout(timer); };
}, [baseDir, isRepoMode, onDraftChange]);
// Load history-backed defaults on mount.
useEffect(() => {
let active = true;
void refreshHistorySummary()
.then((summary) => {
if (!active || !summary) return;
onDraftChange((prev) => {
let changed = false;
const next = { ...prev };
if (prev.baseDir === "" && summary.lastWorkingDir) {
next.baseDir = summary.lastWorkingDir;
changed = true;
}
if (prev.configPath === "" && summary.lastRequestedConfigPath) {
next.configPath = summary.lastRequestedConfigPath;
changed = true;
}
return changed ? next : prev;
});
})
.catch((e) => {
console.error("Failed to refresh history summary:", e);
});
return () => {
active = false;
};
}, [onDraftChange, refreshHistorySummary]);
// True when the form has everything it needs to create a session.
// Either a non-empty task description or at least one attached image is required.
const canSubmit =
(input.trim() !== "" || imageAttachments.length > 0) && (!isRepoMode || repoSpecValid);
async function handleGenerate() {
if (!canSubmit) return;
onDraftChange((prev) => ({ ...prev, error: null, isGenerating: true }));
let formReleased = false;
const channel = new Channel<PlanEvent>();
channel.onmessage = (event) => {
if (event.event === "sessionCreated") {
formReleased = true;
onDraftChange((prev) => ({
...createInitialNewSessionDraft(),
baseDir: prev.baseDir,
configPath: prev.configPath,
sourceKind: prev.sourceKind,
repo: prev.repo,
}));
void clearNewSessionDraft();
void refreshHistorySummary();
onRefreshSidebar();
} else if (event.event === "askUserRequired") {
onRefreshSidebar();
} else if (event.event === "planGenerated" || event.event === "planFailed") {
onRefreshSidebar();
}
};
try {
await createSession(
{
input: input.trim(),
configPath: configPath || undefined,
baseDir: baseDir || ".",
repo: isRepoMode ? repo.trim() : undefined,
skippedSteps: Array.from(skippedSteps),
useInputAsPlan,
grill,
noInteractivePlanning,
imageAttachments,
},
channel,
);
} catch (e) {
if (!formReleased) {
onDraftChange((prev) => ({ ...prev, error: String(e), isGenerating: false }));
}
}
}
async function handleSaveDraft() {
if (!canSubmit) return;
setSavingDraft(true);
onDraftChange((prev) => ({ ...prev, error: null, isGenerating: true }));
try {
await createDraftSession({
input: input.trim(),
configPath: configPath || undefined,
baseDir: baseDir || ".",
repo: isRepoMode ? repo.trim() : undefined,
skippedSteps: Array.from(skippedSteps),
imageAttachments,
});
onDraftChange((prev) => ({
...createInitialNewSessionDraft(),
baseDir: prev.baseDir,
configPath: prev.configPath,
sourceKind: prev.sourceKind,
repo: prev.repo,
}));
void clearNewSessionDraft();
void refreshHistorySummary();
onRefreshSidebar();
} catch (e) {
onDraftChange((prev) => ({ ...prev, error: String(e), isGenerating: false }));
} finally {
setSavingDraft(false);
}
}
return (
<div className="h-full flex flex-col">
<div className="px-6 pt-6 pb-4 border-b border-gray-200 dark:border-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">New Session</h2>
</div>
<div className="flex-1 overflow-auto p-6 space-y-5">
{/* Error banner */}
{error && (
<div className="bg-red-100 dark:bg-red-900/40 border border-red-300 dark:border-red-700 rounded px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{/* Workspace source: local directory or GitHub repository clone */}
<div className="space-y-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Source</span>
<div className="flex gap-4" role="radiogroup" aria-label="Workspace source">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="source-kind"
checked={!isRepoMode}
onChange={() => set("sourceKind", "directory")}
disabled={isGenerating}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Directory</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="source-kind"
checked={isRepoMode}
onChange={() => set("sourceKind", "repository")}
disabled={isGenerating}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">GitHub Repository</span>
</label>
</div>
</div>
{isRepoMode ? (
<div className="space-y-1.5">
<label htmlFor="repo-input" className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Repository</label>
<RepoPicker
id="repo-input"
value={repo}
onChange={(v) => set("repo", v)}
disabled={isGenerating}
placeholder="owner/repository"
/>
{repo.trim() !== "" && !repoSpecValid && (
<p className="text-xs text-red-600 dark:text-red-400">Expected format: owner/repository</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
The repository is cloned into a temporary directory for planning and execution; a PR
is created when the run finishes and the clone is removed.
</p>
</div>
) : (
<div className="space-y-1.5">
<label htmlFor="base-dir-input" className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Working Directory</label>
<DirectoryPicker
id="base-dir-input"
value={baseDir}
onChange={(v) => set("baseDir", v)}
disabled={isGenerating}
placeholder="e.g. /Users/you/projects/myapp"
/>
{recentWorkingDirs.length > 0 && (
<div className="relative z-[60] flex flex-wrap gap-2 pt-1">
{recentWorkingDirs.map((dir) => (
<button
key={dir}
type="button"
onClick={() => set("baseDir", dir)}
disabled={isGenerating}
className="px-2.5 py-1 rounded-full border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{dir}
</button>
))}
</div>
)}
</div>
)}
{/* Config selector */}
<div className="space-y-1.5">
<label htmlFor="config-select" className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Config</label>
<select
id="config-select"
value={configPath}
onChange={(e) => set("configPath", e.target.value)}
disabled={isGenerating}
className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-800 dark:text-gray-200 focus:border-blue-500 outline-none disabled:opacity-50"
>
<option value="">Auto (repo / ~/.cruise / builtin)</option>
{configs.map((c) => (
<option key={c.path} value={c.path}>
{c.description ? `${c.name} — ${c.description}` : c.name}
</option>
))}
</select>
</div>
{/* Skip steps */}
{(configSteps.length > 0 || afterPrSteps.length > 0) && (
<div className="space-y-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Skip Steps</span>
{configSteps.length > 0 && (
<div className="space-y-1">
{configSteps.map((node) => renderStepNode(node, false))}
</div>
)}
{afterPrSteps.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-gray-400 dark:text-gray-500">After PR Steps</span>
{afterPrSteps.map((node) => renderStepNode(node, false))}
</div>
)}
</div>
)}
{/* Task input */}
<div className="space-y-1.5">
<label htmlFor="task-input" className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Task</label>
<textarea
id="task-input"
value={input}
onChange={(e) => set("input", e.target.value)}
disabled={isGenerating}
rows={4}
placeholder="Describe what you want to implement..."
className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 text-sm text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-600 focus:border-blue-500 outline-none resize-none disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) void handleGenerate();
}}
/>
</div>
{/* Image attachments */}
<ImageAttachments
value={imageAttachments}
onChange={(next) => set("imageAttachments", next)}
disabled={isGenerating}
/>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={useInputAsPlan}
onChange={(e) =>
onDraftChange((prev) => ({
...prev,
useInputAsPlan: e.target.checked,
// Mutually exclusive with grill: input-as-plan skips planning entirely.
grill: e.target.checked ? false : prev.grill,
}))
}
disabled={isGenerating}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use input as plan (skip LLM planning)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={grill}
onChange={(e) =>
onDraftChange((prev) => ({
...prev,
grill: e.target.checked,
// Mutually exclusive with use-input-as-plan.
useInputAsPlan: e.target.checked ? false : prev.useInputAsPlan,
}))
}
disabled={isGenerating || useInputAsPlan || noInteractivePlanning}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Grill me (interview one question at a time, then write the plan; SDK backend only)
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={noInteractivePlanning}
onChange={(e) =>
onDraftChange((prev) => ({
...prev,
noInteractivePlanning: e.target.checked,
// Mutually exclusive with grill: grill requires interactive planning tools.
grill: e.target.checked ? false : prev.grill,
}))
}
disabled={isGenerating || useInputAsPlan}
className="accent-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
Non-interactive planning (agent writes plan.md directly, no planning tools)
</span>
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleGenerate()}
disabled={isGenerating || !canSubmit}
className="px-5 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isGenerating && !savingDraft ? (
<>
<Spinner color="border-white" />
Creating session...
</>
) : (
useInputAsPlan ? "Create session" : "Generate plan"
)}
</button>
<button
type="button"
onClick={() => void handleSaveDraft()}
disabled={isGenerating || !canSubmit}
title="Save as a draft without generating a plan; generate the plan later."
className="px-5 py-2 border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded text-sm hover:bg-gray-200 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{savingDraft ? (
<>
<Spinner />
Saving draft...
</>
) : (
"Save as draft"
)}
</button>
</div>
</div>
</div>
);
}
// --- RunAllView ------------------------------------------------------------------
type RunAllStatus = "running" | "completed" | "cancelled" | "error";
interface RunAllSessionResult {
sessionId: string;
input: string;
phase: SessionPhase;
error?: string;
}
interface RunAllRunningSession {
input: string;
currentStep: string | null;
}
interface RunAllUiState {
status: RunAllStatus;
total: number;
parallelism: number | null;
runningSessions: Record<string, RunAllRunningSession>;
results: RunAllSessionResult[];
runError: string | null;
pendingOptions: PendingOption[];
liveLog: string[];
}
interface RunAllViewProps {
state: RunAllUiState;
onCancel: () => void;
onOptionRespond: (result: { nextStep?: string; textInput?: string }) => void;
onDone: () => void;
}
function RunAllView({ state, onCancel, onOptionRespond, onDone }: RunAllViewProps) {
const { status, total, parallelism, runningSessions, results, runError, pendingOptions, liveLog } = state;
const runningEntries = Object.entries(runningSessions);
const progressCount = results.length + runningEntries.length;
const displayTotal = Math.max(total, progressCount);
const { completedCount, failedCount, suspendedCount } = results.reduce(
(acc, r) => {
if (r.phase === "Completed") acc.completedCount++;
else if (r.phase === "Failed") acc.failedCount++;
else if (r.phase === "Suspended") acc.suspendedCount++;
return acc;
},
{ completedCount: 0, failedCount: 0, suspendedCount: 0 },
);
const visiblePendingOption = pendingOptions[0];
const logEndRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
if (status === "running") {
logEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}
}, [liveLog, status]);
return (
<div className="h-full flex flex-col p-6 max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">Run All</h2>
{status === "running" ? (
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 rounded"
>
Cancel
</button>
) : (
<button
type="button"
onClick={onDone}
className="px-3 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700 rounded"
>
Done
</button>
)}
</div>
{(total > 0 || status === "error") && (
<div className="mb-4">
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
{total > 0 && <span>{progressCount} / {displayTotal} sessions</span>}
{status === "running" && runningEntries.length > 0 && (
<span className="text-green-600 dark:text-green-400 animate-pulse">
Running {runningEntries.length}
{parallelism !== null && ` / ${parallelism}`}
</span>
)}
{status === "completed" && <span className="text-green-600 dark:text-green-400">Completed</span>}
{status === "cancelled" && <span className="text-orange-600 dark:text-orange-400">Cancelled</span>}
{status === "error" && <span className="text-red-600 dark:text-red-400">Error</span>}
</div>
{total > 0 && (
<div className="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 rounded-full transition-all duration-300"
style={{ width: `${(progressCount / displayTotal) * 100}%` }}
/>
</div>
)}
</div>
)}
{status === "running" && runningEntries.length > 0 && (
<div className="mb-4 space-y-2">
{runningEntries.map(([sessionId, sessionState]) => (
<div
key={sessionId}
className="p-3 bg-gray-50 dark:bg-gray-900 border border-green-300 dark:border-green-900/50 rounded"
>
<div className="flex items-center gap-2 mb-1">
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">{sessionId}</span>
</div>
<p className="text-sm text-gray-800 dark:text-gray-200 truncate">{sessionState.input}</p>
{sessionState.currentStep && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{sessionState.currentStep}
</p>
)}
</div>
))}
</div>
)}
<pre className="mb-4 text-xs font-mono bg-white dark:bg-gray-950 text-gray-700 dark:text-gray-300 p-4 rounded overflow-auto whitespace-pre-wrap leading-relaxed max-h-80">
{liveLog.length > 0 ? liveLog.join("\n") : "Waiting for events..."}
<span ref={logEndRef} />
</pre>
<div className="flex-1 overflow-y-auto space-y-1">
{results.map((r) => (
<div
key={r.sessionId}
className="flex items-start gap-2 px-3 py-2 rounded bg-gray-50/50 dark:bg-gray-900/50"
>
<span className="mt-0.5 text-sm">
{r.phase === "Completed" && PHASE_ICON.Completed}
{r.phase === "Failed" && PHASE_ICON.Failed}
{r.phase === "Suspended" && PHASE_ICON.Suspended}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">{r.input}</p>
{r.error && <p className="text-xs text-red-600 dark:text-red-400 mt-0.5 truncate">{r.error}</p>}
</div>
<PhaseBadge phase={r.phase} />
</div>
))}
</div>
{(status === "completed" || status === "cancelled" || status === "error") && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded text-sm text-gray-500 dark:text-gray-400 flex flex-col gap-1">
<div className="flex gap-4">
<span className="text-green-600 dark:text-green-400">{completedCount} completed</span>
{failedCount > 0 && <span className="text-red-600 dark:text-red-400">{failedCount} failed</span>}
{suspendedCount > 0 && <span className="text-orange-600 dark:text-orange-400">{suspendedCount} cancelled</span>}
</div>
{runError && <p className="text-xs text-red-600 dark:text-red-400">{runError}</p>}
</div>
)}
{visiblePendingOption && (
<OptionDialog
choices={visiblePendingOption.choices}
plan={visiblePendingOption.plan}
onRespond={onOptionRespond}
/>
)}
</div>
);
}
function appendLog(log: string[], line: string): string[] {
const next = [...log, line];
return next.length > 2000 ? next.slice(-2000) : next;
}
// --- App -------------------------------------------------------------------------
export default function App() {
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [view, setView] = useState<"session" | "new" | "runAll">("session");
const sidebarRefreshRef = useRef<(() => void) | null>(null);
const sidebarRemoveSessionRef = useRef<((id: string) => void) | null>(null);
const selectedSessionRef = useRef<Session | null>(null);
const sessionTabMapRef = useRef<Record<string, ActiveTab>>({});
const [toasts, setToasts] = useState<WorkflowToast[]>([]);
const [newSessionDraft, setNewSessionDraft] = useState<NewSessionDraft>(createInitialNewSessionDraft);
const [sessionTabMap, setSessionTabMap] = useState<Record<string, ActiveTab>>({});
const [runAllState, setRunAllState] = useState<RunAllUiState | null>(null);
const runAllChannelRef = useRef<Channel<WorkflowEvent> | null>(null);
const toastIdRef = useRef(0);
const toastTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [fixingSessionIds, setFixingSessionIds] = useState<ReadonlySet<string>>(new Set());
const [showSettings, setShowSettings] = useState(false);
const [settingsParallelism, setSettingsParallelism] = useState<number>(1);
const {
width: sidebarWidth,
handleMouseDown: handleSidebarMouseDown,
handleKeyDown: handleSidebarKeyDown,
} = useSplitPane(
"cruise-sidebar-width",
288,
180,
480,
);
useEffect(() => {
let active = true;
void getNewSessionDraft().then((d) => {
if (!active || !d) return;
setNewSessionDraft((prev) => ({
...prev,
input: d.input,
configPath: d.configPath ?? "",
baseDir: d.baseDir,
sourceKind: d.repo ? "repository" : "directory",
repo: d.repo ?? "",
}));
}).catch(() => {});
return () => { active = false; };
}, []);
async function handleOpenSettings() {
try {
const config = await getAppConfig();
setSettingsParallelism(config.runAllParallelism);
setShowSettings(true);
} catch (e) {
emitNotification("failed", "Settings", String(e));
}
}
async function handleSaveSettings(config: AppConfig) {
await updateAppConfig(config);
setShowSettings(false);
setRunAllState((prev) => {
if (!prev || prev.status !== "running") return prev;
if (prev.parallelism === config.runAllParallelism) return prev;
return { ...prev, parallelism: config.runAllParallelism };
});
}
const handleFixingChange = useCallback((sessionId: string, fixing: boolean) => {
setFixingSessionIds((prev) => {
if (prev.has(sessionId) === fixing) return prev;
const next = new Set(prev);
if (fixing) {
next.add(sessionId);
} else {
next.delete(sessionId);
}
return next;
});
}, []);
const sessionSnapshotRef = useRef<Map<string, Session> | null>(null);
const addToast = useCallback((toast: Omit<WorkflowToast, "id">) => {
const id = ++toastIdRef.current;
setToasts((prev) => [...prev, { ...toast, id }]);
const timer = setTimeout(() => {
toastTimersRef.current.delete(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
}, TOAST_DURATION_MS[toast.kind]);
toastTimersRef.current.set(id, timer);
}, []);
useLayoutEffect(() => {
selectedSessionRef.current = selectedSession;
sessionTabMapRef.current = sessionTabMap;
});
const handleDeleteConfirmed = useCallback((sessionId: string) => {
const prevSession = selectedSessionRef.current;
const prevTab = sessionTabMapRef.current[sessionId];
sidebarRemoveSessionRef.current?.(sessionId);
setSessionTabMap((prev) => {
if (!(sessionId in prev)) return prev;
const next = { ...prev };
delete next[sessionId];
return next;
});
setSelectedSession(null);
deleteSession(sessionId).catch((e) => {
sidebarRefreshRef.current?.();
setSelectedSession(prevSession);
if (prevTab !== undefined) setSessionTabMap((prev) => ({ ...prev, [sessionId]: prevTab }));
addToast({ kind: "failed", sessionInput: prevSession?.input ?? sessionId, detail: `Delete failed: ${e}` });
});
}, [addToast]);
const dismissToast = useCallback((id: number) => {
const timer = toastTimersRef.current.get(id);
if (timer !== undefined) {
clearTimeout(timer);
toastTimersRef.current.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const emitNotification = useCallback((kind: ToastKind, sessionInput: string, detail?: string) => {
addToast({ kind, sessionInput, detail: detail?.slice(0, 80) });
const safeText = (detail ?? sessionInput).replace(/[\x00-\x1F\x7F]/g, " ").slice(0, 60);
void notifyDesktop("Cruise", `${TOAST_LABEL[kind]} -- ${safeText}`);
}, [addToast]);
const handleSessionsChanged = useCallback((sessions: Session[]) => {
const newMap = new Map(sessions.map((s) => [s.id, s]));
const prevMap = sessionSnapshotRef.current;
sessionSnapshotRef.current = newMap;
if (prevMap === null) return; // first snapshot: suppress startup notifications
for (const session of newMap.values()) {
const prev = prevMap.get(session.id);
const approvalReady = isApprovalReady(session);
const wasApprovalReady = prev !== undefined && isApprovalReady(prev);
if (approvalReady && !wasApprovalReady) {
emitNotification("plan-ready", session.input);
}
const isCompleted = session.phase === "Completed";
const wasCompleted = prev !== undefined && prev.phase === "Completed";
if (isCompleted && !wasCompleted) {
emitNotification("completed", session.input);
}
const isFailed = session.phase === "Failed";
const wasFailed = prev !== undefined && prev.phase === "Failed";
if (isFailed && !wasFailed) {
emitNotification("failed", session.input, session.phaseError);
}
const nowPending = session.pendingAskQuestion;
const prevPending = prev?.pendingAskQuestion;
if (nowPending && prev !== undefined && nowPending !== prevPending) {
emitNotification("input-required", session.input, nowPending);
}
}
}, [emitNotification]);
useEffect(() => {
const timers = toastTimersRef.current;
return () => {
timers.forEach((timer) => clearTimeout(timer));
if (runAllChannelRef.current) {
runAllChannelRef.current.onmessage = () => {};
runAllChannelRef.current = null;
}
};
}, []);
function patchRunAll(updates: Partial<RunAllUiState>) {
setRunAllState((prev) => prev ? { ...prev, ...updates } : prev);
}
function handleRunAll() {
setSelectedSession(null);
setView("runAll");
if (runAllState) return;
const channel = new Channel<WorkflowEvent>();
runAllChannelRef.current = channel;
setRunAllState({
status: "running",
total: 0,
parallelism: null,
runningSessions: {},
results: [],
runError: null,
pendingOptions: [],
liveLog: [],
});
channel.onmessage = (event) => {
if (event.event === "runAllStarted") {
setRunAllState((prev) => {
if (!prev) return prev;
const { total, parallelism } = event.data;
return {
...prev,
total,
parallelism,
liveLog: appendLog(prev.liveLog, runAllStartedLogLine(total, parallelism)),
};
});
} else if (event.event === "runAllSessionStarted") {
setRunAllState((prev) => {
if (!prev) return prev;
return {
...prev,
runningSessions: {
...prev.runningSessions,
[event.data.sessionId]: {
input: event.data.input,
currentStep: null,
},
},
liveLog: appendLog(prev.liveLog, runAllSessionStartedLogLine(event.data.sessionId, event.data.input)),
};
});
} else if (event.event === "runAllSessionFinished") {
setRunAllState((prev) => {
if (!prev) return prev;
const { sessionId, input, phase, error } = event.data;
const { [sessionId]: _finishedSession, ...remainingSessions } = prev.runningSessions;
return {
...prev,
runningSessions: remainingSessions,
results: [...prev.results, { sessionId, input, phase, error }],
pendingOptions: prev.pendingOptions.filter((option) => option.requestId !== sessionId),
};
});
sidebarRefreshRef.current?.();
} else if (event.event === "runAllCompleted") {
setRunAllState((prev) => {
if (!prev) return prev;
return {
...prev,
status: event.data.cancelled > 0 ? "cancelled" : "completed",
liveLog: appendLog(prev.liveLog, runAllCompletedLogLine(event.data.cancelled)),
};
});
} else if (event.event === "stepStarted") {
setRunAllState((prev) => {
if (!prev) return prev;
const runningSession = prev.runningSessions[event.data.sessionId];
return {
...prev,
runningSessions: runningSession ? {
...prev.runningSessions,
[event.data.sessionId]: {
...runningSession,
currentStep: event.data.step,
},
} : prev.runningSessions,
liveLog: appendLog(prev.liveLog, runAllStepLogLine(event.data.sessionId, event.data.step)),
};
});
} else if (event.event === "logChunk") {
setRunAllState((prev) => {
if (!prev) return prev;
return {
...prev,
liveLog: appendLog(prev.liveLog, `[${event.data.sessionId}] ${event.data.line}`),
};
});
} else if (event.event === "optionRequired") {
setRunAllState((prev) => {
if (!prev) return prev;
return {
...prev,
pendingOptions: [
...prev.pendingOptions,
{ requestId: event.data.requestId, choices: event.data.choices, plan: event.data.plan },
],
};
});
} else if (
event.event === "workflowCompleted" ||
event.event === "workflowFailed" ||
event.event === "workflowCancelled"
) {
setRunAllState((prev) => {
if (!prev) return prev;
return {
...prev,
liveLog: appendLog(prev.liveLog, runAllWorkflowEventLogLine(event)),
};
});
}
};
void runAllSessions(channel).catch((e) => {
patchRunAll({ status: "error", runError: String(e) });
});
}
async function handleRunAllCancel() {
try {
await cancelSession();
} catch (e) {
patchRunAll({ runError: String(e) });
}
}
async function handleRunAllOptionRespond(result: { nextStep?: string; textInput?: string }) {
const sessionId = runAllState?.pendingOptions[0]?.requestId;
setRunAllState((prev) => prev ? { ...prev, pendingOptions: prev.pendingOptions.filter((o) => o.requestId !== sessionId) } : prev);
if (!sessionId) return;
try {
await respondToOption(sessionId, result);
} catch (e) {
patchRunAll({ runError: String(e) });
}
}
function handleRunAllDone() {
if (runAllChannelRef.current) {
runAllChannelRef.current.onmessage = () => {};
runAllChannelRef.current = null;
}
setRunAllState(null);
sidebarRefreshRef.current?.();
setView("session");
}
return (
<div className="h-screen flex bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100 font-sans">
<WorkflowToastStack toasts={toasts} onDismiss={dismissToast} />
{showSettings && (
<SettingsModal
initialParallelism={settingsParallelism}
onSave={handleSaveSettings}
onClose={() => setShowSettings(false)}
/>
)}
{/* Sidebar */}
<aside
style={{ width: sidebarWidth, flexShrink: 0 }}
className="border-r border-gray-200 dark:border-gray-800 flex flex-col"
>
<SessionSidebar
selectedId={selectedSession?.id ?? null}
onSelect={(s) => { setSelectedSession(s); setView("session"); }}
onNewSession={() => { setSelectedSession(null); setView("new"); }}
onRunAll={handleRunAll}
runAllActive={!!runAllState}
onRefreshRef={sidebarRefreshRef}
onSelectedSessionUpdated={(s) => setSelectedSession(s)}
fixingSessionIds={fixingSessionIds}
onSessionsChanged={handleSessionsChanged}
onSettings={() => void handleOpenSettings()}
onOptimisticRemoveRef={sidebarRemoveSessionRef}
/>
</aside>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
aria-valuenow={sidebarWidth}
aria-valuemin={180}
aria-valuemax={480}
tabIndex={0}
className="w-1 cursor-col-resize bg-transparent hover:bg-blue-500/50 active:bg-blue-500/70 focus-visible:bg-blue-500/70 flex-shrink-0 transition-colors outline-none"
onMouseDown={handleSidebarMouseDown}
onKeyDown={handleSidebarKeyDown}
/>
{/* Main content */}
<main className="flex-1 overflow-auto min-w-0">
{view === "runAll" && runAllState ? (
<RunAllView
state={runAllState}
onCancel={handleRunAllCancel}
onOptionRespond={handleRunAllOptionRespond}
onDone={handleRunAllDone}
/>
) : view === "new" ? (
<NewSessionForm
draft={newSessionDraft}
onDraftChange={setNewSessionDraft}
onRefreshSidebar={() => sidebarRefreshRef.current?.()}
/>
) : selectedSession ? (
<WorkflowRunner
key={selectedSession.id}
session={selectedSession}
activeTab={sessionTabMap[selectedSession.id] ?? "plan"}
onActiveTabChange={(tab) =>
setSessionTabMap((prev) => ({ ...prev, [selectedSession.id]: tab }))
}
onSessionUpdated={(updated) => {
setSelectedSession(updated);
sidebarRefreshRef.current?.();
}}
onDeleteConfirmed={handleDeleteConfirmed}
onToast={(toast) => emitNotification(toast.kind, toast.sessionInput, toast.detail)}
onFixingChange={handleFixingChange}
/>
) : (
<EmptyState />
)}
</main>
</div>
);
}