From 76934ad804da198356b59c83a7a4f19329005c8b Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 18:09:14 +0530 Subject: [PATCH 1/4] Implement fallback plan capture for text-only plan-mode responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a heuristic to detect and capture plan-like text when Claude completes a plan-mode turn without calling ExitPlanMode. This makes the Implement button appear for structured text plans that don't explicitly use the SDK tool. The fallback gates on all of: - Turn ran in plan mode - No ExitPlanMode already captured - No pending user input request - Turn completed successfully The heuristic checks for: - Text length >= 200 chars (filters trivial responses) - Structural markers (headings, bullet/numbered lists) - Not starting with refusal stems - Either 3+ total list items OR a plan-related heading keyword Captures via the same emitProposedPlanCompleted path, with rawMethod "claude/text-fallback" for observability. Deduplication prevents double-firing when ExitPlanMode already captured the turn. No web/client changes required—the capture flows through existing turn.proposed.completed → activeProposedPlan → Implement button path. --- .../src/provider/Layers/ClaudeAdapter.ts | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 556504d6cf4..c995b1c14d9 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; } @@ -785,6 +786,49 @@ function extractExitPlanModePlan(value: unknown): string | undefined { : undefined; } +function looksLikePlan(text: string): boolean { + const trimmed = text.trim(); + + if (trimmed.length < 200) { + return false; + } + + const headingMatch = /^#{1,4} \S/m.test(trimmed); + const orderedListMatch = /^\s*\d+\.\s+\S/m.test(trimmed); + const bulletedListMatch = /^\s*[-*]\s+\S/m.test(trimmed); + + if (!headingMatch && !orderedListMatch && !bulletedListMatch) { + return false; + } + + const firstLineChars = trimmed.substring(0, 120).replace(/^#+\s*/, ""); + const refusalStems = [ + /^i can't/i, + /^i cannot/i, + /^i'm not able/i, + /^i am unable/i, + /^i don't have enough/i, + /^could you clarify/i, + /^can you clarify/i, + /^before i /i, + /^to help you, i need/i, + ]; + + if (refusalStems.some((stem) => stem.test(firstLineChars))) { + return false; + } + + const orderedListCount = (trimmed.match(/^\s*\d+\.\s+\S/gm) || []).length; + const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length; + const totalListItems = orderedListCount + bulletedListCount; + + const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/i.test( + trimmed, + ); + + return totalListItems >= 3 || planHeadingMatch; +} + function exitPlanCaptureKey(input: { readonly toolUseId?: string | undefined; readonly planMarkdown: string; @@ -1359,7 +1403,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" + | "claude.sdk.text-fallback"; readonly rawMethod: string; readonly rawPayload: unknown; }, @@ -1543,6 +1590,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + if ( + turnState.interactionMode === "plan" && + turnState.capturedProposedPlanKeys.size === 0 && + context.pendingUserInputs.size === 0 && + status === "completed" && + result?.subtype === "success" + ) { + let lastNonEmptyBlock: AssistantTextBlockState | undefined; + for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) { + const block = turnState.assistantTextBlockOrder[i]; + const blockText = block.fallbackText.trim(); + if (blockText.length > 0) { + lastNonEmptyBlock = block; + break; + } + } + + if (lastNonEmptyBlock && looksLikePlan(lastNonEmptyBlock.fallbackText)) { + yield* emitProposedPlanCompleted(context, { + planMarkdown: lastNonEmptyBlock.fallbackText, + rawSource: "claude.sdk.text-fallback", + rawMethod: "claude/text-fallback", + rawPayload: result, + }); + } + } + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", @@ -3093,6 +3167,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const turnId = TurnId.make(yield* Random.nextUUIDv4); + const resolvedInteractionMode = + input.interactionMode ?? context.session.interactionMode ?? "default"; const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, @@ -3100,6 +3176,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: resolvedInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; From 3f4e22e9752d7be056836d42927a28ffe83edb17 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 18:33:28 +0530 Subject: [PATCH 2/4] Track interaction mode state in Claude adapter - Store currentInteractionMode in context to properly reflect plan/default mode - Use tracked mode instead of input-based derivation for resolvedInteractionMode - Add null safety check for text block access - Support claude.sdk.text-fallback event source --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 14 ++++++++++---- packages/contracts/src/providerRuntime.ts | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c995b1c14d9..1000f682ee3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -174,6 +174,7 @@ interface ClaudeSessionContext { lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + currentInteractionMode: "plan" | "default"; stopped: boolean; } @@ -1600,8 +1601,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( let lastNonEmptyBlock: AssistantTextBlockState | undefined; for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) { const block = turnState.assistantTextBlockOrder[i]; - const blockText = block.fallbackText.trim(); - if (blockText.length > 0) { + if (!block) { + continue; + } + if (block.fallbackText.trim().length > 0) { lastNonEmptyBlock = block; break; } @@ -2030,6 +2033,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: "default", nextSyntheticAssistantBlockIndex: -1, }; context.session = { @@ -3047,6 +3051,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); @@ -3159,16 +3164,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 = - input.interactionMode ?? context.session.interactionMode ?? "default"; + const resolvedInteractionMode = context.currentInteractionMode; const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb41..77491639e0d 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("claude.sdk.text-fallback"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"), From 087a96bd5c7cf7b66668e5aca1aca5afbef138fb Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 18:50:21 +0530 Subject: [PATCH 3/4] Track interaction mode state in Claude adapter - Initialize interactionMode from context instead of hardcoding - Fix plan detection regex to support multiline matching with 'm' flag --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1000f682ee3..d5e1541463c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -823,7 +823,7 @@ function looksLikePlan(text: string): boolean { const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length; const totalListItems = orderedListCount + bulletedListCount; - const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/i.test( + const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/im.test( trimmed, ); @@ -2033,7 +2033,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), - interactionMode: "default", + interactionMode: context.currentInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; context.session = { From 30d31cf4cc850b68858c598f0a4ad8a41207b4f4 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sat, 9 May 2026 16:30:48 +0530 Subject: [PATCH 4/4] Add manual plan promotion/revert commands - Introduce thread.proposed-plan.promote to allow users to promote assistant messages to proposed plans - Introduce thread.proposed-plan.revert to allow reverting promoted plans back to messages - Remove automatic text-fallback plan capture (looksLikePlan logic) - Update backend event handling, persistence layer, and frontend UI for plan mode interaction --- .../Layers/ProjectionPipeline.ts | 7 ++ apps/server/src/orchestration/Schemas.ts | 2 + apps/server/src/orchestration/decider.ts | 79 ++++++++++++++++++ apps/server/src/orchestration/projector.ts | 26 ++++++ .../Layers/ProjectionThreadProposedPlans.ts | 17 ++++ .../Services/ProjectionThreadProposedPlans.ts | 9 +++ .../src/provider/Layers/ClaudeAdapter.ts | 74 +---------------- apps/server/src/ws.ts | 2 + apps/web/src/components/ChatView.tsx | 81 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 40 +++++++++ .../CompactComposerControlsMenu.browser.tsx | 4 + .../chat/CompactComposerControlsMenu.tsx | 13 ++- .../chat/ComposerPrimaryActions.tsx | 12 +++ apps/web/src/session-logic.ts | 21 +++-- apps/web/src/store.ts | 15 ++++ packages/contracts/src/orchestration.ts | 31 +++++++ packages/contracts/src/providerRuntime.ts | 2 +- 17 files changed, 354 insertions(+), 81 deletions(-) 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 d5e1541463c..6ff7f7855c2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -787,49 +787,6 @@ function extractExitPlanModePlan(value: unknown): string | undefined { : undefined; } -function looksLikePlan(text: string): boolean { - const trimmed = text.trim(); - - if (trimmed.length < 200) { - return false; - } - - const headingMatch = /^#{1,4} \S/m.test(trimmed); - const orderedListMatch = /^\s*\d+\.\s+\S/m.test(trimmed); - const bulletedListMatch = /^\s*[-*]\s+\S/m.test(trimmed); - - if (!headingMatch && !orderedListMatch && !bulletedListMatch) { - return false; - } - - const firstLineChars = trimmed.substring(0, 120).replace(/^#+\s*/, ""); - const refusalStems = [ - /^i can't/i, - /^i cannot/i, - /^i'm not able/i, - /^i am unable/i, - /^i don't have enough/i, - /^could you clarify/i, - /^can you clarify/i, - /^before i /i, - /^to help you, i need/i, - ]; - - if (refusalStems.some((stem) => stem.test(firstLineChars))) { - return false; - } - - const orderedListCount = (trimmed.match(/^\s*\d+\.\s+\S/gm) || []).length; - const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length; - const totalListItems = orderedListCount + bulletedListCount; - - const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/im.test( - trimmed, - ); - - return totalListItems >= 3 || planHeadingMatch; -} - function exitPlanCaptureKey(input: { readonly toolUseId?: string | undefined; readonly planMarkdown: string; @@ -1407,7 +1364,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( readonly rawSource: | "claude.sdk.message" | "claude.sdk.permission" - | "claude.sdk.text-fallback"; + | "client.user-promoted"; readonly rawMethod: string; readonly rawPayload: unknown; }, @@ -1591,35 +1548,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - if ( - turnState.interactionMode === "plan" && - turnState.capturedProposedPlanKeys.size === 0 && - context.pendingUserInputs.size === 0 && - status === "completed" && - result?.subtype === "success" - ) { - let lastNonEmptyBlock: AssistantTextBlockState | undefined; - for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) { - const block = turnState.assistantTextBlockOrder[i]; - if (!block) { - continue; - } - if (block.fallbackText.trim().length > 0) { - lastNonEmptyBlock = block; - break; - } - } - - if (lastNonEmptyBlock && looksLikePlan(lastNonEmptyBlock.fallbackText)) { - yield* emitProposedPlanCompleted(context, { - planMarkdown: lastNonEmptyBlock.fallbackText, - rawSource: "claude.sdk.text-fallback", - rawMethod: "claude/text-fallback", - rawPayload: result, - }); - } - } - const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", 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 77491639e0d..f5312df2304 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,7 +24,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), - Schema.Literal("claude.sdk.text-fallback"), + Schema.Literal("client.user-promoted"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"),