diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 6e6b503df..e2aa9280e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -366,6 +366,45 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps snake_case plan statuses to canonical turn.plan.updated events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-plan-updated"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "turn/plan/updated", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + explanation: "Working through the plan", + plan: [ + { step: "Inspect files", status: "completed" }, + { step: "Apply patch", status: "in_progress" }, + ], + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "turn.plan.updated"); + if (firstEvent.value.type !== "turn.plan.updated") { + return; + } + assert.deepStrictEqual(firstEvent.value.payload.plan, [ + { step: "Inspect files", status: "completed" }, + { step: "Apply patch", status: "inProgress" }, + ]); + }), + ); + it.effect("maps plan deltas to canonical proposed-plan delta events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index da341bb70..a2b98b30f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -443,6 +443,16 @@ function extractProposedPlanMarkdown(text: string | undefined): string | undefin return planMarkdown && planMarkdown.length > 0 ? planMarkdown : undefined; } +function normalizePlanStepStatus(status: unknown): "pending" | "inProgress" | "completed" { + if (status === "completed") { + return "completed"; + } + if (status === "inProgress" || status === "in_progress") { + return "inProgress"; + } + return "pending"; +} + function asRuntimeItemId(itemId: ProviderItemId): RuntimeItemId { return RuntimeItemId.makeUnsafe(itemId); } @@ -833,10 +843,7 @@ function mapToRuntimeEvents( .filter((entry): entry is Record => entry !== undefined) .map((entry) => ({ step: asString(entry.step) ?? "step", - status: - entry.status === "completed" || entry.status === "inProgress" - ? entry.status - : "pending", + status: normalizePlanStepStatus(entry.status), })), }, }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f274ed113..ee5eb2503 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -874,6 +874,11 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); + const activePlanTurnId = activePlan?.turnId ?? null; + const activePendingUserInputRequestId = activePendingUserInput?.requestId ?? null; + const hasPendingPlanFeedback = + activePendingUserInputRequestId !== null && + (activePlanTurnId !== null || interactionMode === "plan"); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -928,6 +933,23 @@ export default function ChatView({ threadId }: ChatViewProps) { activePendingUserInput?.requestId, activePendingProgress?.activeQuestion?.id, ]); + useEffect(() => { + if (!hasPendingPlanFeedback) { + return; + } + const turnKey = + activePlanTurnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null; + if (!turnKey || planSidebarDismissedForTurnRef.current === turnKey) { + return; + } + setPlanSidebarOpen(true); + }, [ + activeLatestTurn?.turnId, + activePlanTurnId, + hasPendingPlanFeedback, + sidebarProposedPlan?.turnId, + ]); + useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1921,7 +1943,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; + const turnKey = + activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? activeLatestTurn?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } @@ -1930,7 +1953,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } return !open; }); - }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); + }, [activeLatestTurn?.turnId, activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -5048,14 +5071,24 @@ export default function ChatView({ threadId }: ChatViewProps) { {planSidebarOpen ? ( { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. - const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; + const turnKey = + activePlan?.turnId ?? + sidebarProposedPlan?.turnId ?? + activeLatestTurn?.turnId ?? + null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } diff --git a/apps/web/src/components/PlanChecklist.tsx b/apps/web/src/components/PlanChecklist.tsx index 827c50247..be2f494c8 100644 --- a/apps/web/src/components/PlanChecklist.tsx +++ b/apps/web/src/components/PlanChecklist.tsx @@ -1,6 +1,7 @@ import { memo } from "react"; import { CheckIcon, LoaderIcon } from "lucide-react"; import { cn } from "~/lib/utils"; +import { Badge } from "./ui/badge"; // --------------------------------------------------------------------------- // Types @@ -11,6 +12,12 @@ export interface PlanChecklistItemData { text: string; /** Execution status. */ status: "pending" | "inProgress" | "completed"; + /** Optional supporting note shown below the step. */ + note?: string; + /** Optional status label override shown beside the step. */ + statusText?: string; + /** Optional badge tone for the status label override. */ + statusTone?: "success" | "info" | "warning"; } interface PlanChecklistProps { @@ -50,6 +57,14 @@ const PlanChecklist = memo(function PlanChecklist({ {items.length === 1 ? "To-do" : "To-dos"} · {completionMode} + {completedCount > 0 ? ( + <> + · + + {completedCount === items.length ? "All done" : `${completedCount} done`} + + + ) : null}

@@ -57,7 +72,7 @@ const PlanChecklist = memo(function PlanChecklist({
{items.map((item, index) => ( {/* Progress summary */} - {completedCount > 0 && completedCount < items.length ? ( + {completedCount > 0 ? (
- - {completedCount}/{items.length} + + {completedCount === items.length ? "Done" : `${completedCount}/${items.length} done`}
) : null} @@ -101,6 +116,8 @@ const PlanChecklistRow = memo(function PlanChecklistRow({ isLast: boolean; live: boolean; }) { + const statusBadge = resolveChecklistStatusBadge(item); + return (
{/* Status indicator */}
- +
{/* Text */} -

- {item.text} -

+
+

+ {item.text} +

+ {item.note ? ( +

+ {item.note} +

+ ) : null} +
+ + {statusBadge ? ( + + {statusBadge.label} + + ) : null} {/* Item number */} @@ -144,9 +183,11 @@ const PlanChecklistRow = memo(function PlanChecklistRow({ function ChecklistStatusIndicator({ status, + statusTone, live, }: { status: PlanChecklistItemData["status"]; + statusTone?: PlanChecklistItemData["statusTone"]; live: boolean; }) { if (status === "completed") { @@ -158,6 +199,13 @@ function ChecklistStatusIndicator({ } if (status === "inProgress") { + if (statusTone === "warning") { + return ( + + ? + + ); + } return ( {live ? ( @@ -177,5 +225,26 @@ function ChecklistStatusIndicator({ ); } +function resolveChecklistStatusBadge( + item: PlanChecklistItemData, +): { label: string; variant: "success" | "info" | "warning" } | null { + if (item.statusText && item.statusTone) { + return { + label: item.statusText, + variant: item.statusTone, + }; + } + + if (item.status === "completed") { + return { label: "Done", variant: "success" }; + } + + if (item.status === "inProgress") { + return { label: "Working", variant: "info" }; + } + + return null; +} + export default PlanChecklist; export type { PlanChecklistProps }; diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 43f561116..94f0d8587 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,11 +1,11 @@ import { memo, useState, useCallback, useRef, useEffect, useMemo } from "react"; import { type TimestampFormat } from "../appSettings"; import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; import { ScrollArea } from "./ui/scroll-area"; import ChatMarkdown from "./ChatMarkdown"; import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon, PanelRightCloseIcon } from "lucide-react"; -import type { ActivePlanState } from "../session-logic"; -import type { LatestProposedPlanState } from "../session-logic"; +import type { ActivePlanState, LatestProposedPlanState, PendingUserInput } from "../session-logic"; import { proposedPlanTitle, buildProposedPlanMarkdownFilename, @@ -14,12 +14,14 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { extractPlanChecklistItems } from "../planChecklist"; -import PlanChecklist from "./PlanChecklist"; +import PlanChecklist, { type PlanChecklistItemData } from "./PlanChecklist"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { readNativeApi } from "~/nativeApi"; import { toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { type PendingUserInputProgress } from "../pendingUserInput"; +import { cn } from "~/lib/utils"; import { Schema } from "effect"; const PLAN_SIDEBAR_WIDTH_STORAGE_KEY = "plan_sidebar_width"; @@ -33,6 +35,12 @@ interface PlanSidebarProps { markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; + activePendingUserInput: PendingUserInput | null; + activePendingProgress: PendingUserInputProgress | null; + activePendingIsResponding: boolean; + onSelectPendingUserInputOption: (questionId: string, optionLabel: string) => void; + onAdvancePendingUserInput: () => void; + onFocusComposer: () => void; onClose: () => void; } @@ -139,16 +147,44 @@ function useResizablePlanSidebar() { }; } +function findFeedbackStepIndex( + steps: ActivePlanState["steps"] | undefined, + feedbackRequested: boolean, +): number | null { + if (!feedbackRequested || !steps || steps.length === 0) { + return null; + } + + const inProgressIndex = steps.findIndex((step) => step.status === "inProgress"); + if (inProgressIndex !== -1) { + return inProgressIndex; + } + + const nextPendingIndex = steps.findIndex((step) => step.status !== "completed"); + if (nextPendingIndex !== -1) { + return nextPendingIndex; + } + + return steps.length - 1; +} + const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, markdownCwd, workspaceRoot, + activePendingUserInput, + activePendingProgress, + activePendingIsResponding, + onSelectPendingUserInputOption, + onAdvancePendingUserInput, + onFocusComposer, onClose, }: PlanSidebarProps) { const hasActiveSteps = (activePlan?.steps.length ?? 0) > 0; const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const pendingAdvanceTimerRef = useRef(null); const { copyToClipboard, isCopied } = useCopyToClipboard(); const { width, railProps } = useResizablePlanSidebar(); const progress = usePlanProgress(activePlan?.steps); @@ -156,14 +192,39 @@ const PlanSidebar = memo(function PlanSidebar({ const planMarkdown = activeProposedPlan?.planMarkdown ?? null; const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; + const feedbackQuestion = activePendingProgress?.activeQuestion ?? null; + const feedbackStepIndex = useMemo( + () => findFeedbackStepIndex(activePlan?.steps, activePendingUserInput !== null), + [activePendingUserInput, activePlan?.steps], + ); + const feedbackStep = + feedbackStepIndex !== null && activePlan?.steps[feedbackStepIndex] + ? activePlan.steps[feedbackStepIndex] + : null; // Derive checklist items: prefer live execution steps; fall back to markdown extraction. // Always normalised to { text, status } for the PlanChecklist component. - const checklistItems = useMemo< - Array<{ text: string; status: "pending" | "inProgress" | "completed" }> - >(() => { + const checklistItems = useMemo(() => { if (hasActiveSteps && activePlan) { - return activePlan.steps.map((s) => ({ text: s.step, status: s.status })); + return activePlan.steps.map((step, index) => { + const isFeedbackStep = + feedbackQuestion !== null && feedbackStepIndex !== null && index === feedbackStepIndex; + return { + text: step.step, + status: step.status, + ...(isFeedbackStep + ? { + note: activePendingIsResponding + ? "Submitting your answer." + : activePendingProgress?.usingCustomAnswer + ? "Custom answer drafted in the composer. Submit when ready." + : "Waiting for your feedback to continue.", + statusText: activePendingIsResponding ? "Sending" : "Needs input", + statusTone: "warning" as const, + } + : {}), + }; + }); } if (planMarkdown) { const extracted = extractPlanChecklistItems(planMarkdown); @@ -175,7 +236,15 @@ const PlanSidebar = memo(function PlanSidebar({ } } return []; - }, [hasActiveSteps, activePlan, planMarkdown]); + }, [ + hasActiveSteps, + activePlan, + planMarkdown, + feedbackQuestion, + feedbackStepIndex, + activePendingIsResponding, + activePendingProgress?.usingCustomAnswer, + ]); const hasChecklist = checklistItems.length > 0; @@ -186,6 +255,14 @@ const PlanSidebar = memo(function PlanSidebar({ } }, [hasChecklist, planMarkdown]); + useEffect(() => { + return () => { + if (pendingAdvanceTimerRef.current !== null) { + window.clearTimeout(pendingAdvanceTimerRef.current); + } + }; + }, []); + const handleCopyPlan = useCallback(() => { if (!planMarkdown) return; copyToClipboard(planMarkdown); @@ -228,6 +305,33 @@ const PlanSidebar = memo(function PlanSidebar({ ); }, [planMarkdown, workspaceRoot]); + const handleSelectPendingOption = useCallback( + (questionId: string, optionLabel: string) => { + onSelectPendingUserInputOption(questionId, optionLabel); + if (pendingAdvanceTimerRef.current !== null) { + window.clearTimeout(pendingAdvanceTimerRef.current); + } + pendingAdvanceTimerRef.current = window.setTimeout(() => { + pendingAdvanceTimerRef.current = null; + onAdvancePendingUserInput(); + }, 200); + }, + [onAdvancePendingUserInput, onSelectPendingUserInputOption], + ); + + const handlePendingAction = useCallback(() => { + if (activePendingProgress?.usingCustomAnswer && activePendingProgress.canAdvance) { + onAdvancePendingUserInput(); + return; + } + onFocusComposer(); + }, [ + activePendingProgress?.canAdvance, + activePendingProgress?.usingCustomAnswer, + onAdvancePendingUserInput, + onFocusComposer, + ]); + return (
- - {progress.completed}/{progress.total} + 0 + ? "text-emerald-700/80 dark:text-emerald-300/80" + : "text-muted-foreground/50", + )} + > + {progress.completed === progress.total + ? "Done" + : `${progress.completed}/${progress.total} done`}
) : null} @@ -309,9 +422,104 @@ const PlanSidebar = memo(function PlanSidebar({
{/* Explanation */} {activePlan?.explanation ? ( -

- {activePlan.explanation} -

+
+

+ Latest Note +

+

+ {activePlan.explanation} +

+
+ ) : null} + + {activePendingUserInput && feedbackQuestion ? ( +
+
+ + {feedbackStepIndex !== null ? `Step ${feedbackStepIndex + 1}` : "Plan input"} + + {activePendingUserInput.questions.length > 1 ? ( + + {(activePendingProgress?.questionIndex ?? 0) + 1}/ + {activePendingUserInput.questions.length} questions + + ) : null} + + {feedbackQuestion.header} + +
+ {feedbackStep ? ( +

+ {feedbackStep.step} +

+ ) : null} +

+ {feedbackQuestion.question} +

+
+ {feedbackQuestion.options.map((option, index) => { + const isSelected = + !activePendingProgress?.usingCustomAnswer && + activePendingProgress?.selectedOptionLabel === option.label; + return ( + + ); + })} +
+
+

+ {activePendingProgress?.usingCustomAnswer + ? "Custom answer ready in the composer." + : "Choose an option here or type a custom answer in the composer."} +

+ +
+
) : null} {/* Checklist (primary view) */} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 02bb423d4..b813f9ec1 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -326,7 +326,7 @@ describe("deriveActivePlanState", () => { turnId: "turn-1", payload: { explanation: "Refined plan", - plan: [{ step: "Implement Codex user input", status: "inProgress" }], + plan: [{ step: "Implement Codex user input", status: "in_progress" }], }, }), ]; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 250fbef16..c09928b2f 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -366,11 +366,9 @@ export function deriveActivePlanState( if (typeof record.step !== "string") { return null; } - const status = - record.status === "completed" || record.status === "inProgress" ? record.status : "pending"; return { step: record.step, - status, + status: normalizePlanStepStatus(record.status), }; }) .filter( @@ -394,6 +392,16 @@ export function deriveActivePlanState( }; } +function normalizePlanStepStatus(status: unknown): "pending" | "inProgress" | "completed" { + if (status === "completed") { + return "completed"; + } + if (status === "inProgress" || status === "in_progress") { + return "inProgress"; + } + return "pending"; +} + export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, diff --git a/package.json b/package.json index 4994f3d98..a0356c34f 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs", "regenerate:brand-assets": "python3 scripts/generate-brand-assets.py", - "prepare": "husky && node scripts/patch-effect-language-service.ts" + "prepare": "husky && node scripts/patch-effect-language-service.ts && node scripts/patch-effect-smol-peer-installs.mjs" }, "devDependencies": { "@types/node": "catalog:", diff --git a/scripts/patch-effect-smol-peer-installs.mjs b/scripts/patch-effect-smol-peer-installs.mjs new file mode 100644 index 000000000..e67873d1e --- /dev/null +++ b/scripts/patch-effect-smol-peer-installs.mjs @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; + +const rootDir = path.resolve(import.meta.dirname, ".."); +const rootEffectDir = path.join(rootDir, "node_modules", "effect"); + +const nestedEffectDirs = [ + path.join(rootDir, "node_modules", "@effect", "platform-node", "node_modules", "effect"), + path.join(rootDir, "node_modules", "@effect", "platform-node-shared", "node_modules", "effect"), + path.join(rootDir, "node_modules", "@effect", "sql-sqlite-bun", "node_modules", "effect"), + path.join(rootDir, "node_modules", "@effect", "vitest", "node_modules", "effect"), +]; + +async function pathExists(targetPath) { + try { + await fsp.lstat(targetPath); + return true; + } catch { + return false; + } +} + +async function isSymlinkToRootEffect(targetPath) { + try { + const stat = await fsp.lstat(targetPath); + if (!stat.isSymbolicLink()) { + return false; + } + const realTarget = await fsp.realpath(targetPath); + const realRootEffect = await fsp.realpath(rootEffectDir); + return realTarget === realRootEffect; + } catch { + return false; + } +} + +async function patchNestedEffectInstall(targetPath) { + const parentDir = path.dirname(targetPath); + await fsp.mkdir(parentDir, { recursive: true }); + + if (await isSymlinkToRootEffect(targetPath)) { + return; + } + + if (await pathExists(targetPath)) { + await fsp.rm(targetPath, { recursive: true, force: true }); + } + + const symlinkType = process.platform === "win32" ? "junction" : "dir"; + await fsp.symlink(rootEffectDir, targetPath, symlinkType); + console.log(`patched nested effect install: ${path.relative(rootDir, targetPath)}`); +} + +async function main() { + if (!fs.existsSync(rootEffectDir)) { + console.warn("skipping nested effect patch because root effect install is missing"); + return; + } + + await Promise.all(nestedEffectDirs.map((targetPath) => patchNestedEffectInstall(targetPath))); +} + +await main();