chore: refactor composer/dock components (#14328)
parent
f8dad0ae17
commit
49cc872c44
|
|
@ -332,6 +332,163 @@ export async function withSession<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seedSystem = [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Follow the user's instruction exactly.",
|
||||||
|
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
||||||
|
"Do not call any extra tools.",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
||||||
|
const timeout = input.timeout ?? 30_000
|
||||||
|
const end = Date.now() + timeout
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const value = await input.probe()
|
||||||
|
if (value !== undefined) return value
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = async <T>(input: {
|
||||||
|
sessionID: string
|
||||||
|
prompt: string
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
probe: () => Promise<T | undefined>
|
||||||
|
timeout?: number
|
||||||
|
attempts?: number
|
||||||
|
}) => {
|
||||||
|
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: seedSystem,
|
||||||
|
parts: [{ type: "text", text: input.prompt }],
|
||||||
|
})
|
||||||
|
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
||||||
|
if (value !== undefined) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionQuestion(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
questions: Array<{
|
||||||
|
header: string
|
||||||
|
question: string
|
||||||
|
options: Array<{ label: string; description: string }>
|
||||||
|
multiple?: boolean
|
||||||
|
custom?: boolean
|
||||||
|
}>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const first = input.questions[0]
|
||||||
|
if (!first) throw new Error("Question seed requires at least one question")
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one question tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"After calling the tool, wait for the user response.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.question.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding question request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionPermission(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
permission: string
|
||||||
|
patterns: string[]
|
||||||
|
description?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one bash tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
||||||
|
workdir: "/",
|
||||||
|
description: input.description ?? `seed ${input.permission} permission request`,
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding permission request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTodos(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
todos: Array<{ content: string; status: string; priority: string }>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one todowrite tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n")
|
||||||
|
const target = JSON.stringify(input.todos)
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
||||||
|
if (JSON.stringify(todos) !== target) return
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding todos")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const [questions, permissions] = await Promise.all([
|
||||||
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
|
sdk.permission.list().then((x) => x.data ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...questions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
||||||
|
...permissions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export async function openStatusPopover(page: Page) {
|
export async function openStatusPopover(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
export const terminalSelector = '[data-component="terminal"]'
|
||||||
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
|
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
||||||
|
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
||||||
|
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
||||||
|
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
||||||
|
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
||||||
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
|
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||||
|
import {
|
||||||
|
permissionDockSelector,
|
||||||
|
promptSelector,
|
||||||
|
questionDockSelector,
|
||||||
|
sessionComposerDockSelector,
|
||||||
|
sessionTodoDockSelector,
|
||||||
|
sessionTodoListSelector,
|
||||||
|
sessionTodoToggleButtonSelector,
|
||||||
|
} from "../selectors"
|
||||||
|
|
||||||
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
|
|
||||||
|
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
|
||||||
|
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||||
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
return fn(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
description: "Need permission for command",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(permissionDockSelector)
|
||||||
|
.getByRole("button", { name: /allow once/i })
|
||||||
|
.click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["REJECT.md"],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionPermission(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["README.md"],
|
||||||
|
description: "Need permission for command",
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator(permissionDockSelector)
|
||||||
|
.getByRole("button", { name: /allow always/i })
|
||||||
|
.click()
|
||||||
|
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionTodos(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
todos: [
|
||||||
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
||||||
|
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await seedSessionTodos(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
todos: [
|
||||||
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -20,6 +20,7 @@ import { useParams } from "@solidjs/router"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||||
|
|
@ -1045,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
commandKeybind={command.keybind}
|
commandKeybind={command.keybind}
|
||||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||||
/>
|
/>
|
||||||
<form
|
<DockShellForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
classList={{
|
classList={{
|
||||||
"group/prompt-input": true,
|
"group/prompt-input": true,
|
||||||
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
|
"focus-within:shadow-xs-border": true,
|
||||||
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
|
|
||||||
"border-icon-info-active border-dashed": store.draggingType !== null,
|
"border-icon-info-active border-dashed": store.draggingType !== null,
|
||||||
[props.class ?? ""]: !!props.class,
|
[props.class ?? ""]: !!props.class,
|
||||||
}}
|
}}
|
||||||
|
|
@ -1243,9 +1243,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</DockShellForm>
|
||||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
<div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
|
<DockTray attach="top">
|
||||||
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
<div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<Show when={store.mode === "shell"}>
|
<Show when={store.mode === "shell"}>
|
||||||
|
|
@ -1385,7 +1385,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DockTray>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
|
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
|
|
@ -54,11 +54,7 @@ export default function Page() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const blocked = createMemo(() => {
|
const composer = createSessionComposerState()
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return false
|
|
||||||
return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const workspaceKey = createMemo(() => params.dir ?? "")
|
const workspaceKey = createMemo(() => params.dir ?? "")
|
||||||
|
|
@ -401,7 +397,7 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
if (blocked()) return
|
if (composer.blocked()) return
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1090,7 +1086,8 @@ export default function Page() {
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionPromptDock
|
<SessionComposerRegion
|
||||||
|
state={composer}
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
inputRef={(el) => {
|
inputRef={(el) => {
|
||||||
inputRef = el
|
inputRef = el
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { SessionComposerRegion } from "./session-composer-region"
|
||||||
|
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||||
|
export type { SessionComposerState } from "./session-composer-state"
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Show, createEffect, createMemo } from "solid-js"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
|
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||||
|
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||||
|
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||||
|
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||||
|
|
||||||
|
export function SessionComposerRegion(props: {
|
||||||
|
state: SessionComposerState
|
||||||
|
centered: boolean
|
||||||
|
inputRef: (el: HTMLDivElement) => void
|
||||||
|
newSessionWorktree: string
|
||||||
|
onNewSessionWorktreeReset: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
setPromptDockRef: (el: HTMLDivElement) => void
|
||||||
|
}) {
|
||||||
|
const params = useParams()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
|
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
||||||
|
|
||||||
|
const previewPrompt = () =>
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === "file") return `[file:${part.path}]`
|
||||||
|
if (part.type === "agent") return `@${part.name}`
|
||||||
|
if (part.type === "image") return `[image:${part.filename}]`
|
||||||
|
return part.content
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!prompt.ready()) return
|
||||||
|
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={props.setPromptDockRef}
|
||||||
|
data-component="session-prompt-dock"
|
||||||
|
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"w-full px-3 pointer-events-auto": true,
|
||||||
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={props.state.questionRequest()} keyed>
|
||||||
|
{(request) => (
|
||||||
|
<div>
|
||||||
|
<SessionQuestionDock request={request} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.state.permissionRequest()} keyed>
|
||||||
|
{(request) => (
|
||||||
|
<div>
|
||||||
|
<SessionPermissionDock
|
||||||
|
request={request}
|
||||||
|
responding={props.state.permissionResponding()}
|
||||||
|
onDecide={props.state.decide}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.state.blocked()}>
|
||||||
|
<Show
|
||||||
|
when={prompt.ready()}
|
||||||
|
fallback={
|
||||||
|
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||||
|
{handoffPrompt() || language.t("prompt.loading")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={props.state.dock()}>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||||
|
"max-h-[320px]": !props.state.closing(),
|
||||||
|
"max-h-0 pointer-events-none": props.state.closing(),
|
||||||
|
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
|
||||||
|
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionTodoDock
|
||||||
|
todos={props.state.todos()}
|
||||||
|
title={language.t("session.todo.title")}
|
||||||
|
collapseLabel={language.t("session.todo.collapse")}
|
||||||
|
expandLabel={language.t("session.todo.expand")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"relative z-10": true,
|
||||||
|
"transition-[margin] duration-[400ms] ease-out": true,
|
||||||
|
"-mt-9": props.state.dock() && !props.state.closing(),
|
||||||
|
"mt-0": !props.state.dock() || props.state.closing(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PromptInput
|
||||||
|
ref={props.inputRef}
|
||||||
|
newSessionWorktree={props.newSessionWorktree}
|
||||||
|
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||||
|
onSubmit={props.onSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
|
||||||
|
export function createSessionComposerBlocked() {
|
||||||
|
const params = useParams()
|
||||||
|
const sync = useSync()
|
||||||
|
return createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return false
|
||||||
|
return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionComposerState() {
|
||||||
|
const params = useParams()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
return sync.data.question[id]?.[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
return sync.data.permission[id]?.[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const blocked = createSessionComposerBlocked()
|
||||||
|
|
||||||
|
const todos = createMemo((): Todo[] => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return []
|
||||||
|
return globalSync.data.session_todo[id] ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
responding: undefined as string | undefined,
|
||||||
|
dock: todos().length > 0,
|
||||||
|
closing: false,
|
||||||
|
opening: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissionResponding = createMemo(() => {
|
||||||
|
const perm = permissionRequest()
|
||||||
|
if (!perm) return false
|
||||||
|
return store.responding === perm.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const decide = (response: "once" | "always" | "reject") => {
|
||||||
|
const perm = permissionRequest()
|
||||||
|
if (!perm) return
|
||||||
|
if (store.responding === perm.id) return
|
||||||
|
|
||||||
|
setStore("responding", perm.id)
|
||||||
|
sdk.client.permission
|
||||||
|
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const description = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStore("responding", (id) => (id === perm.id ? undefined : id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = createMemo(
|
||||||
|
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
let timer: number | undefined
|
||||||
|
let raf: number | undefined
|
||||||
|
|
||||||
|
const scheduleClose = () => {
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
setStore({ dock: false, closing: false })
|
||||||
|
timer = undefined
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [todos().length, done()] as const,
|
||||||
|
([count, complete], prev) => {
|
||||||
|
if (raf) cancelAnimationFrame(raf)
|
||||||
|
raf = undefined
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
setStore({ dock: false, closing: false, opening: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!complete) {
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
const hidden = !store.dock || store.closing
|
||||||
|
setStore({ dock: true, closing: false })
|
||||||
|
if (hidden) {
|
||||||
|
setStore("opening", true)
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
setStore("opening", false)
|
||||||
|
raf = undefined
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("opening", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev && prev[1]) {
|
||||||
|
if (store.closing && !timer) scheduleClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore({ dock: true, opening: false, closing: true })
|
||||||
|
scheduleClose()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!timer) return
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!raf) return
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocked,
|
||||||
|
questionRequest,
|
||||||
|
permissionRequest,
|
||||||
|
permissionResponding,
|
||||||
|
decide,
|
||||||
|
todos,
|
||||||
|
dock: () => store.dock,
|
||||||
|
closing: () => store.closing,
|
||||||
|
opening: () => store.opening,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionComposerState = ReturnType<typeof createSessionComposerState>
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
export function SessionPermissionDock(props: {
|
||||||
|
request: PermissionRequest
|
||||||
|
responding: boolean
|
||||||
|
onDecide: (response: "once" | "always" | "reject") => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const toolDescription = () => {
|
||||||
|
const key = `settings.permissions.tool.${props.request.permission}.description`
|
||||||
|
const value = language.t(key as Parameters<typeof language.t>[0])
|
||||||
|
if (value === key) return ""
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DockPrompt
|
||||||
|
kind="permission"
|
||||||
|
header={
|
||||||
|
<div data-slot="permission-row" data-variant="header">
|
||||||
|
<span data-slot="permission-icon">
|
||||||
|
<Icon name="warning" size="normal" />
|
||||||
|
</span>
|
||||||
|
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<div />
|
||||||
|
<div data-slot="permission-footer-actions">
|
||||||
|
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||||
|
{language.t("ui.permission.deny")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="normal"
|
||||||
|
onClick={() => props.onDecide("always")}
|
||||||
|
disabled={props.responding}
|
||||||
|
>
|
||||||
|
{language.t("ui.permission.allowAlways")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||||
|
{language.t("ui.permission.allowOnce")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={toolDescription()}>
|
||||||
|
<div data-slot="permission-row">
|
||||||
|
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||||
|
<div data-slot="permission-hint">{toolDescription()}</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.request.patterns.length > 0}>
|
||||||
|
<div data-slot="permission-row">
|
||||||
|
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||||
|
<div data-slot="permission-patterns">
|
||||||
|
<For each={props.request.patterns}>
|
||||||
|
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</DockPrompt>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
||||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
export const SessionQuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Todo } from "@opencode-ai/sdk/v2"
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||||
|
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
@ -54,13 +55,14 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||||
const preview = createMemo(() => active()?.content ?? "")
|
const preview = createMemo(() => active()?.content ?? "")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DockTray
|
||||||
|
data-component="session-todo-dock"
|
||||||
classList={{
|
classList={{
|
||||||
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
|
|
||||||
"h-[78px]": store.collapsed,
|
"h-[78px]": store.collapsed,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
data-action="session-todo-toggle"
|
||||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -81,6 +83,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||||
</Show>
|
</Show>
|
||||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
data-action="session-todo-toggle-button"
|
||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
size="normal"
|
size="normal"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -98,10 +101,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div hidden={store.collapsed}>
|
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
||||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DockTray>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
|
||||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
|
||||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
|
||||||
import { QuestionDock } from "@/components/question-dock"
|
|
||||||
import { SessionTodoDock } from "@/components/session-todo-dock"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { usePrompt } from "@/context/prompt"
|
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
|
||||||
|
|
||||||
export function SessionPromptDock(props: {
|
|
||||||
centered: boolean
|
|
||||||
inputRef: (el: HTMLDivElement) => void
|
|
||||||
newSessionWorktree: string
|
|
||||||
onNewSessionWorktreeReset: () => void
|
|
||||||
onSubmit: () => void
|
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
|
||||||
}) {
|
|
||||||
const params = useParams()
|
|
||||||
const sdk = useSDK()
|
|
||||||
const sync = useSync()
|
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
const prompt = usePrompt()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
|
||||||
|
|
||||||
const todos = createMemo((): Todo[] => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return []
|
|
||||||
return globalSync.data.session_todo[id] ?? []
|
|
||||||
})
|
|
||||||
|
|
||||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
return sync.data.question[sessionID]?.[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
return sync.data.permission[sessionID]?.[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
|
|
||||||
|
|
||||||
const previewPrompt = () =>
|
|
||||||
prompt
|
|
||||||
.current()
|
|
||||||
.map((part) => {
|
|
||||||
if (part.type === "file") return `[file:${part.path}]`
|
|
||||||
if (part.type === "agent") return `@${part.name}`
|
|
||||||
if (part.type === "image") return `[image:${part.filename}]`
|
|
||||||
return part.content
|
|
||||||
})
|
|
||||||
.join("")
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!prompt.ready()) return
|
|
||||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
|
||||||
})
|
|
||||||
|
|
||||||
const [responding, setResponding] = createSignal<string | undefined>()
|
|
||||||
const permissionResponding = () => {
|
|
||||||
const perm = permissionRequest()
|
|
||||||
if (!perm) return false
|
|
||||||
return responding() === perm.id
|
|
||||||
}
|
|
||||||
|
|
||||||
const decide = (response: "once" | "always" | "reject") => {
|
|
||||||
const perm = permissionRequest()
|
|
||||||
if (!perm) return
|
|
||||||
if (responding() === perm.id) return
|
|
||||||
|
|
||||||
setResponding(perm.id)
|
|
||||||
sdk.client.permission
|
|
||||||
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setResponding((id) => (id === perm.id ? undefined : id))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const done = createMemo(
|
|
||||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [dock, setDock] = createSignal(todos().length > 0)
|
|
||||||
const [closing, setClosing] = createSignal(false)
|
|
||||||
const [opening, setOpening] = createSignal(false)
|
|
||||||
let timer: number | undefined
|
|
||||||
let raf: number | undefined
|
|
||||||
|
|
||||||
const scheduleClose = () => {
|
|
||||||
if (timer) window.clearTimeout(timer)
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
setDock(false)
|
|
||||||
setClosing(false)
|
|
||||||
timer = undefined
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [todos().length, done()] as const,
|
|
||||||
([count, complete], prev) => {
|
|
||||||
if (raf) cancelAnimationFrame(raf)
|
|
||||||
raf = undefined
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
if (timer) window.clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
setDock(false)
|
|
||||||
setClosing(false)
|
|
||||||
setOpening(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!complete) {
|
|
||||||
if (timer) window.clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
const wasHidden = !dock() || closing()
|
|
||||||
setDock(true)
|
|
||||||
setClosing(false)
|
|
||||||
if (wasHidden) {
|
|
||||||
setOpening(true)
|
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
setOpening(false)
|
|
||||||
raf = undefined
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setOpening(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev && prev[1]) {
|
|
||||||
if (closing() && !timer) scheduleClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setDock(true)
|
|
||||||
setOpening(false)
|
|
||||||
setClosing(true)
|
|
||||||
scheduleClose()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (!timer) return
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (!raf) return
|
|
||||||
cancelAnimationFrame(raf)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={props.setPromptDockRef}
|
|
||||||
data-component="session-prompt-dock"
|
|
||||||
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"w-full px-3 pointer-events-auto": true,
|
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={questionRequest()} keyed>
|
|
||||||
{(req) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<QuestionDock request={req} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={permissionRequest()} keyed>
|
|
||||||
{(perm) => {
|
|
||||||
const toolDescription = () => {
|
|
||||||
const key = `settings.permissions.tool.${perm.permission}.description`
|
|
||||||
const value = language.t(key as Parameters<typeof language.t>[0])
|
|
||||||
if (value === key) return ""
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DockPrompt
|
|
||||||
kind="permission"
|
|
||||||
header={
|
|
||||||
<div data-slot="permission-row" data-variant="header">
|
|
||||||
<span data-slot="permission-icon">
|
|
||||||
<Icon name="warning" size="normal" />
|
|
||||||
</span>
|
|
||||||
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<div />
|
|
||||||
<div data-slot="permission-footer-actions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="normal"
|
|
||||||
onClick={() => decide("reject")}
|
|
||||||
disabled={permissionResponding()}
|
|
||||||
>
|
|
||||||
{language.t("ui.permission.deny")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="normal"
|
|
||||||
onClick={() => decide("always")}
|
|
||||||
disabled={permissionResponding()}
|
|
||||||
>
|
|
||||||
{language.t("ui.permission.allowAlways")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="normal"
|
|
||||||
onClick={() => decide("once")}
|
|
||||||
disabled={permissionResponding()}
|
|
||||||
>
|
|
||||||
{language.t("ui.permission.allowOnce")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={toolDescription()}>
|
|
||||||
<div data-slot="permission-row">
|
|
||||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
|
||||||
<div data-slot="permission-hint">{toolDescription()}</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={perm.patterns.length > 0}>
|
|
||||||
<div data-slot="permission-row">
|
|
||||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
|
||||||
<div data-slot="permission-patterns">
|
|
||||||
<For each={perm.patterns}>
|
|
||||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</DockPrompt>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!blocked()}>
|
|
||||||
<Show
|
|
||||||
when={prompt.ready()}
|
|
||||||
fallback={
|
|
||||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
|
||||||
{handoffPrompt() || language.t("prompt.loading")}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={dock()}>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
|
||||||
"max-h-[320px]": !closing(),
|
|
||||||
"max-h-0 pointer-events-none": closing(),
|
|
||||||
"opacity-0 translate-y-9": closing() || opening(),
|
|
||||||
"opacity-100 translate-y-0": !closing() && !opening(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SessionTodoDock
|
|
||||||
todos={todos()}
|
|
||||||
title={language.t("session.todo.title")}
|
|
||||||
collapseLabel={language.t("session.todo.collapse")}
|
|
||||||
expandLabel={language.t("session.todo.expand")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"relative z-10": true,
|
|
||||||
"transition-[margin] duration-[400ms] ease-out": true,
|
|
||||||
"-mt-9": dock() && !closing(),
|
|
||||||
"mt-0": !dock() || closing(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PromptInput
|
|
||||||
ref={props.inputRef}
|
|
||||||
newSessionWorktree={props.newSessionWorktree}
|
|
||||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
|
||||||
onSubmit={props.onSubmit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
|
import { DockShell, DockTray } from "./dock-surface"
|
||||||
|
|
||||||
export function DockPrompt(props: {
|
export function DockPrompt(props: {
|
||||||
kind: "question" | "permission"
|
kind: "question" | "permission"
|
||||||
|
|
@ -11,11 +12,11 @@ export function DockPrompt(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||||
<div data-slot={slot("body")}>
|
<DockShell data-slot={slot("body")}>
|
||||||
<div data-slot={slot("header")}>{props.header}</div>
|
<div data-slot={slot("header")}>{props.header}</div>
|
||||||
<div data-slot={slot("content")}>{props.children}</div>
|
<div data-slot={slot("content")}>{props.children}</div>
|
||||||
</div>
|
</DockShell>
|
||||||
<div data-slot={slot("footer")}>{props.footer}</div>
|
<DockTray data-slot={slot("footer")}>{props.footer}</DockTray>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
[data-dock-surface="shell"] {
|
||||||
|
background-color: var(--surface-raised-stronger-non-alpha);
|
||||||
|
box-shadow: var(--shadow-xs-border);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dock-surface="tray"] {
|
||||||
|
background-color: var(--background-base);
|
||||||
|
border: 1px solid var(--border-weak-base);
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-dock-surface="tray"][data-dock-attach="top"] {
|
||||||
|
margin-top: -0.875rem;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { type ComponentProps, splitProps } from "solid-js"
|
||||||
|
|
||||||
|
export interface DockTrayProps extends ComponentProps<"div"> {
|
||||||
|
attach?: "none" | "top"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockShell(props: ComponentProps<"div">) {
|
||||||
|
const [split, rest] = splitProps(props, ["children", "class", "classList"])
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
data-dock-surface="shell"
|
||||||
|
classList={{
|
||||||
|
...(split.classList ?? {}),
|
||||||
|
[split.class ?? ""]: !!split.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{split.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockShellForm(props: ComponentProps<"form">) {
|
||||||
|
const [split, rest] = splitProps(props, ["children", "class", "classList"])
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
{...rest}
|
||||||
|
data-dock-surface="shell"
|
||||||
|
classList={{
|
||||||
|
...(split.classList ?? {}),
|
||||||
|
[split.class ?? ""]: !!split.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{split.children}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockTray(props: DockTrayProps) {
|
||||||
|
const [split, rest] = splitProps(props, ["attach", "children", "class", "classList"])
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
data-dock-surface="tray"
|
||||||
|
data-dock-attach={split.attach || "none"}
|
||||||
|
classList={{
|
||||||
|
...(split.classList ?? {}),
|
||||||
|
[split.class ?? ""]: !!split.class,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{split.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -768,12 +768,6 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 12px 12px 0;
|
padding: 12px 12px 0;
|
||||||
background-color: var(--surface-raised-stronger-non-alpha);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--shadow-xs-border);
|
|
||||||
overflow: clip;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="permission-header"] {
|
[data-slot="permission-header"] {
|
||||||
|
|
@ -856,13 +850,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 32px 8px 8px;
|
padding: 32px 8px 8px;
|
||||||
background-color: var(--background-base);
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: clip;
|
|
||||||
margin-top: -24px;
|
margin-top: -24px;
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="permission-footer-actions"] {
|
[data-slot="permission-footer-actions"] {
|
||||||
|
|
@ -892,12 +880,6 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 8px 8px 0;
|
padding: 8px 8px 0;
|
||||||
background-color: var(--surface-raised-stronger-non-alpha);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--shadow-xs-border);
|
|
||||||
overflow: clip;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="question-header"] {
|
[data-slot="question-header"] {
|
||||||
|
|
@ -1181,13 +1163,7 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 32px 8px 8px;
|
padding: 32px 8px 8px;
|
||||||
background-color: var(--background-base);
|
|
||||||
border: 1px solid var(--border-weak-base);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: clip;
|
|
||||||
margin-top: -24px;
|
margin-top: -24px;
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="question-footer-actions"] {
|
[data-slot="question-footer-actions"] {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
@import "../components/file-icon.css" layer(components);
|
@import "../components/file-icon.css" layer(components);
|
||||||
@import "../components/hover-card.css" layer(components);
|
@import "../components/hover-card.css" layer(components);
|
||||||
@import "../components/provider-icon.css" layer(components);
|
@import "../components/provider-icon.css" layer(components);
|
||||||
|
@import "../components/dock-surface.css" layer(components);
|
||||||
@import "../components/icon.css" layer(components);
|
@import "../components/icon.css" layer(components);
|
||||||
@import "../components/icon-button.css" layer(components);
|
@import "../components/icon-button.css" layer(components);
|
||||||
@import "../components/image-preview.css" layer(components);
|
@import "../components/image-preview.css" layer(components);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Session Composer Refactor Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Improve structure, ownership, and reuse for the bottom-of-session composer area without changing user-visible behavior.
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
- `packages/ui/src/components/dock-prompt.tsx`
|
||||||
|
- `packages/app/src/components/session-todo-dock.tsx`
|
||||||
|
- `packages/app/src/components/question-dock.tsx`
|
||||||
|
- `packages/app/src/pages/session/session-prompt-dock.tsx`
|
||||||
|
- related shared UI in `packages/app/src/components/prompt-input.tsx`
|
||||||
|
|
||||||
|
## Decisions Up Front
|
||||||
|
|
||||||
|
1. **`session-prompt-dock` should stay route-scoped.**
|
||||||
|
It is session-page orchestration, so it belongs under `pages/session`, not global `src/components`.
|
||||||
|
|
||||||
|
2. **The orchestrator should keep blocking ownership.**
|
||||||
|
A single component should decide whether to show blockers (`question`/`permission`) or the regular prompt input. This avoids drift and duplicate logic.
|
||||||
|
|
||||||
|
3. **Current component does too much.**
|
||||||
|
Split state derivation, permission actions, and rendering into smaller units while preserving behavior.
|
||||||
|
|
||||||
|
4. **There is style duplication worth addressing.**
|
||||||
|
The prompt top shell and lower tray (`prompt-input.tsx`) visually overlap with dock shells/footers and todo containers. We should extract reusable dock surface primitives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 (Mandatory Gate): Baseline E2E Coverage
|
||||||
|
|
||||||
|
No refactor work starts until this phase is complete and green locally.
|
||||||
|
|
||||||
|
### 0.1 Deterministic test harness
|
||||||
|
|
||||||
|
Add a test-only way to put a session into exact dock states, so tests do not rely on model/tool nondeterminism.
|
||||||
|
|
||||||
|
Proposed implementation:
|
||||||
|
|
||||||
|
- Add a guarded e2e route in backend (enabled only when a dedicated env flag is set by e2e-local runner).
|
||||||
|
- New route file: `packages/opencode/src/server/routes/e2e.ts`
|
||||||
|
- Mount from: `packages/opencode/src/server/server.ts`
|
||||||
|
- Gate behind env flag (for example `OPENCODE_E2E=1`) so this route is never exposed in normal runs.
|
||||||
|
- Add seed helpers in app e2e layer:
|
||||||
|
- `packages/app/e2e/actions.ts` (or `fixtures.ts`) helpers to:
|
||||||
|
- seed question request for a session
|
||||||
|
- seed permission request for a session
|
||||||
|
- seed/update todos for a session
|
||||||
|
- clear seeded blockers/todos
|
||||||
|
- Update e2e-local runner to set the flag:
|
||||||
|
- `packages/app/script/e2e-local.ts`
|
||||||
|
|
||||||
|
### 0.2 New e2e spec
|
||||||
|
|
||||||
|
Create a focused spec:
|
||||||
|
|
||||||
|
- `packages/app/e2e/session/session-composer-dock.spec.ts`
|
||||||
|
|
||||||
|
Test matrix (minimum required):
|
||||||
|
|
||||||
|
1. **Default prompt dock**
|
||||||
|
- no blocker state
|
||||||
|
- assert prompt input is visible and focusable
|
||||||
|
- assert blocker cards are absent
|
||||||
|
|
||||||
|
2. **Blocked question flow**
|
||||||
|
- seed question request for session
|
||||||
|
- assert question dock renders
|
||||||
|
- assert prompt input is not shown/active
|
||||||
|
- answer and submit
|
||||||
|
- assert unblock and prompt input returns
|
||||||
|
|
||||||
|
3. **Blocked permission flow**
|
||||||
|
- seed permission request with patterns + optional description
|
||||||
|
- assert permission dock renders expected actions
|
||||||
|
- assert prompt input is not shown/active
|
||||||
|
- test each response path (`once`, `always`, `reject`) across tests
|
||||||
|
- assert unblock behavior
|
||||||
|
|
||||||
|
4. **Todo dock transitions and collapse behavior**
|
||||||
|
- seed todos with `pending`/`in_progress`
|
||||||
|
- assert todo dock appears above prompt and can collapse/expand
|
||||||
|
- update todos to all completed/cancelled
|
||||||
|
- assert close animation path and eventual hide
|
||||||
|
|
||||||
|
5. **Keyboard focus behavior while blocked**
|
||||||
|
- with blocker active, typing from document context must not focus prompt input
|
||||||
|
- blocker actions remain keyboard reachable
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Prefer stable selectors (`data-component`, `data-slot`, role/name).
|
||||||
|
- Extend `packages/app/e2e/selectors.ts` as needed.
|
||||||
|
- Use `expect.poll` for async transitions.
|
||||||
|
|
||||||
|
### 0.3 Gate commands (must pass before Phase 1)
|
||||||
|
|
||||||
|
Run from `packages/app` (never from repo root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test:e2e:local -- e2e/session/session-composer-dock.spec.ts
|
||||||
|
bun test:e2e:local -- e2e/prompt/prompt.spec.ts e2e/prompt/prompt-multiline.spec.ts e2e/commands/input-focus.spec.ts
|
||||||
|
bun test:e2e:local
|
||||||
|
```
|
||||||
|
|
||||||
|
If any fail, stop and fix before refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Structural Refactor (No Intended Behavior Changes)
|
||||||
|
|
||||||
|
### 1.1 Colocate session-composer files
|
||||||
|
|
||||||
|
Create a route-local composer folder:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
packages/app/src/pages/session/composer/
|
||||||
|
session-composer-region.tsx # rename/move from session-prompt-dock.tsx
|
||||||
|
session-composer-state.ts # derived state + actions
|
||||||
|
session-permission-dock.tsx # extracted from inline JSX
|
||||||
|
session-question-dock.tsx # moved from src/components/question-dock.tsx
|
||||||
|
session-todo-dock.tsx # moved from src/components/session-todo-dock.tsx
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Import updates:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx` imports `SessionComposerRegion` from `pages/session/composer`.
|
||||||
|
|
||||||
|
### 1.2 Split responsibilities
|
||||||
|
|
||||||
|
- Keep `session-composer-region.tsx` focused on rendering orchestration:
|
||||||
|
- blocker mode vs normal mode
|
||||||
|
- relative stacking (todo above prompt)
|
||||||
|
- handoff fallback rendering
|
||||||
|
- Move side-effect/business pieces into `session-composer-state.ts`:
|
||||||
|
- derive `questionRequest`, `permissionRequest`, `blocked`, todo visibility state
|
||||||
|
- permission response action + in-flight state
|
||||||
|
- todo close/open animation state
|
||||||
|
|
||||||
|
### 1.3 Remove duplicate blocked logic in `session.tsx`
|
||||||
|
|
||||||
|
Current `session.tsx` computes `blocked` independently. Make the composer state the single source for blocker status consumed by both:
|
||||||
|
|
||||||
|
- page-level keydown autofocus guard
|
||||||
|
- composer rendering guard
|
||||||
|
|
||||||
|
### 1.4 Keep prompt gating in orchestrator
|
||||||
|
|
||||||
|
`session-composer-region` should remain responsible for choosing whether `PromptInput` renders when blocked.
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- this is layout-mode orchestration, not prompt implementation detail
|
||||||
|
- keeps blocker and prompt transitions coordinated in one place
|
||||||
|
|
||||||
|
### 1.5 Phase 1 acceptance criteria
|
||||||
|
|
||||||
|
- No intentional behavior deltas.
|
||||||
|
- Phase 0 suite remains green.
|
||||||
|
- `session-prompt-dock` no longer exists as a large mixed-responsibility component.
|
||||||
|
- Session composer files are colocated under `pages/session/composer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Reuse + Styling Maintainability
|
||||||
|
|
||||||
|
### 2.1 Extract shared dock surface primitives
|
||||||
|
|
||||||
|
Create reusable shell/tray wrappers to remove repeated visual scaffolding:
|
||||||
|
|
||||||
|
- primary elevated surface (prompt top shell / dock body)
|
||||||
|
- secondary tray surface (prompt bottom bar / dock footer / todo shell)
|
||||||
|
|
||||||
|
Proposed targets:
|
||||||
|
|
||||||
|
- `packages/ui/src/components` for shared primitives if reused by both app and ui components
|
||||||
|
- or `packages/app/src/pages/session/composer` first, then promote to ui after proving reuse
|
||||||
|
|
||||||
|
### 2.2 Apply primitives to current components
|
||||||
|
|
||||||
|
Adopt in:
|
||||||
|
|
||||||
|
- `packages/app/src/components/prompt-input.tsx`
|
||||||
|
- `packages/app/src/pages/session/composer/session-todo-dock.tsx`
|
||||||
|
- `packages/ui/src/components/dock-prompt.tsx` (where appropriate)
|
||||||
|
|
||||||
|
Focus on deduping patterns seen in:
|
||||||
|
|
||||||
|
- prompt elevated shell styles (`prompt-input.tsx` form container)
|
||||||
|
- prompt lower tray (`prompt-input.tsx` bottom panel)
|
||||||
|
- dock prompt footer/body and todo dock container
|
||||||
|
|
||||||
|
### 2.3 De-risk style ownership
|
||||||
|
|
||||||
|
- Move dock-specific styling out of overly broad files (for example, avoid keeping new dock-specific rules buried in unrelated message-part styling files).
|
||||||
|
- Keep slot names stable unless tests are updated in the same PR.
|
||||||
|
|
||||||
|
### 2.4 Optional follow-up (if low risk)
|
||||||
|
|
||||||
|
Evaluate extracting shared question/permission presentational pieces used by:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session/composer/session-question-dock.tsx`
|
||||||
|
- `packages/ui/src/components/message-part.tsx`
|
||||||
|
|
||||||
|
Only do this if behavior parity is protected by tests and the change is still reviewable.
|
||||||
|
|
||||||
|
### 2.5 Phase 2 acceptance criteria
|
||||||
|
|
||||||
|
- Reduced duplicated shell/tray styling code.
|
||||||
|
- No regressions in blocker/todo/prompt transitions.
|
||||||
|
- Phase 0 suite remains green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Sequence (single branch)
|
||||||
|
|
||||||
|
1. **Step A - Baseline safety net**
|
||||||
|
- Add e2e harness + new session composer dock spec + selector/helpers.
|
||||||
|
- Must pass locally before any refactor work proceeds.
|
||||||
|
|
||||||
|
2. **Step B - Phase 1 colocation/splitting**
|
||||||
|
- Move/rename files, extract state and permission component, keep behavior.
|
||||||
|
|
||||||
|
3. **Step C - Phase 1 dedupe blocked source**
|
||||||
|
- Remove duplicate blocked derivation and wire page autofocus guard to shared source.
|
||||||
|
|
||||||
|
4. **Step D - Phase 2 style primitives**
|
||||||
|
- Introduce shared surface primitives and migrate prompt/todo/dock usage.
|
||||||
|
|
||||||
|
5. **Step E (optional) - shared question/permission presentational extraction**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Strategy
|
||||||
|
|
||||||
|
- Keep each step logically isolated and easy to revert.
|
||||||
|
- If regressions occur, revert the latest completed step first and rerun the Phase 0 suite.
|
||||||
|
- If style extraction destabilizes behavior, keep structural Phase 1 changes and revert only Phase 2 styling commits.
|
||||||
Loading…
Reference in New Issue