Skip to content

Commit 1b65c67

Browse files
committed
fix(app): polish plan-mode prompts and session timeline interactions
1 parent faa6322 commit 1b65c67

28 files changed

Lines changed: 704 additions & 341 deletions

packages/app/src/i18n/en.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,14 @@ export const dict = {
512512
"session.todo.title": "Todos",
513513
"session.todo.collapse": "Collapse",
514514
"session.todo.expand": "Expand",
515+
"session.question.planEnter.title": "Switch to Plan Mode?",
516+
"session.question.planEnter.description": "The assistant will pause edits and prepare a plan for your approval.",
517+
"session.question.planEnter.confirm": "Enter Plan Mode",
518+
"session.question.planEnter.dismiss": "Stay in Build Mode",
519+
"session.question.planExit.title": "Switch to Build Mode?",
520+
"session.question.planExit.description": "The plan is complete. Approve to start implementing.",
521+
"session.question.planExit.confirm": "Start Building",
522+
"session.question.planExit.dismiss": "Stay in Plan Mode",
515523

516524
"session.new.worktree.main": "Main branch",
517525
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",

packages/app/src/pages/session/composer/session-composer-region.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export function SessionComposerRegion(props: {
2525

2626
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
2727
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
28+
const activeDock = createMemo(() => {
29+
if (props.state.questionRequest()) return "question"
30+
if (props.state.permissionRequest()) return "permission"
31+
return "none"
32+
})
2833

2934
const previewPrompt = () =>
3035
prompt
@@ -55,15 +60,19 @@ export function SessionComposerRegion(props: {
5560
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
5661
}}
5762
>
58-
<Show when={props.state.questionRequest()} keyed>
63+
<Show when={activeDock() === "question" && props.state.questionRequest()} keyed>
5964
{(request) => (
6065
<div>
61-
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
66+
<SessionQuestionDock
67+
request={request}
68+
kind={props.state.questionKind()}
69+
onSubmit={props.onResponseSubmit}
70+
/>
6271
</div>
6372
)}
6473
</Show>
6574

66-
<Show when={props.state.permissionRequest()} keyed>
75+
<Show when={activeDock() === "permission" && props.state.permissionRequest()} keyed>
6776
{(request) => (
6877
<div>
6978
<SessionPermissionDock
@@ -78,7 +87,7 @@ export function SessionComposerRegion(props: {
7887
)}
7988
</Show>
8089

81-
<Show when={!props.state.blocked()}>
90+
<Show when={activeDock() === "none"}>
8291
<Show
8392
when={prompt.ready()}
8493
fallback={
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { Message, Part, QuestionRequest } from "@opencode-ai/sdk/v2"
3+
import { resolveQuestionKind, resolveSessionMode } from "./session-mode"
4+
5+
describe("resolveSessionMode", () => {
6+
test("defaults to build without messages", () => {
7+
expect(resolveSessionMode(undefined)).toBe("build")
8+
})
9+
10+
test("uses latest user agent mode", () => {
11+
const messages = [
12+
{ role: "user", agent: "build" },
13+
{ role: "assistant" },
14+
{ role: "user", agent: "plan" },
15+
] as Message[]
16+
expect(resolveSessionMode(messages)).toBe("plan")
17+
})
18+
19+
test("ignores non-build non-plan user agents", () => {
20+
const messages = [{ role: "user", agent: "custom" }, { role: "assistant" }] as Message[]
21+
expect(resolveSessionMode(messages)).toBe("build")
22+
})
23+
})
24+
25+
describe("resolveQuestionKind", () => {
26+
const request = {
27+
id: "req_1",
28+
sessionID: "ses_1",
29+
questions: [],
30+
tool: {
31+
messageID: "msg_1",
32+
callID: "call_1",
33+
},
34+
} as QuestionRequest
35+
36+
test("returns generic when question has no tool ref", () => {
37+
expect(resolveQuestionKind({ request: { ...request, tool: undefined }, parts: [] })).toBe("generic")
38+
})
39+
40+
test("detects plan_enter from matching tool part", () => {
41+
const parts = [{ type: "tool", callID: "call_1", tool: "plan_enter" }] as Part[]
42+
expect(resolveQuestionKind({ request, parts })).toBe("plan_enter")
43+
})
44+
45+
test("detects plan_exit from matching tool part", () => {
46+
const parts = [{ type: "tool", callID: "call_1", tool: "plan_exit" }] as Part[]
47+
expect(resolveQuestionKind({ request, parts })).toBe("plan_exit")
48+
})
49+
50+
test("returns generic on missing or mismatched part", () => {
51+
const parts = [{ type: "tool", callID: "call_2", tool: "plan_exit" }] as Part[]
52+
expect(resolveQuestionKind({ request, parts })).toBe("generic")
53+
})
54+
})

packages/app/src/pages/session/composer/session-composer-state.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useGlobalSync } from "@/context/global-sync"
77
import { useLanguage } from "@/context/language"
88
import { useSDK } from "@/context/sdk"
99
import { useSync } from "@/context/sync"
10+
import { type SessionMode, type SessionQuestionKind, resolveQuestionKind, resolveSessionMode } from "./session-mode"
1011

1112
export function createSessionComposerBlocked() {
1213
const params = useParams()
@@ -45,6 +46,21 @@ export function createSessionComposerState() {
4546
return globalSync.data.session_todo[id] ?? []
4647
})
4748

49+
const activeMode = createMemo((): SessionMode => {
50+
const id = params.id
51+
if (!id) return "build"
52+
return resolveSessionMode(sync.data.message[id])
53+
})
54+
55+
const questionKind = createMemo((): SessionQuestionKind => {
56+
const request = questionRequest()
57+
if (!request?.tool) return "generic"
58+
return resolveQuestionKind({
59+
request,
60+
parts: sync.data.part[request.tool.messageID],
61+
})
62+
})
63+
4864
const [store, setStore] = createStore({
4965
responding: undefined as string | undefined,
5066
dock: todos().length > 0,
@@ -149,6 +165,8 @@ export function createSessionComposerState() {
149165
permissionResponding,
150166
decide,
151167
todos,
168+
activeMode,
169+
questionKind,
152170
dock: () => store.dock,
153171
closing: () => store.closing,
154172
opening: () => store.opening,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Message, Part, QuestionRequest } from "@opencode-ai/sdk/v2"
2+
3+
export type SessionMode = "build" | "plan"
4+
export type SessionQuestionKind = "generic" | "plan_enter" | "plan_exit"
5+
6+
export function resolveSessionMode(messages: Message[] | undefined): SessionMode {
7+
if (!messages?.length) return "build"
8+
for (let i = messages.length - 1; i >= 0; i--) {
9+
const msg = messages[i]
10+
if (msg.role !== "user") continue
11+
if (msg.agent === "plan") return "plan"
12+
if (msg.agent === "build") return "build"
13+
}
14+
return "build"
15+
}
16+
17+
export function resolveQuestionKind(input: {
18+
request: QuestionRequest | undefined
19+
parts: Part[] | undefined
20+
}): SessionQuestionKind {
21+
const request = input.request
22+
if (!request?.tool) return "generic"
23+
const part = input.parts?.find((part) => part.type === "tool" && part.callID === request.tool?.callID)
24+
if (!part || part.type !== "tool") return "generic"
25+
if (part.tool === "plan_enter") return "plan_enter"
26+
if (part.tool === "plan_exit") return "plan_exit"
27+
return "generic"
28+
}

0 commit comments

Comments
 (0)