Skip to content
8 changes: 8 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}})",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,15 +60,19 @@ export function SessionComposerRegion(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
<Show when={activeDock() === "question" && props.state.questionRequest()} keyed>
{(request) => (
<div>
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
<SessionQuestionDock
request={request}
kind={props.state.questionKind()}
onSubmit={props.onResponseSubmit}
/>
</div>
)}
</Show>

<Show when={props.state.permissionRequest()} keyed>
<Show when={activeDock() === "permission" && props.state.permissionRequest()} keyed>
{(request) => (
<div>
<SessionPermissionDock
Expand All @@ -78,7 +87,7 @@ export function SessionComposerRegion(props: {
)}
</Show>

<Show when={!props.state.blocked()}>
<Show when={activeDock() === "none"}>
<Show
when={prompt.ready()}
fallback={
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
import type { Message, Part, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
import { resolveQuestionKind, resolveSessionMode } from "./session-mode"
import { sessionPermissionRequest } from "./session-request-tree"

const session = (input: { id: string; parentID?: string }) =>
({
Expand All @@ -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", () => {
Expand All @@ -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")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
})
}
Expand All @@ -47,18 +46,29 @@ 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
if (!id) return []
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,
Expand Down Expand Up @@ -163,6 +173,8 @@ export function createSessionComposerState() {
permissionResponding,
decide,
todos,
activeMode,
questionKind,
dock: () => store.dock,
closing: () => store.closing,
opening: () => store.opening,
Expand Down
28 changes: 28 additions & 0 deletions packages/app/src/pages/session/composer/session-mode.ts
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading