Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d2f987
feat: implement iterative plan mode with review workflow
KibbeWater Jan 12, 2026
6ed2e24
fix: inject plan file path into prompt and improve incremental writin…
KibbeWater Jan 12, 2026
07b6f46
fix: update plan prompt to explore first, then write plan
KibbeWater Jan 12, 2026
cbd8fec
fix: include plan files in session revert/unrevert
KibbeWater Jan 12, 2026
64f7bc9
fix: interrupt conversation when plan review is dismissed
KibbeWater Jan 12, 2026
2e2f0d6
fix: don't delete plan file during revert
KibbeWater Jan 12, 2026
089ee8d
feat: track plan file changes for proper revert support
KibbeWater Jan 12, 2026
913309f
feat: add plan review support to web interface
KibbeWater Jan 13, 2026
59ad27d
test: restore permission-task tests and add session-scoped coverage
KibbeWater Jan 13, 2026
9a29e1b
refactor: return plan.txt original with minor modifications
KibbeWater Jan 13, 2026
ed5e69f
refactor: fix diff on permission-task.test.ts
KibbeWater Jan 13, 2026
7ebc5c0
chore(ui): make reject send with reason when pressing enter
KibbeWater Jan 13, 2026
f2a68ad
refactor: remove PlanExitTool from registry
KibbeWater Jan 16, 2026
ccc7238
fix: align plan directory with upstream (plan -> plans)
KibbeWater Jan 16, 2026
d7aa966
feat(ui): inline plan review with markdown highlighting
KibbeWater Jan 16, 2026
f4de8dc
fix(ui): improve plan review styling to match app design
KibbeWater Jan 16, 2026
a7245a2
chore: regenerate SDK and fix toModelMessage options
KibbeWater Jan 16, 2026
05b4fcf
fix: restore ExitPlanModeTool under experimental flag
KibbeWater Jan 16, 2026
220f033
Merge branch 'dev' into feat/iterative-planning
KibbeWater Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions packages/app/src/components/plan-review-inline.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div class="px-4 md:px-6 py-4">
{/* Header */}
<div class="flex items-center gap-2 mb-3">
<div class="flex items-center justify-center size-6 rounded-md bg-surface-warning-weaker">
<Icon name="checklist" class="size-4 text-icon-warning" />
</div>
<span class="text-14-semibold text-text-strong">Plan</span>
<span class="text-12-regular text-text-subtle">{getFilename(props.request.filePath)}</span>
</div>

{/* Plan content with markdown */}
<div class="rounded-md border border-border-base bg-surface-inset-base overflow-hidden">
<Show when={loading()}>
<div class="p-4 flex items-center gap-2 text-text-weak">
<Spinner />
<span class="text-14-regular">Loading plan...</span>
</div>
</Show>
<Show when={!loading()}>
<div class="p-4 prose prose-sm max-w-none">
<Markdown text={content() ?? ""} />
</div>
</Show>
</div>
</div>
)
}

/**
* 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 (
<div class="bg-surface-raised-stronger-non-alpha shadow-xs-border rounded-md overflow-clip">
<div class="px-4 py-3">
{/* Header row */}
<div class="flex items-center justify-between gap-4 mb-3">
<div class="flex items-center gap-2 min-w-0">
<div class="flex items-center justify-center size-6 rounded-md bg-surface-warning-weaker shrink-0">
<Icon name="checklist" class="size-4 text-icon-warning" />
</div>
<span class="text-14-semibold text-text-strong">Plan Review</span>
<span class="text-12-regular text-text-subtle truncate">{getFilename(props.request.filePath)}</span>
</div>
<span class="text-11-medium text-text-warning bg-surface-warning-weaker px-1.5 py-0.5 rounded shrink-0">
Awaiting Approval
</span>
</div>

{/* Description */}
<p class="text-12-regular text-text-weak mb-3">
Review the plan above. Approve to begin implementation, or reject with feedback to revise.
</p>

{/* Feedback input (shown in reject mode) */}
<Show when={rejectMode()}>
<div class="mb-3">
<TextField
autofocus
label="Feedback (optional)"
placeholder="Enter feedback for revision..."
value={feedback()}
onChange={setFeedback}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Enter" && !submitting()) {
e.preventDefault()
handleReject()
}
}}
/>
</div>
</Show>

{/* Actions */}
<div class="flex justify-end gap-2">
<Show when={!rejectMode()}>
<Button type="button" variant="ghost" size="normal" onClick={handleDismiss} disabled={submitting()}>
Dismiss
</Button>
<Button type="button" variant="ghost" size="normal" onClick={handleReject} disabled={submitting()}>
Reject
</Button>
<Button type="button" variant="primary" size="normal" onClick={handleApprove} disabled={submitting()}>
{submitting() ? "Approving..." : "Approve"}
</Button>
</Show>
<Show when={rejectMode()}>
<Button type="button" variant="ghost" size="normal" onClick={handleCancelReject} disabled={submitting()}>
Cancel
</Button>
<Button type="button" variant="primary" size="normal" onClick={handleReject} disabled={submitting()}>
{submitting() ? "Submitting..." : "Submit Feedback"}
</Button>
</Show>
</div>
</div>
</div>
)
}

/**
* @deprecated Use PlanReviewContent (inline) + PlanReviewControls (docked) instead
*/
export const PlanReviewInline: Component<{ request: PlanReviewRequest }> = (props) => {
return <PlanReviewContent request={props.request} />
}
74 changes: 74 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -68,6 +69,9 @@ type State = {
question: {
[sessionID: string]: QuestionRequest[]
}
plan_review: {
[sessionID: string]: PlanReviewRequest[]
}
mcp: {
[name: string]: McpStatus
}
Expand Down Expand Up @@ -139,6 +143,7 @@ function createGlobalSync() {
todo: {},
permission: {},
question: {},
plan_review: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
Expand Down Expand Up @@ -310,6 +315,39 @@ function createGlobalSync() {
}
})
}),
// Load pending plan reviews
sdk.planReview.list().then((x) => {
const grouped: Record<string, PlanReviewRequest[]> = {}
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")
})
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1268,6 +1297,20 @@ export default function Page() {
)
}}
</For>

{/* Plan review inline - ephemeral message in chat */}
<Show when={pendingPlanReview()}>
{(request) => (
<div
classList={{
"min-w-0 w-full": true,
"md:max-w-200 md:mx-auto": !showTabs(),
}}
>
<PlanReviewContent request={request()} />
</div>
)}
</Show>
</div>
</div>
</div>
Expand Down Expand Up @@ -1307,6 +1350,14 @@ export default function Page() {
"md:max-w-200": !showTabs(),
}}
>
{/* Plan review controls docked - visible when plan is pending */}
<Show when={pendingPlanReview()}>
{(request) => (
<div class="mb-4">
<PlanReviewControls request={request()} />
</div>
)}
</Show>
<Show
when={prompt.ready()}
fallback={
Expand Down
Loading