)
}
+
+/**
+ * @deprecated Use PlanReviewContent (inline) + PlanReviewControls (docked) instead
+ */
+export const PlanReviewInline: Component<{ request: PlanReviewRequest }> = (props) => {
+ return
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 8c415a1fb3d..1590dd8f8a3 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -32,7 +32,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
-import { PlanReviewInline } from "@/components/plan-review-inline"
+import { PlanReviewContent, PlanReviewControls } from "@/components/plan-review-inline"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -212,12 +212,32 @@ export default function Page() {
})
}
- // Get pending plan review for current session
- const pendingPlanReview = createMemo(() => {
+ // Get current session
+ const session = createMemo(() => {
const id = params.id
if (!id) return undefined
- const requests = sync.data.plan_review[id]
- return requests?.[0]
+ return sync.data.session.find((s) => s.id === id)
+ })
+
+ // Get all child sessions (including current session)
+ const childSessions = createMemo(() => {
+ const current = session()
+ if (!current) return []
+ const parentID = current.parentID ?? current.id
+ return sync.data.session
+ .filter((x) => x.parentID === parentID || x.id === parentID)
+ .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ })
+
+ // Get pending plan review from all child sessions (not if we're a child session ourselves)
+ const pendingPlanReview = createMemo(() => {
+ const current = session()
+ if (!current) return undefined
+ // Don't show plan reviews if we're viewing a child session
+ if (current.parentID) return undefined
+ // Aggregate plan reviews from all child sessions
+ const reviews = childSessions().flatMap((x) => sync.data.plan_review[x.id] ?? [])
+ return reviews[0]
})
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -1216,6 +1236,15 @@ export default function Page() {
)
}}
+
+ {/* Plan review inline - ephemeral message in chat */}
+
+ {(request) => (
+
+
+
+ )}
+
@@ -1255,11 +1284,11 @@ export default function Page() {
"md:max-w-200": !showTabs(),
}}
>
- {/* Plan review inline - shown when a plan is awaiting approval */}
+ {/* Plan review controls docked - visible when plan is pending */}
{(request) => (
-
+
)}
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 123a66425ef..a4892e74df2 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -26,7 +26,7 @@ import type { Snapshot } from "@/snapshot"
import type { PlanReview } from "@/session/plan-review"
import { useExit } from "./exit"
import { useArgs } from "./args"
-import { batch, onMount } from "solid-js"
+import { batch, onMount, createSignal } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -35,6 +35,9 @@ type PlanReviewRequest = PlanReview.Request
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
+ // Signal to force reactivity when plan_review changes
+ const [planReviewVersion, setPlanReviewVersion] = createSignal(0)
+
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
@@ -196,37 +199,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "plan_review.rejected": {
const requests = store.plan_review[event.properties.sessionID]
if (!requests) break
- const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
- if (!match.found) break
- setStore(
- "plan_review",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(match.index, 1)
- }),
- )
+
+ const filtered = requests.filter((r) => r.id !== event.properties.requestID)
+ if (filtered.length === requests.length) break // Not found
+
+ // Set to empty array (not delete) to properly trigger reactivity
+ // Setting directly on the key ensures Solid tracks the change
+ setStore("plan_review", event.properties.sessionID, filtered)
+ // Increment version to force memo re-evaluation
+ setPlanReviewVersion((v) => v + 1)
break
}
case "plan_review.requested": {
const request = event.properties
- const requests = store.plan_review[request.sessionID]
- if (!requests) {
- setStore("plan_review", request.sessionID, [request])
- break
- }
- const match = Binary.search(requests, request.id, (r) => r.id)
- if (match.found) {
- setStore("plan_review", request.sessionID, match.index, reconcile(request))
- break
- }
- setStore(
- "plan_review",
- request.sessionID,
- produce((draft) => {
- draft.splice(match.index, 0, request)
- }),
- )
+ // Replace any existing plan reviews for this session with the new one
+ // (only one plan review should be pending per session at a time)
+ setStore("plan_review", request.sessionID, [request])
+ setPlanReviewVersion((v) => v + 1)
break
}
@@ -419,6 +409,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = {
data: store,
set: setStore,
+ planReviewVersion,
get status() {
return store.status
},
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index f5ce0f1506a..8af88c7b77a 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -72,7 +72,7 @@ import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
-import { PlanReviewPrompt } from "./plan-review"
+import { PlanReviewContent, PlanReviewControls } from "./plan-review"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
@@ -129,8 +129,23 @@ export function Session() {
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const planReviews = createMemo(() => {
+ // Access version to establish dependency when plan reviews change
+ // (accessing the signal creates the reactive dependency, even if value unused)
+ void sync.planReviewVersion()
+
if (session()?.parentID) return []
- return children().flatMap((x) => sync.data.plan_review[x.id] ?? [])
+ const childIDs = children().map((x) => x.id)
+
+ // Access store properties directly through the proxy (NOT Object.entries)
+ // This ensures Solid.js tracks reactivity properly
+ const result: typeof sync.data.plan_review[string] = []
+ for (const sessionID of childIDs) {
+ const sessionReviews = sync.data.plan_review[sessionID]
+ if (sessionReviews && sessionReviews.length > 0) {
+ result.push(...sessionReviews)
+ }
+ }
+ return result
})
const pending = createMemo(() => {
@@ -1026,6 +1041,13 @@ export function Session() {
)}
+
+ {/* Plan review content inline - use box with dynamic id to force scrollbox update */}
+
+
+ {(request) => }
+
+ 0}>
@@ -1034,8 +1056,8 @@ export function Session() {
0}>
- 0}>
-
+
+ {(request) => }
(null)
+ const [loading, setLoading] = createSignal(true)
+
+ // Load plan content - tracks props.request.id reactively
+ createEffect(() => {
+ const filePath = props.request.filePath
+ setLoading(true)
+ setContent(null)
+
+ Bun.file(filePath).text()
+ .then((result) => {
+ setContent(result)
+ })
+ .catch(() => {
+ setContent("(Unable to read plan file)")
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ })
+
+ return (
+
+
+ {/* Header */}
+
+ Plan Review
+ · {props.request.filePath}
+
+
+ {/* Content with markdown rendering */}
+
+ Loading plan...
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Docked controls for plan review (keyboard hints, feedback input)
+ */
+export function PlanReviewControls(props: { request: PlanReviewRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
- const [content, setContent] = createSignal(null)
- const [loading, setLoading] = createSignal(true)
const [rejectMode, setRejectMode] = createSignal(false)
- const [scrollOffset, setScrollOffset] = createSignal(0)
let textarea: TextareaRenderable | undefined
- // Load plan content on mount
- createEffect(async () => {
- try {
- const result = await Bun.file(props.request.filePath).text()
- setContent(result)
- } catch {
- setContent("(Unable to read plan file)")
- } finally {
- setLoading(false)
- }
- })
-
function approve() {
sdk.client.planReview.approve({
requestID: props.request.id,
@@ -48,22 +108,6 @@ export function PlanReviewPrompt(props: { request: PlanReviewRequest }) {
})
}
- const contentLines = () => {
- const c = content()
- if (!c) return []
- return c.split("\n")
- }
-
- const visibleLines = () => {
- const lines = contentLines()
- const offset = scrollOffset()
- const maxVisible = 20 // Show max 20 lines at a time
- return lines.slice(offset, offset + maxVisible)
- }
-
- const canScrollUp = () => scrollOffset() > 0
- const canScrollDown = () => scrollOffset() + 20 < contentLines().length
-
useKeyboard((evt) => {
// When in reject mode (typing feedback)
if (rejectMode()) {
@@ -100,77 +144,24 @@ export function PlanReviewPrompt(props: { request: PlanReviewRequest }) {
reject()
return
}
-
- // Scroll navigation
- if (evt.name === "up" || evt.name === "k") {
- evt.preventDefault()
- if (canScrollUp()) {
- setScrollOffset((o) => o - 1)
- }
- return
- }
-
- if (evt.name === "down" || evt.name === "j") {
- evt.preventDefault()
- if (canScrollDown()) {
- setScrollOffset((o) => o + 1)
- }
- return
- }
-
- if (evt.name === "pageup") {
- evt.preventDefault()
- setScrollOffset((o) => Math.max(0, o - 10))
- return
- }
-
- if (evt.name === "pagedown") {
- evt.preventDefault()
- setScrollOffset((o) => Math.min(contentLines().length - 20, o + 10))
- return
- }
})
return (
-
- {/* Header */}
-
-
- Plan Review
-
-
-
-
- {props.request.filePath}
-
-
- {/* Content */}
-
-
- Loading plan...
-
-
-
-
-
- {visibleLines().map((line) => (
- {line || " "}
- ))}
-
- ... ({contentLines().length - scrollOffset() - 20} more lines)
-
-
-
+
+ {/* Awaiting Approval badge */}
+
+ AWAITING APPROVAL
+
{/* Reject feedback input */}
-
+ Feedback (optional):
-
- {/* Footer with keybindings */}
-
+ {/* Keyboard hints */}
-
- {"↑↓"} scroll
-
-
- enter approve
+
+ enter approve
-
- r reject
+
+ r reject
-
- esc dismiss
+
+ esc dismiss
-
- enter submit feedback
+
+ enter submit feedback
-
- esc cancel
+
+ esc cancel
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 501cf2e306b..d8f536c9ab1 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -511,6 +511,17 @@ export namespace MessageV2 {
type: "step-start",
})
if (part.type === "tool") {
+ // Skip tool parts if the tool isn't in the current tools object
+ // This handles cases like plan→build transition where exit_plan_mode
+ // was used by plan agent but isn't available to build agent
+ if (options?.tools && !options.tools[part.tool]) {
+ // Convert to a text summary instead of a tool call
+ assistantMessage.parts.push({
+ type: "text",
+ text: `[Tool ${part.tool} was called${part.state.status === "completed" ? " and completed successfully" : ""}]`,
+ })
+ continue
+ }
if (part.state.status === "completed") {
if (part.state.attachments?.length) {
result.push({
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index d72f28dccc9..25b24d59243 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -292,38 +292,46 @@ export namespace SessionPrompt {
}
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
+
+ // Check for plan→build transition (ExitPlanMode was approved)
+ // When approved, ExitPlanMode updates the user message's agent to "build"
+ // but the assistant message was still from the plan agent with finish: "tool-calls"
+ // This check must run BEFORE the normal exit check since tool-calls finish would skip it
+ if (
+ lastAssistant?.finish === "tool-calls" &&
+ lastAssistant.agent === "plan" &&
+ lastUser.agent === "build" &&
+ lastUser.id < lastAssistant.id
+ ) {
+ log.info("plan→build transition detected, continuing with build agent", { sessionID })
+ // Create a synthetic continuation user message for the build agent
+ const continueMsg: MessageV2.User = {
+ id: Identifier.ascending("message"),
+ role: "user",
+ sessionID,
+ time: { created: Date.now() },
+ agent: "build",
+ model: lastUser.model,
+ }
+ await Session.updateMessage(continueMsg)
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: continueMsg.id,
+ sessionID,
+ type: "text",
+ synthetic: true,
+ text: "Continue with the approved plan.",
+ time: { start: Date.now(), end: Date.now() },
+ })
+ // Don't exit - continue the loop with the new user message
+ continue
+ }
+
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
- // Check for plan→build transition (ExitPlanMode was approved)
- // When approved, ExitPlanMode updates the user message's agent to "build"
- // but the assistant message was still from the plan agent
- if (lastAssistant.agent === "plan" && lastUser.agent === "build") {
- log.info("plan→build transition detected, continuing with build agent", { sessionID })
- // Create a synthetic continuation user message for the build agent
- const continueMsg: MessageV2.User = {
- id: Identifier.ascending("message"),
- role: "user",
- sessionID,
- time: { created: Date.now() },
- agent: "build",
- model: lastUser.model,
- }
- await Session.updateMessage(continueMsg)
- await Session.updatePart({
- id: Identifier.ascending("part"),
- messageID: continueMsg.id,
- sessionID,
- type: "text",
- synthetic: true,
- text: "Continue with the approved plan.",
- time: { start: Date.now(), end: Date.now() },
- })
- // Don't exit - continue the loop with the new user message
- continue
- }
log.info("exiting loop", { sessionID })
break
}
From f4de8dc2d3578fe39ab10765520b226457de9bfd Mon Sep 17 00:00:00 2001
From: KibbeWater <35224538+KibbeWater@users.noreply.github.com>
Date: Fri, 16 Jan 2026 23:14:18 +0100
Subject: [PATCH 16/18] fix(ui): improve plan review styling to match app
design
---
.../app/src/components/plan-review-inline.tsx | 125 ++++++++++--------
packages/app/src/pages/session.tsx | 7 +-
2 files changed, 79 insertions(+), 53 deletions(-)
diff --git a/packages/app/src/components/plan-review-inline.tsx b/packages/app/src/components/plan-review-inline.tsx
index 03ddca70b61..488e54e0b83 100644
--- a/packages/app/src/components/plan-review-inline.tsx
+++ b/packages/app/src/components/plan-review-inline.tsx
@@ -3,8 +3,10 @@ import { Button } from "@opencode-ai/ui/button"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { Markdown } from "@opencode-ai/ui/markdown"
+import { Spinner } from "@opencode-ai/ui/spinner"
import { useSDK } from "@/context/sdk"
import type { PlanReviewRequest } from "@opencode-ai/sdk/v2/client"
+import { getFilename } from "@opencode-ai/util/path"
/**
* Inline plan content that displays in the chat history (scrollable)
@@ -28,18 +30,23 @@ export const PlanReviewContent: Component<{ request: PlanReviewRequest }> = (pro
})
return (
-