diff --git a/packages/app/src/components/plan-review-inline.tsx b/packages/app/src/components/plan-review-inline.tsx new file mode 100644 index 00000000000..488e54e0b83 --- /dev/null +++ b/packages/app/src/components/plan-review-inline.tsx @@ -0,0 +1,175 @@ +import { Component, createEffect, createSignal, Show } from "solid-js" +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) + */ +export const PlanReviewContent: Component<{ request: PlanReviewRequest }> = (props) => { + const sdk = useSDK() + + const [content, setContent] = createSignal(null) + const [loading, setLoading] = createSignal(true) + + // Load plan content on mount + createEffect(async () => { + try { + const response = await sdk.client.planReview.content({ requestID: props.request.id }) + setContent(response.data ?? "(Empty plan file)") + } catch { + setContent("(Unable to read plan file)") + } finally { + setLoading(false) + } + }) + + return ( +
+ {/* Header */} +
+
+ +
+ Plan + {getFilename(props.request.filePath)} +
+ + {/* Plan content with markdown */} +
+ +
+ + Loading plan... +
+
+ +
+ +
+
+
+
+ ) +} + +/** + * Docked controls for plan review (buttons, feedback input) + */ +export const PlanReviewControls: Component<{ request: PlanReviewRequest }> = (props) => { + const sdk = useSDK() + + const [rejectMode, setRejectMode] = createSignal(false) + const [feedback, setFeedback] = createSignal("") + const [submitting, setSubmitting] = createSignal(false) + + async function handleApprove() { + setSubmitting(true) + try { + await sdk.client.planReview.approve({ requestID: props.request.id }) + } finally { + setSubmitting(false) + } + } + + async function handleReject() { + if (!rejectMode()) { + setRejectMode(true) + return + } + setSubmitting(true) + try { + await sdk.client.planReview.reject({ requestID: props.request.id, feedback: feedback() || undefined }) + } finally { + setSubmitting(false) + } + } + + function handleDismiss() { + sdk.client.planReview.reject({ requestID: props.request.id }) + } + + function handleCancelReject() { + setRejectMode(false) + setFeedback("") + } + + return ( +
+
+ {/* Header row */} +
+
+
+ +
+ Plan Review + {getFilename(props.request.filePath)} +
+ + Awaiting Approval + +
+ + {/* Description */} +

+ Review the plan above. Approve to begin implementation, or reject with feedback to revise. +

+ + {/* Feedback input (shown in reject mode) */} + +
+ { + if (e.key === "Enter" && !submitting()) { + e.preventDefault() + handleReject() + } + }} + /> +
+
+ + {/* Actions */} +
+ + + + + + + + + +
+
+
+ ) +} + +/** + * @deprecated Use PlanReviewContent (inline) + PlanReviewControls (docked) instead + */ +export const PlanReviewInline: Component<{ request: PlanReviewRequest }> = (props) => { + return +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 96f8c63eab2..3695f118b27 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -17,6 +17,7 @@ import { type VcsInfo, type PermissionRequest, type QuestionRequest, + type PlanReviewRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" @@ -68,6 +69,9 @@ type State = { question: { [sessionID: string]: QuestionRequest[] } + plan_review: { + [sessionID: string]: PlanReviewRequest[] + } mcp: { [name: string]: McpStatus } @@ -139,6 +143,7 @@ function createGlobalSync() { todo: {}, permission: {}, question: {}, + plan_review: {}, mcp: {}, lsp: [], vcs: cache[0].value, @@ -310,6 +315,39 @@ function createGlobalSync() { } }) }), + // Load pending plan reviews + sdk.planReview.list().then((x) => { + const grouped: Record = {} + for (const review of x.data ?? []) { + if (!review?.id || !review.sessionID) continue + const existing = grouped[review.sessionID] + if (existing) { + existing.push(review) + continue + } + grouped[review.sessionID] = [review] + } + + batch(() => { + for (const sessionID of Object.keys(store.plan_review)) { + if (grouped[sessionID]) continue + setStore("plan_review", sessionID, []) + } + for (const [sessionID, reviews] of Object.entries(grouped)) { + setStore( + "plan_review", + sessionID, + reconcile( + reviews + .filter((r) => !!r?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), ]).then(() => { setStore("status", "complete") }) @@ -558,6 +596,42 @@ function createGlobalSync() { ) break } + case "plan_review.requested": { + const sessionID = event.properties.sessionID + const requests = store.plan_review[sessionID] + if (!requests) { + setStore("plan_review", sessionID, [event.properties]) + break + } + const result = Binary.search(requests, event.properties.id, (r) => r.id) + if (result.found) { + setStore("plan_review", sessionID, result.index, reconcile(event.properties)) + break + } + setStore( + "plan_review", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "plan_review.approved": + case "plan_review.rejected": { + const requests = store.plan_review[event.properties.sessionID] + if (!requests) break + const result = Binary.search(requests, event.properties.requestID, (r) => r.id) + if (!result.found) break + setStore( + "plan_review", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } case "lsp.updated": { const sdk = createOpencodeClient({ baseUrl: globalSDK.url, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f063ce35b40..1324f38b522 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -32,6 +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 { 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" @@ -211,6 +212,34 @@ export default function Page() { }) } + // Get current session + const session = createMemo(() => { + const id = params.id + if (!id) return undefined + 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)") function normalizeTab(tab: string) { @@ -1268,6 +1297,20 @@ export default function Page() { ) }} + + {/* Plan review inline - ephemeral message in chat */} + + {(request) => ( +
+ +
+ )} +
@@ -1307,6 +1350,14 @@ export default function Page() { "md:max-w-200": !showTabs(), }} > + {/* Plan review controls docked - visible when plan is pending */} + + {(request) => ( +
+ +
+ )} +
{ + // Signal to force reactivity when plan_review changes + const [planReviewVersion, setPlanReviewVersion] = createSignal(0) + const [store, setStore] = createStore<{ status: "loading" | "partial" | "complete" provider: Provider[] @@ -46,6 +52,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ question: { [sessionID: string]: QuestionRequest[] } + plan_review: { + [sessionID: string]: PlanReviewRequest[] + } config: Config session: Session[] session_status: { @@ -85,6 +94,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: [], permission: {}, question: {}, + plan_review: {}, command: [], provider: [], provider_default: {}, @@ -185,6 +195,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "plan_review.approved": + case "plan_review.rejected": { + const requests = store.plan_review[event.properties.sessionID] + if (!requests) break + + 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 + // 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 + } + case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break @@ -374,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 1294ab849e9..53e04f6d566 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -72,6 +72,7 @@ import { Filesystem } from "@/util/filesystem" import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" +import { PlanReviewContent, PlanReviewControls } from "./plan-review" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" @@ -127,6 +128,25 @@ export function Session() { if (session()?.parentID) return [] 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 [] + 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(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -1076,6 +1096,13 @@ export function Session() { )} + + {/* Plan review content inline - use box with dynamic id to force scrollbox update */} + + + {(request) => } + + 0}> @@ -1084,8 +1111,16 @@ export function Session() { 0}> + + {(request) => } + { prompt = r promptRef.set(r) @@ -1094,7 +1129,7 @@ export function Session() { r.set(route.initialPrompt) } }} - disabled={permissions().length > 0 || questions().length > 0} + disabled={permissions().length > 0 || questions().length > 0 || planReviews().length > 0} onSubmit={() => { toBottom() }} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx new file mode 100644 index 00000000000..8a948084da6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/plan-review.tsx @@ -0,0 +1,199 @@ +import { createSignal, createEffect, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { useKeybind } from "../../context/keybind" +import { useTheme } from "../../context/theme" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import type { PlanReview } from "@/session/plan-review" + +type PlanReviewRequest = PlanReview.Request + +/** + * Inline plan content that displays in the chat history (scrollable) + * This is ephemeral - it disappears when the plan is approved/rejected + */ +export function PlanReviewContent(props: { request: PlanReviewRequest }) { + const { theme, syntax } = useTheme() + + const [content, setContent] = createSignal(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 [rejectMode, setRejectMode] = createSignal(false) + + let textarea: TextareaRenderable | undefined + + function approve() { + sdk.client.planReview.approve({ + requestID: props.request.id, + }) + } + + function reject(feedback?: string) { + sdk.client.planReview.reject({ + requestID: props.request.id, + feedback, + }) + } + + useKeyboard((evt) => { + // When in reject mode (typing feedback) + if (rejectMode()) { + if (evt.name === "escape") { + evt.preventDefault() + setRejectMode(false) + return + } + if (evt.name === "return") { + evt.preventDefault() + const feedback = textarea?.plainText?.trim() ?? "" + reject(feedback || undefined) + return + } + // Let textarea handle all other keys + return + } + + // Normal mode + if (evt.name === "return") { + evt.preventDefault() + approve() + return + } + + if (evt.name === "r") { + evt.preventDefault() + setRejectMode(true) + return + } + + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + return + } + }) + + return ( + + + {/* Awaiting Approval badge */} + + AWAITING APPROVAL + + + {/* Reject feedback input */} + + + Feedback (optional): +