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

250 lines
6.8 KiB
TypeScript

import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
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 { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
count: number
done: boolean
live: boolean
}): "hide" | "clear" | "open" | "close" => {
if (input.count === 0) return "hide"
if (!input.live) return "clear"
if (!input.done) return "open"
return "close"
}
const idle = { type: "idle" as const }
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const language = useLanguage()
const permission = usePermission()
const questionRequest = createMemo((): QuestionRequest | undefined => {
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
return !permission.autoResponds(item, sdk.directory)
})
})
const blocked = createMemo(() => {
const id = params.id
if (!id) return false
return !!permissionRequest() || !!questionRequest()
})
const [test, setTest] = createStore({
on: false,
live: undefined as boolean | undefined,
todos: undefined as Todo[] | undefined,
})
const pull = () => {
const id = params.id
if (!id) {
setTest({ on: false, live: undefined, todos: undefined })
return
}
const next = composerDriver(id)
if (!next) {
setTest({ on: false, live: undefined, todos: undefined })
return
}
setTest({
on: true,
live: next.live,
todos: next.todos?.map((todo) => ({ ...todo })),
})
}
onMount(() => {
if (!composerEnabled()) return
pull()
createEffect(on(() => params.id, pull, { defer: true }))
const onEvent = (event: Event) => {
const detail = (event as CustomEvent<{ sessionID?: string }>).detail
if (detail?.sessionID !== params.id) return
pull()
}
makeEventListener(window, composerEvent, onEvent)
})
const todos = createMemo((): Todo[] => {
if (test.on && test.todos !== undefined) return test.todos
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const status = createMemo(() => {
const id = params.id
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const busy = createMemo(() => status().type !== "idle")
const live = createMemo(() => {
if (test.on && test.live !== undefined) return test.live
return busy() || blocked()
})
const [store, setStore] = createStore({
responding: undefined as string | undefined,
dock: todos().length > 0 && live(),
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))
})
}
let timer: number | undefined
let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, closeMs())
}
// Keep stale turn todos from reopening if the model never clears them.
const clear = () => {
if (test.on && test.todos !== undefined) {
setTest("todos", [])
return
}
const id = params.id
if (!id) return
globalSync.todo.set(id, [])
sync.set("todo", id, [])
}
createEffect(
on(
() => [todos().length, done(), live()] as const,
([count, complete, active]) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
const next = todoState({
count,
done: complete,
live: active,
})
if (next === "hide") {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: false, closing: false, opening: false })
return
}
if (next === "clear") {
if (timer) window.clearTimeout(timer)
timer = undefined
clear()
return
}
if (next === "open") {
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
}
setStore({ dock: true, opening: false, closing: true })
if (!timer) 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>