diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4b97bfb89e64..e5198ceb55d7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -520,6 +520,14 @@ export const dict = { "session.todo.title": "Todos", "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", + "session.question.planEnter.title": "Switch to Plan Mode?", + "session.question.planEnter.description": "The assistant will pause edits and prepare a plan for your approval.", + "session.question.planEnter.confirm": "Enter Plan Mode", + "session.question.planEnter.dismiss": "Stay in Build Mode", + "session.question.planExit.title": "Switch to Build Mode?", + "session.question.planExit.description": "The plan is complete. Approve to start implementing.", + "session.question.planExit.confirm": "Start Building", + "session.question.planExit.dismiss": "Stay in Plan Mode", "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index cfd78ece8589..9a0e58df6e6c 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -25,6 +25,11 @@ export function SessionComposerRegion(props: { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) + const activeDock = createMemo(() => { + if (props.state.questionRequest()) return "question" + if (props.state.permissionRequest()) return "permission" + return "none" + }) const previewPrompt = () => prompt @@ -55,15 +60,19 @@ export function SessionComposerRegion(props: { "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > - + {(request) => (
- +
)}
- + {(request) => (
- + ({ @@ -14,46 +15,23 @@ const permission = (id: string, sessionID: string) => sessionID, }) as PermissionRequest -const question = (id: string, sessionID: string) => - ({ - id, - sessionID, - questions: [], - }) as QuestionRequest - -describe("sessionPermissionRequest", () => { - test("prefers the current session permission", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const permissions = { - root: [permission("perm-root", "root")], - child: [permission("perm-child", "child")], - } - - expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root") +describe("resolveSessionMode", () => { + test("defaults to build without messages", () => { + expect(resolveSessionMode(undefined)).toBe("build") }) - test("returns a nested child permission", () => { - const sessions = [ - session({ id: "root" }), - session({ id: "child", parentID: "root" }), - session({ id: "grand", parentID: "child" }), - session({ id: "other" }), - ] - const permissions = { - grand: [permission("perm-grand", "grand")], - other: [permission("perm-other", "other")], - } - - expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand") + test("uses latest user agent mode", () => { + const messages = [ + { role: "user", agent: "build" }, + { role: "assistant" }, + { role: "user", agent: "plan" }, + ] as Message[] + expect(resolveSessionMode(messages)).toBe("plan") }) - test("returns undefined without a matching tree permission", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const permissions = { - other: [permission("perm-other", "other")], - } - - expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined() + test("ignores non-build non-plan user agents", () => { + const messages = [{ role: "user", agent: "custom" }, { role: "assistant" }] as Message[] + expect(resolveSessionMode(messages)).toBe("build") }) test("skips filtered permissions in the current tree", () => { @@ -79,27 +57,33 @@ describe("sessionPermissionRequest", () => { }) }) -describe("sessionQuestionRequest", () => { - test("prefers the current session question", () => { - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const questions = { - root: [question("q-root", "root")], - child: [question("q-child", "child")], - } +describe("resolveQuestionKind", () => { + const request = { + id: "req_1", + sessionID: "ses_1", + questions: [], + tool: { + messageID: "msg_1", + callID: "call_1", + }, + } as QuestionRequest + + test("returns generic when question has no tool ref", () => { + expect(resolveQuestionKind({ request: { ...request, tool: undefined }, parts: [] })).toBe("generic") + }) - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-root") + test("detects plan_enter from matching tool part", () => { + const parts = [{ type: "tool", callID: "call_1", tool: "plan_enter" }] as Part[] + expect(resolveQuestionKind({ request, parts })).toBe("plan_enter") }) - test("returns a nested child question", () => { - const sessions = [ - session({ id: "root" }), - session({ id: "child", parentID: "root" }), - session({ id: "grand", parentID: "child" }), - ] - const questions = { - grand: [question("q-grand", "grand")], - } + test("detects plan_exit from matching tool part", () => { + const parts = [{ type: "tool", callID: "call_1", tool: "plan_exit" }] as Part[] + expect(resolveQuestionKind({ request, parts })).toBe("plan_exit") + }) - expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand") + test("returns generic on missing or mismatched part", () => { + const parts = [{ type: "tool", callID: "call_2", tool: "plan_exit" }] as Part[] + expect(resolveQuestionKind({ request, parts })).toBe("generic") }) }) diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 201846177853..fa7705f71de2 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { type SessionMode, type SessionQuestionKind, resolveQuestionKind, resolveSessionMode } from "./session-mode" import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree" export function createSessionComposerBlocked() { @@ -23,8 +24,6 @@ export function createSessionComposerBlocked() { const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id)) return createMemo(() => { - const id = params.id - if (!id) return false return !!permissionRequest() || !!questionRequest() }) } @@ -47,11 +46,7 @@ export function createSessionComposerState() { }) }) - const blocked = createMemo(() => { - const id = params.id - if (!id) return false - return !!permissionRequest() || !!questionRequest() - }) + const blocked = createSessionComposerBlocked() const todos = createMemo((): Todo[] => { const id = params.id @@ -59,6 +54,21 @@ export function createSessionComposerState() { return globalSync.data.session_todo[id] ?? [] }) + const activeMode = createMemo((): SessionMode => { + const id = params.id + if (!id) return "build" + return resolveSessionMode(sync.data.message[id]) + }) + + const questionKind = createMemo((): SessionQuestionKind => { + const request = questionRequest() + if (!request?.tool) return "generic" + return resolveQuestionKind({ + request, + parts: sync.data.part[request.tool.messageID], + }) + }) + const [store, setStore] = createStore({ responding: undefined as string | undefined, dock: todos().length > 0, @@ -163,6 +173,8 @@ export function createSessionComposerState() { permissionResponding, decide, todos, + activeMode, + questionKind, dock: () => store.dock, closing: () => store.closing, opening: () => store.opening, diff --git a/packages/app/src/pages/session/composer/session-mode.ts b/packages/app/src/pages/session/composer/session-mode.ts new file mode 100644 index 000000000000..fbca8874f225 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-mode.ts @@ -0,0 +1,28 @@ +import type { Message, Part, QuestionRequest } from "@opencode-ai/sdk/v2" + +export type SessionMode = "build" | "plan" +export type SessionQuestionKind = "generic" | "plan_enter" | "plan_exit" + +export function resolveSessionMode(messages: Message[] | undefined): SessionMode { + if (!messages?.length) return "build" + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "user") continue + if (msg.agent === "plan") return "plan" + if (msg.agent === "build") return "build" + } + return "build" +} + +export function resolveQuestionKind(input: { + request: QuestionRequest | undefined + parts: Part[] | undefined +}): SessionQuestionKind { + const request = input.request + if (!request?.tool) return "generic" + const part = input.parts?.find((part) => part.type === "tool" && part.callID === request.tool?.callID) + if (!part || part.type !== "tool") return "generic" + if (part.tool === "plan_enter") return "plan_enter" + if (part.tool === "plan_exit") return "plan_exit" + return "generic" +} diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index fd2ced3dc814..0e01c9d814b1 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" @@ -7,8 +7,13 @@ import { showToast } from "@opencode-ai/ui/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" +import type { SessionQuestionKind } from "./session-mode" -export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { +export const SessionQuestionDock: Component<{ + request: QuestionRequest + kind?: SessionQuestionKind + onSubmit: () => void +}> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -16,6 +21,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const total = createMemo(() => questions().length) const [store, setStore] = createStore({ + requestID: props.request.id, + kind: props.kind ?? "generic", tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], @@ -31,6 +38,52 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) + const customEnabled = createMemo(() => question()?.custom !== false) + const kind = createMemo(() => store.kind) + const transition = createMemo(() => kind() === "plan_enter" || kind() === "plan_exit") + const showCustom = createMemo(() => customEnabled() && !transition()) + + createEffect(() => { + if (props.request.id === store.requestID) return + setStore({ + requestID: props.request.id, + kind: props.kind ?? "generic", + tab: 0, + answers: [], + custom: [], + customOn: [], + editing: false, + }) + }) + + createEffect(() => { + if (store.editing || store.sending) return + setStore("kind", props.kind ?? "generic") + }) + + const transitionTitle = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.title") + if (kind() === "plan_exit") return language.t("session.question.planExit.title") + return "" + }) + + const transitionDescription = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.description") + if (kind() === "plan_exit") return language.t("session.question.planExit.description") + return "" + }) + + const transitionConfirm = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.confirm") + if (kind() === "plan_exit") return language.t("session.question.planExit.confirm") + return language.t("ui.common.submit") + }) + + const transitionDismiss = createMemo(() => { + if (kind() === "plan_enter") return language.t("session.question.planEnter.dismiss") + if (kind() === "plan_exit") return language.t("session.question.planExit.dismiss") + return language.t("ui.common.dismiss") + }) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) @@ -142,6 +195,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) + const confirmTransition = () => { + const first = questions()[0] + const choice = first?.options[0]?.label + if (!choice) return + void reply([[choice]]) + } + const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) if (custom) setStore("custom", store.tab, answer) @@ -159,6 +219,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customToggle = () => { if (store.sending) return + if (!showCustom()) return if (!multi()) { setStore("customOn", store.tab, true) setStore("editing", true) @@ -181,6 +242,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customOpen = () => { if (store.sending) return + if (!showCustom()) return if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) @@ -189,7 +251,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const selectOption = (optIndex: number) => { if (store.sending) return - if (optIndex === options().length) { + if (showCustom() && optIndex === options().length) { customOpen() return } @@ -237,191 +299,217 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit return ( (root = el)} header={ <> -
{summary()}
-
- - {(_, i) => ( -
+ {summary()}
}> +
{transitionTitle()}
+
+ +
+ + {(_, i) => ( +
+
} footer={ <> -
- 0}> - + + +
+ } + > +
+ - - -
+ +
} > -
{question()?.question}
- {language.t("ui.question.singleHint")}}> -
{language.t("ui.question.multiHint")}
+ {question()?.question}}> +
{transitionDescription()}
-
- - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( -
}> +
{language.t("ui.question.multiHint")}
+
+
+ +
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + + - - - {opt.label} - - {opt.description} - - - - ) - }} - - - - - - {language.t("ui.messagePart.option.typeOwnAnswer")} - {input() || language.t("ui.question.custom.placeholder")} - - - } - > -
{ - if (store.sending) { - e.preventDefault() - return + + {language.t("ui.messagePart.option.typeOwnAnswer")} + + {input() || language.t("ui.question.custom.placeholder")} + + + } - if (e.target instanceof HTMLTextAreaElement) return - const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') - if (input instanceof HTMLTextAreaElement) input.focus() - }} - onSubmit={(e) => { - e.preventDefault() - commitCustom() - }} - > - - - {language.t("ui.messagePart.option.typeOwnAnswer")} -