Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 79 additions & 108 deletions apps/web/src/app/editor/[projectId]/page.tsx

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion apps/web/src/modules/editor/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { useChat } from "../hooks/use-chat";
import { useEditorStore } from "../hooks/use-editor-store";
import { ChatMessage } from "./chat-message";
import { ChatInput } from "./chat-input";
import {usePlanSync} from "../hooks/use-plan-sync";
import {acceptPlanSnapshot} from "./plan/plan-state";
import { ClarificationFlow, PlanCard, PlanProgress } from "./plan";
import { SupabaseProvisionDialog } from "@/modules/integrations/supabase-provision-dialog";
import { IntegrationConnectDialog } from "@/modules/integrations/integration-connect-dialog";
Expand Down Expand Up @@ -58,6 +60,12 @@ export function ChatPanel() {
dismissSupabaseProvision,
} = useChat(projectId);

const receivePlan = useCallback((plan:import("@doable/shared/types/ai").Plan|null)=> {
if(!projectId)return;const store=useEditorStore.getState();
const next=acceptPlanSnapshot(store.activePlan,plan,projectId);store.setActivePlan(next);
if(next && next.status!=="draft")store.setPlanPhase("building");
},[projectId]);
usePlanSync(projectId,isStreaming,receivePlan);
const activePlan = useEditorStore((s) => s.activePlan);
const planPhase = useEditorStore((s) => s.planPhase);
const pendingQuestions = useEditorStore((s) => s.pendingQuestions);
Expand Down Expand Up @@ -206,7 +214,7 @@ export function ChatPanel() {
{/* Sticky plan progress during build */}
{planPhase === "building" && activePlan && (
<div className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur-sm shrink-0">
<PlanProgress plan={activePlan} />
<PlanProgress plan={activePlan} running={isStreaming} />
</div>
)}

Expand Down
35 changes: 23 additions & 12 deletions apps/web/src/modules/editor/chat/plan/plan-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@ import {
SkipForward,
XCircle,
} from "lucide-react";
import {planProgressState} from "./plan-state";
import type { Plan } from "@doable/shared/types/ai";

interface PlanProgressProps {
plan: Plan;
running?: boolean;
onPause?: () => void;
onSkipStep?: (stepId: string) => void;
/** When true, renders the compact horizontal pill strip for the sticky header */
compact?: boolean;
}

// ─── Step icon ────────────────────────────────────────────────
function StepIcon({ status }: { status: string }) {
function StepIcon({ status, running }: { status: string; running: boolean }) {
if (status === "completed") return <CheckCircle2 className="h-3 w-3 flex-none text-green-500" />;
if (status === "in_progress" && !running) return <Pause className="h-3 w-3 flex-none text-muted-foreground" />;
if (status === "in_progress") return <Loader2 className="h-3 w-3 flex-none text-brand-500 animate-spin" />;
if (status === "skipped") return <SkipForward className="h-3 w-3 flex-none text-muted-foreground" />;
if (status === "failed") return <XCircle className="h-3 w-3 flex-none text-red-400" />;
Expand All @@ -33,10 +36,12 @@ function StepPill({
status,
title,
isLast,
running,
}: {
status: string;
title: string;
isLast: boolean;
running: boolean;
}) {
const [justCompleted, setJustCompleted] = useState(false);
const prevStatus = useRef(status);
Expand Down Expand Up @@ -67,7 +72,7 @@ function StepPill({
: "ring-border bg-transparent"
}`}
>
<StepIcon status={status} />
<StepIcon status={status} running={running} />
</div>
{!isLast && (
<div
Expand All @@ -81,7 +86,8 @@ function StepPill({
}

// ─── Compact mode (sticky header) ─────────────────────────────
function CompactPlanProgress({ plan }: { plan: Plan }) {
function CompactPlanProgress({ plan, running }: { plan: Plan; running:boolean }) {

const sortedSteps = useMemo(
() => [...plan.steps].sort((a, b) => a.order - b.order),
[plan.steps]
Expand All @@ -99,14 +105,15 @@ function CompactPlanProgress({ plan }: { plan: Plan }) {
};
}, [plan.steps]);

const display = planProgressState(plan,running);
const activeStep = sortedSteps.find((s) => s.status === "in_progress");

return (
<div className="flex items-center gap-3 px-3 py-2">
{/* Spinner + label */}
<div className="flex items-center gap-1.5 shrink-0">
<Loader2 className="h-3 w-3 text-brand-500 animate-spin" />
<span className="text-xs font-medium text-foreground">Building</span>
{display.spinning ? <Loader2 className="h-3 w-3 text-brand-500 animate-spin" /> : display.finished ? <CheckCircle2 className="h-3 w-3 text-green-500" /> : <Pause className="h-3 w-3 text-muted-foreground" />}
<span className="text-xs font-medium text-foreground">{display.label}</span>
</div>

{/* Step pills */}
Expand All @@ -117,6 +124,7 @@ function CompactPlanProgress({ plan }: { plan: Plan }) {
status={step.status}
title={step.title}
isLast={idx === sortedSteps.length - 1}
running={running}
/>
))}
</div>
Expand All @@ -142,6 +150,7 @@ export const PlanProgress = memo(function PlanProgress({
onPause,
onSkipStep,
compact = false,
running = false,
}: PlanProgressProps) {
const sortedSteps = useMemo(
() => [...plan.steps].sort((a, b) => a.order - b.order),
Expand All @@ -157,19 +166,20 @@ export const PlanProgress = memo(function PlanProgress({
return { completedCount: done, percentage: pct };
}, [plan.steps]);

const display = planProgressState(plan,running);

// Early return AFTER hooks to keep hook order stable across renders.
if (compact) return <CompactPlanProgress plan={plan} />;
if (compact) return <CompactPlanProgress plan={plan} running={running} />;

return (
<div className="rounded-lg border border-border bg-muted/30">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/50">
<Loader2 className="h-3.5 w-3.5 text-brand-500 animate-spin" />
<span className="text-xs font-semibold text-foreground">Building</span>
{display.spinning ? <Loader2 className="h-3.5 w-3.5 text-brand-500 animate-spin" /> : display.finished ? <CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> : <Pause className="h-3.5 w-3.5 text-muted-foreground" />}
<span className="text-xs font-semibold text-foreground">{display.label}</span>
<span className="ml-auto text-xs text-muted-foreground">
{completedCount}/{plan.steps.length} steps
</span>
{onPause && (
{completedCount}/{plan.steps.length} {"steps"} </span>
{onPause && running && (
<button
onClick={onPause}
className="flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
Expand All @@ -185,6 +195,7 @@ export const PlanProgress = memo(function PlanProgress({
<p className="text-xs text-muted-foreground leading-relaxed">{plan.summary}</p>
</div>

<p className="px-3 pt-1 text-[11px] text-muted-foreground">{"Steps show reported results, not elapsed build time."}{!running && !display.finished && " " + "No active run. Unreported steps remain unconfirmed."}</p>
{/* Progress bar */}
<div className="px-3 py-2">
<div className="flex items-center gap-2">
Expand All @@ -211,7 +222,7 @@ export const PlanProgress = memo(function PlanProgress({
isActive ? "bg-brand-500/5" : ""
}`}
>
<StepIcon status={step.status} />
<StepIcon status={step.status} running={running} />
<span
className={`flex-1 truncate ${
step.status === "completed" || step.status === "skipped"
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/modules/editor/chat/plan/plan-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { acceptPlanSnapshot, planProgressState } from "./plan-state";
import type { Plan } from "@doable/shared/types/ai";
const base: Plan = {
id: "p",
projectId: "project",
summary: "Plan",
status: "approved",
complexity: "simple",
createdAt: "2026-01-01T00:00:00Z",
revision: 3,
steps: [
{ id: "a", order: 1, title: "A", description: "A", status: "pending" },
{ id: "b", order: 2, title: "B", description: "B", status: "pending" },
],
};
test("approved incomplete plans stop spinning when execution ends, without invented completion", () => {
assert.equal(planProgressState(base, true).spinning, true);
const idle = planProgressState(base, false);
assert.equal(idle.spinning, false);
assert.equal(idle.percentage, 0);
assert.equal(idle.label, "Plan paused");
assert.equal(idle.finished, false);
});
test("late snapshots and unrelated projects cannot reverse or replace current progress", () => {
assert.equal(
acceptPlanSnapshot(base, { ...base, revision: 2 }, "project"),
base,
);
assert.equal(
acceptPlanSnapshot(
base,
{ ...base, projectId: "other", revision: 9 },
"project",
),
base,
);
assert.equal(
acceptPlanSnapshot(
base,
{ ...base, id: "older", createdAt: "2025-01-01T00:00:00Z" },
"project",
),
base,
);
const next = { ...base, revision: 4 };
assert.equal(acceptPlanSnapshot(base, next, "project"), next);
});
test("out-of-order step completion measures results, never the current step index", () => {
const p = {
...base,
steps: [
base.steps[0]!,
{ ...base.steps[1]!, status: "completed" as const },
],
};
assert.equal(planProgressState(p, true).percentage, 50);
});
test("failed steps remain unresolved; explicit skips and completed results finish a plan", () => {
assert.equal(
planProgressState(
{
...base,
steps: [{ ...base.steps[0]!, status: "failed" }, base.steps[1]!],
},
false,
).label,
"Plan needs attention",
);
const p = {
...base,
steps: [
{ ...base.steps[0]!, status: "skipped" as const },
{ ...base.steps[1]!, status: "completed" as const },
],
};
const display = planProgressState(p, true);
assert.equal(display.finished, true);
assert.equal(display.spinning, false);
assert.equal(display.completed, 1);
assert.equal(display.skipped, 1);
assert.equal(display.percentage, 100);
});
43 changes: 43 additions & 0 deletions apps/web/src/modules/editor/chat/plan/plan-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Plan } from "@doable/shared/types/ai";
export function acceptPlanSnapshot(
current: Plan | null,
next: Plan | null,
projectId: string,
): Plan | null {
if (next && next.projectId !== projectId)
return current?.projectId === projectId ? current : null;
if (!next) return null;
if (current?.id === next.id && (current.revision ?? 0) > (next.revision ?? 0))
return current;
if (
current?.projectId === projectId &&
current.id !== next.id &&
Date.parse(current.createdAt) > Date.parse(next.createdAt)
)
return current;
return next;
}
export function planProgressState(plan: Plan, running: boolean) {
const completed = plan.steps.filter((s) => s.status === "completed").length;
const skipped = plan.steps.filter((s) => s.status === "skipped").length;
const failed = plan.steps.some((s) => s.status === "failed");
const finished =
plan.status === "completed" ||
(plan.steps.length > 0 && completed + skipped === plan.steps.length);
return {
completed,
skipped,
percentage: plan.steps.length
? Math.round(((completed + skipped) / plan.steps.length) * 100)
: 0,
finished,
label: finished
? "Plan finished"
: running
? "Executing plan"
: failed
? "Plan needs attention"
: "Plan paused",
spinning: running && !finished,
};
}
8 changes: 8 additions & 0 deletions apps/web/src/modules/editor/chat/plan/plan-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SkipForward,
ChevronDown,
X,
XCircle,
GripVertical,
} from "lucide-react";
import type { PlanStep as PlanStepType } from "@doable/shared/types/ai";
Expand Down Expand Up @@ -49,6 +50,13 @@ const STATUS_CONFIG = {
iconColor: "text-green-500",
badge: null,
},
failed: {
icon: <XCircle className="h-3.5 w-3.5" />,
borderColor: "border-red-500/40",
leftBorder: "bg-red-500",
iconColor: "text-red-500",
badge: "Failed",
},
skipped: {
icon: <SkipForward className="h-3.5 w-3.5" />,
borderColor: "border-border/30",
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/modules/editor/hooks/use-chat-sse.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {acceptPlanSnapshot} from "../chat/plan/plan-state";
import { useEditorStore, type ChatMessage } from "./use-editor-store";
import type { SupabaseProvisionRequest, PendingIntegrationRequest } from "./use-chat-types";
import {
Expand Down Expand Up @@ -301,8 +302,10 @@ export function dispatchSSEEvent(
if (parsed.type === "plan") {
const plan = parsed.data?.plan;
if (plan) {
const accepted = acceptPlanSnapshot(store.activePlan,plan,store.projectId ?? "");
if(accepted !== plan)return {};
store.setActivePlan(plan);
store.setPlanPhase("reviewing");
store.setPlanPhase(plan.status === "draft" ? "reviewing" : "building");
store.setActiveAgentProgress({ phase: "planning", message: "Plan ready for review" });
ctx.updateMessageFields(ctx.assistantId, {
agentProgress: { phase: "planning", message: "Plan ready for review" },
Expand All @@ -313,7 +316,14 @@ export function dispatchSSEEvent(

// ─── Plan step update ─────────────────────────────────────
if (parsed.type === "plan_step_update") {
const { stepId, status, message } = parsed.data ?? {};
const { stepId, status, message, planId, plan } = parsed.data ?? {};
if(plan) {
const accepted=acceptPlanSnapshot(store.activePlan,plan,store.projectId ?? "");
if(accepted !== plan)return {};
store.setActivePlan(plan);
return {};
}
if(planId && store.activePlan?.id !== planId)return {};
if (stepId && status) {
store.updatePlanStep(stepId, { status });

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/modules/editor/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,14 +462,15 @@ export function useChat(
try {
const { getStoredTokens } = await import("@/lib/api");
const { accessToken } = getStoredTokens();
await fetch(`${API_BASE}/projects/${projectId}/plan/approve`, {
const response = await fetch(`${API_BASE}/projects/${projectId}/plan/approve`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ planId }),
});
if(!response.ok)throw new Error("Could not approve plan. Please try again.");
useEditorStore.getState().approvePlan();
setTimeout(() => {
sendMessage(
Expand Down
Loading