310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
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(false)
|
|
|
|
createEffect(
|
|
on(
|
|
() => permissionRequest()?.id,
|
|
() => setResponding(false),
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
const decide = (response: "once" | "always" | "reject") => {
|
|
const perm = permissionRequest()
|
|
if (!perm) return
|
|
if (responding()) return
|
|
|
|
setResponding(true)
|
|
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(false))
|
|
}
|
|
|
|
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-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
|
|
>
|
|
<div
|
|
classList={{
|
|
"w-full px-4 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={responding()}>
|
|
{language.t("ui.permission.deny")}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="normal"
|
|
onClick={() => decide("always")}
|
|
disabled={responding()}
|
|
>
|
|
{language.t("ui.permission.allowAlways")}
|
|
</Button>
|
|
<Button variant="primary" size="normal" onClick={() => decide("once")} disabled={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={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>
|
|
)
|
|
}
|