diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3ef8b38d642..2af3cb3fd71 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -683,6 +683,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": + case "thread.proposed-plan-removed": case "thread.activity-appended": case "thread.approval-response-requested": case "thread.user-input-response-requested": { @@ -871,6 +872,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.proposed-plan-removed": + yield* projectionThreadProposedPlanRepository.deleteByPlanId({ + planId: event.payload.planId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..8294d5412bb 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,7 @@ import { ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, + ThreadProposedPlanRemovedPayload as ContractsThreadProposedPlanRemovedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, @@ -37,6 +38,7 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; +export const ThreadProposedPlanRemovedPayload = ContractsThreadProposedPlanRemovedPayloadSchema; export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 05ae5b0eb00..062e11ec066 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -653,6 +653,85 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.proposed-plan.promote": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const message = thread.messages.find((entry) => entry.id === command.messageId); + if (!message) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' not found in thread '${command.threadId}'.`, + }); + } + if (message.role !== "assistant") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' is not an assistant message.`, + }); + } + const planMarkdown = message.text.trim(); + if (planMarkdown.length === 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' has no text to promote.`, + }); + } + const planId = `plan:${command.threadId}:promoted:${command.messageId}`; + const existingPlan = thread.proposedPlans.find((entry) => entry.id === planId); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: { + id: planId, + turnId: message.turnId ?? null, + planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? command.createdAt, + updatedAt: command.createdAt, + }, + }, + }; + } + + case "thread.proposed-plan.revert": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingPlan = thread.proposedPlans.find((entry) => entry.id === command.planId); + if (!existingPlan) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Proposed plan '${command.planId}' not found in thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-removed", + payload: { + threadId: command.threadId, + planId: command.planId, + }, + }; + } + case "thread.turn.diff.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d7..68142a85b39 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -20,6 +20,7 @@ import { ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, + ThreadProposedPlanRemovedPayload, ThreadRuntimeModeSetPayload, ThreadUnarchivedPayload, ThreadRevertedPayload, @@ -499,6 +500,31 @@ export function projectEvent( }; }); + case "thread.proposed-plan-removed": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadProposedPlanRemovedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const proposedPlans = thread.proposedPlans.filter((entry) => entry.id !== payload.planId); + if (proposedPlans.length === thread.proposedPlans.length) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + proposedPlans, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.turn-diff-completed": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index ccd322feb23..605eb88c7e5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -4,6 +4,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { toPersistenceSqlError } from "../Errors.ts"; import { + DeleteProjectionThreadProposedPlanByIdInput, DeleteProjectionThreadProposedPlansInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, @@ -76,6 +77,14 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const deleteProjectionThreadProposedPlanRowById = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlanByIdInput, + execute: ({ planId }) => sql` + DELETE FROM projection_thread_proposed_plans + WHERE plan_id = ${planId} + `, + }); + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => upsertProjectionThreadProposedPlanRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), @@ -97,10 +106,18 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ), ); + const deleteByPlanId: ProjectionThreadProposedPlanRepositoryShape["deleteByPlanId"] = (input) => + deleteProjectionThreadProposedPlanRowById(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByPlanId:query"), + ), + ); + return { upsert, listByThreadId, deleteByThreadId, + deleteByPlanId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index a68bedb8c37..aba35bafdef 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -34,6 +34,12 @@ export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ export type DeleteProjectionThreadProposedPlansInput = typeof DeleteProjectionThreadProposedPlansInput.Type; +export const DeleteProjectionThreadProposedPlanByIdInput = Schema.Struct({ + planId: OrchestrationProposedPlanId, +}); +export type DeleteProjectionThreadProposedPlanByIdInput = + typeof DeleteProjectionThreadProposedPlanByIdInput.Type; + export interface ProjectionThreadProposedPlanRepositoryShape { readonly upsert: ( proposedPlan: ProjectionThreadProposedPlan, @@ -44,6 +50,9 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly deleteByThreadId: ( input: DeleteProjectionThreadProposedPlansInput, ) => Effect.Effect; + readonly deleteByPlanId: ( + input: DeleteProjectionThreadProposedPlanByIdInput, + ) => Effect.Effect; } export class ProjectionThreadProposedPlanRepository extends Context.Service< diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 556504d6cf4..6ff7f7855c2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -117,6 +117,7 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + readonly interactionMode: "plan" | "default"; nextSyntheticAssistantBlockIndex: number; } @@ -173,6 +174,7 @@ interface ClaudeSessionContext { lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + currentInteractionMode: "plan" | "default"; stopped: boolean; } @@ -1359,7 +1361,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: { readonly planMarkdown: string; readonly toolUseId?: string | undefined; - readonly rawSource: "claude.sdk.message" | "claude.sdk.permission"; + readonly rawSource: + | "claude.sdk.message" + | "claude.sdk.permission" + | "client.user-promoted"; readonly rawMethod: string; readonly rawPayload: unknown; }, @@ -1956,6 +1961,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: context.currentInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; context.session = { @@ -2973,6 +2979,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTokenUsage: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + currentInteractionMode: permissionMode === "plan" ? "plan" : "default", stopped: false, }; yield* Ref.set(contextRef, context); @@ -3085,14 +3092,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( try: () => context.query.setPermissionMode("plan"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.currentInteractionMode = "plan"; } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.currentInteractionMode = "default"; } const turnId = TurnId.make(yield* Random.nextUUIDv4); + const resolvedInteractionMode = context.currentInteractionMode; const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, @@ -3100,6 +3110,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: resolvedInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 476140dd3ae..fba294defba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -84,6 +84,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< type: | "thread.message-sent" | "thread.proposed-plan-upserted" + | "thread.proposed-plan-removed" | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" @@ -93,6 +94,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< return ( event.type === "thread.message-sent" || event.type === "thread.proposed-plan-upserted" || + event.type === "thread.proposed-plan-removed" || event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..9a7aaea6f2d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3366,6 +3366,83 @@ export default function ChatView(props: ChatViewProps) { environmentId, ]); + const latestPromotableAssistantMessageId = useMemo(() => { + const messages = activeThread?.messages; + if (!messages || messages.length === 0) return undefined; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role === "assistant" && message.text.trim().length > 0) { + return message.id; + } + } + return undefined; + }, [activeThread?.messages]); + + const canPromoteToPlan = + interactionMode === "plan" && + activeProposedPlan === null && + latestPromotableAssistantMessageId !== undefined && + isServerThread && + !isSendBusy && + !isConnecting && + !activeEnvironmentUnavailable; + + const onPromoteToPlan = useCallback(() => { + if (!activeThread || !latestPromotableAssistantMessageId) return; + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + void api.orchestration + .dispatchCommand({ + type: "thread.proposed-plan.promote", + commandId: newCommandId(), + threadId: activeThread.id, + messageId: latestPromotableAssistantMessageId, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not promote message to plan", + description: + err instanceof Error ? err.message : "An error occurred while promoting the message.", + }), + ); + }); + }, [activeThread, latestPromotableAssistantMessageId]); + + const canRevertPlan = + activeProposedPlan !== null && + /:promoted:/.test(activeProposedPlan.id) && + isServerThread && + !isSendBusy && + !isConnecting && + !activeEnvironmentUnavailable; + + const onRevertPlan = useCallback(() => { + if (!activeThread || !activeProposedPlan) return; + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + void api.orchestration + .dispatchCommand({ + type: "thread.proposed-plan.revert", + commandId: newCommandId(), + threadId: activeThread.id, + planId: activeProposedPlan.id, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revert plan", + description: + err instanceof Error ? err.message : "An error occurred while reverting the plan.", + }), + ); + }); + }, [activeThread, activeProposedPlan]); + const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; @@ -3655,6 +3732,10 @@ export default function ChatView(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} + canRevertPlan={canRevertPlan} + onRevertPlan={onRevertPlan} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c6..9b0b059396f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,6 +88,7 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + CornerRightUpIcon, ListTodoIcon, type LucideIcon, LockIcon, @@ -186,6 +187,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showPlanToggle: boolean; planSidebarLabel: string; planSidebarOpen: boolean; + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; @@ -256,6 +259,23 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop + {props.interactionMode === "plan" && props.canPromoteToPlan ? ( + <> + + + + ) : null} + {props.showPlanToggle ? ( <> @@ -304,9 +324,11 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + canRevertPlan?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onRevertPlan?: () => void; }) { return ( <> @@ -326,9 +348,11 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + canRevertPlan={props.canRevertPlan ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} + onRevertPlan={props.onRevertPlan} /> ); @@ -454,6 +478,12 @@ export interface ChatComposerProps { shouldAutoScrollRef: React.MutableRefObject; scheduleStickToBottom: () => void; + // Promote-to-plan + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; + canRevertPlan: boolean; + onRevertPlan: () => void; + // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; @@ -539,6 +569,10 @@ export const ChatComposer = memo( composerTerminalContextsRef, shouldAutoScrollRef, scheduleStickToBottom, + canPromoteToPlan, + onPromoteToPlan, + canRevertPlan, + onRevertPlan, onSend, onInterrupt, onImplementPlanInNewThread, @@ -2353,6 +2387,8 @@ export const ChatComposer = memo( runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onRuntimeModeChange={handleRuntimeModeChange} @@ -2377,6 +2413,8 @@ export const ChatComposer = memo( showPlanToggle={showPlanSidebarToggle} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} onTogglePlanSidebar={togglePlanSidebar} @@ -2408,9 +2446,11 @@ export const ChatComposer = memo( isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + canRevertPlan={canRevertPlan} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onRevertPlan={onRevertPlan} /> diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 49eb5fbb94b..4056a7ee533 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -152,6 +152,8 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str onPromptChange={onPromptChange} /> } + canPromoteToPlan={false} + onPromoteToPlan={vi.fn()} onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} onRuntimeModeChange={vi.fn()} @@ -303,6 +305,8 @@ describe("CompactComposerControlsMenu", () => { planSidebarOpen={false} runtimeMode="approval-required" showInteractionModeToggle={false} + canPromoteToPlan={false} + onPromoteToPlan={vi.fn()} onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} onRuntimeModeChange={vi.fn()} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..f6a32c0443a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,6 +1,6 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { CornerRightUpIcon, EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, @@ -20,6 +20,8 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; @@ -73,6 +75,15 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Auto-accept edits Full access + {props.interactionMode === "plan" && props.canPromoteToPlan ? ( + <> + + + + Promote to plan + + + ) : null} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index fbeb9de30b8..5b62b7bedc8 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -24,9 +24,11 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + canRevertPlan?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onRevertPlan?: () => void; } export const formatPendingPrimaryActionLabel = (input: { @@ -63,9 +65,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + canRevertPlan = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, + onRevertPlan, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } @@ -186,6 +190,14 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ > Implement in a new thread + {canRevertPlan && onRevertPlan ? ( + void onRevertPlan()} + > + Revert plan to message + + ) : null} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a7767672fa1..8f735bba0e6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1161,12 +1161,21 @@ export function deriveTimelineEntries( proposedPlans: ProposedPlan[], workEntries: WorkLogEntry[], ): TimelineEntry[] { - const messageRows: TimelineEntry[] = messages.map((message) => ({ - id: message.id, - kind: "message", - createdAt: message.createdAt, - message, - })); + const promotedSourceMessageIds = new Set(); + for (const proposedPlan of proposedPlans) { + const match = /:promoted:(.+)$/.exec(proposedPlan.id); + if (match) { + promotedSourceMessageIds.add(match[1]); + } + } + const messageRows: TimelineEntry[] = messages + .filter((message) => !promotedSourceMessageIds.has(message.id)) + .map((message) => ({ + id: message.id, + kind: "message", + createdAt: message.createdAt, + message, + })); const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ id: proposedPlan.id, kind: "proposed-plan", diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 921054df34f..7a7b8647a26 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1512,6 +1512,21 @@ function applyEnvironmentOrchestrationEvent( }; }); + case "thread.proposed-plan-removed": + return updateThreadState(state, event.payload.threadId, (thread) => { + const proposedPlans = thread.proposedPlans.filter( + (entry) => entry.id !== event.payload.planId, + ); + if (proposedPlans.length === thread.proposedPlans.length) { + return thread; + } + return { + ...thread, + proposedPlans, + updatedAt: event.occurredAt, + }; + }); + case "thread.turn-diff-completed": return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 44d840d1499..2acb3e407fe 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -644,6 +644,22 @@ const ThreadSessionStopCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadProposedPlanPromoteCommand = Schema.Struct({ + type: Schema.Literal("thread.proposed-plan.promote"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + +const ThreadProposedPlanRevertCommand = Schema.Struct({ + type: Schema.Literal("thread.proposed-plan.revert"), + commandId: CommandId, + threadId: ThreadId, + planId: OrchestrationProposedPlanId, + createdAt: IsoDateTime, +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -661,6 +677,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadProposedPlanPromoteCommand, + ThreadProposedPlanRevertCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -682,6 +700,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadProposedPlanPromoteCommand, + ThreadProposedPlanRevertCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -788,6 +808,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.session-stop-requested", "thread.session-set", "thread.proposed-plan-upserted", + "thread.proposed-plan-removed", "thread.turn-diff-completed", "thread.activity-appended", ]); @@ -948,6 +969,11 @@ export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ proposedPlan: OrchestrationProposedPlan, }); +export const ThreadProposedPlanRemovedPayload = Schema.Struct({ + threadId: ThreadId, + planId: OrchestrationProposedPlanId, +}); + export const ThreadTurnDiffCompletedPayload = Schema.Struct({ threadId: ThreadId, turnId: TurnId, @@ -1086,6 +1112,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.proposed-plan-upserted"), payload: ThreadProposedPlanUpsertedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.proposed-plan-removed"), + payload: ThreadProposedPlanRemovedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-diff-completed"), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb41..f5312df2304 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), + Schema.Literal("client.user-promoted"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"),