Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
246 lines
7.9 KiB
TypeScript
246 lines
7.9 KiB
TypeScript
import { Show, createMemo, createEffect, type Component } from "solid-js"
|
|
import type { Session } from "../../types/session"
|
|
import type { Attachment } from "../../types/attachment"
|
|
import type { ClientPart } from "../../types/message"
|
|
import MessageSection from "../message-section"
|
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
|
import PromptInput from "../prompt-input"
|
|
import { instances } from "../../stores/instances"
|
|
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
|
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
|
import { showAlertDialog } from "../../stores/alerts"
|
|
import { getLogger } from "../../lib/logger"
|
|
import { requestData } from "../../lib/opencode-api"
|
|
|
|
const log = getLogger("session")
|
|
|
|
function isTextPart(part: ClientPart): part is ClientPart & { type: "text"; text: string } {
|
|
return part?.type === "text" && typeof (part as any).text === "string"
|
|
}
|
|
|
|
interface SessionViewProps {
|
|
sessionId: string
|
|
activeSessions: Map<string, Session>
|
|
instanceId: string
|
|
instanceFolder: string
|
|
escapeInDebounce: boolean
|
|
showSidebarToggle?: boolean
|
|
onSidebarToggle?: () => void
|
|
forceCompactStatusLayout?: boolean
|
|
isActive?: boolean
|
|
}
|
|
|
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
|
const session = () => props.activeSessions.get(props.sessionId)
|
|
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
|
const sessionBusy = createMemo(() => {
|
|
const currentSession = session()
|
|
if (!currentSession) return false
|
|
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
|
})
|
|
let scrollToBottomHandle: (() => void) | undefined
|
|
let rootRef: HTMLDivElement | undefined
|
|
function scheduleScrollToBottom() {
|
|
if (!scrollToBottomHandle) return
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => scrollToBottomHandle?.())
|
|
})
|
|
}
|
|
createEffect(() => {
|
|
if (!props.isActive) return
|
|
scheduleScrollToBottom()
|
|
})
|
|
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
|
|
|
createEffect(() => {
|
|
const currentSession = session()
|
|
if (currentSession) {
|
|
loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error))
|
|
}
|
|
})
|
|
|
|
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
|
|
quoteHandler = handler
|
|
return () => {
|
|
if (quoteHandler === handler) {
|
|
quoteHandler = null
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleQuoteSelection(text: string, mode: "quote" | "code") {
|
|
if (quoteHandler) {
|
|
quoteHandler(text, mode)
|
|
}
|
|
}
|
|
|
|
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
|
scheduleScrollToBottom()
|
|
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
|
}
|
|
|
|
async function handleRunShell(command: string) {
|
|
await runShellCommand(props.instanceId, props.sessionId, command)
|
|
}
|
|
|
|
async function handleAbortSession() {
|
|
const currentSession = session()
|
|
if (!currentSession) return
|
|
|
|
try {
|
|
await abortSession(props.instanceId, currentSession.id)
|
|
log.info("Abort requested", { instanceId: props.instanceId, sessionId: currentSession.id })
|
|
} catch (error) {
|
|
log.error("Failed to abort session", error)
|
|
showAlertDialog("Failed to stop session", {
|
|
title: "Stop failed",
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
variant: "error",
|
|
})
|
|
}
|
|
}
|
|
|
|
function getUserMessageText(messageId: string): string | null {
|
|
|
|
const normalizedMessage = messageStore().getMessage(messageId)
|
|
if (normalizedMessage && normalizedMessage.role === "user") {
|
|
const parts = normalizedMessage.partIds
|
|
.map((partId) => normalizedMessage.parts[partId]?.data)
|
|
.filter((part): part is ClientPart => Boolean(part))
|
|
const textParts = parts.filter(isTextPart)
|
|
if (textParts.length > 0) {
|
|
return textParts.map((part) => part.text).join("\n")
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
|
|
async function handleRevert(messageId: string) {
|
|
const instance = instances().get(props.instanceId)
|
|
if (!instance || !instance.client) return
|
|
|
|
try {
|
|
await requestData(
|
|
instance.client.session.revert({
|
|
sessionID: props.sessionId,
|
|
messageID: messageId,
|
|
}),
|
|
"session.revert",
|
|
)
|
|
|
|
const restoredText = getUserMessageText(messageId)
|
|
if (restoredText) {
|
|
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
|
if (textarea) {
|
|
textarea.value = restoredText
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
|
textarea.focus()
|
|
}
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to revert message", error)
|
|
showAlertDialog("Failed to revert to message", {
|
|
title: "Revert failed",
|
|
variant: "error",
|
|
})
|
|
}
|
|
}
|
|
|
|
async function handleFork(messageId?: string) {
|
|
if (!messageId) {
|
|
log.warn("Fork requires a user message id")
|
|
return
|
|
}
|
|
|
|
const restoredText = getUserMessageText(messageId)
|
|
|
|
try {
|
|
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
|
|
|
|
const parentToActivate = forkedSession.parentId ?? forkedSession.id
|
|
setActiveParentSession(props.instanceId, parentToActivate)
|
|
if (forkedSession.parentId) {
|
|
setActiveSession(props.instanceId, forkedSession.id)
|
|
}
|
|
|
|
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
|
|
|
|
if (restoredText) {
|
|
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
|
|
if (textarea) {
|
|
textarea.value = restoredText
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
|
textarea.focus()
|
|
}
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to fork session", error)
|
|
showAlertDialog("Failed to fork session", {
|
|
title: "Fork failed",
|
|
variant: "error",
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
return (
|
|
<Show
|
|
when={session()}
|
|
fallback={
|
|
<div class="flex items-center justify-center h-full">
|
|
<div class="text-center text-gray-500">Session not found</div>
|
|
</div>
|
|
}
|
|
>
|
|
{(sessionAccessor) => {
|
|
const activeSession = sessionAccessor()
|
|
if (!activeSession) return null
|
|
return (
|
|
<div ref={rootRef} class="session-view">
|
|
<MessageSection
|
|
instanceId={props.instanceId}
|
|
sessionId={activeSession.id}
|
|
loading={messagesLoading()}
|
|
onRevert={handleRevert}
|
|
onFork={handleFork}
|
|
isActive={props.isActive}
|
|
registerScrollToBottom={(fn) => {
|
|
scrollToBottomHandle = fn
|
|
if (props.isActive) {
|
|
scheduleScrollToBottom()
|
|
}
|
|
}}
|
|
|
|
|
|
|
|
|
|
showSidebarToggle={props.showSidebarToggle}
|
|
onSidebarToggle={props.onSidebarToggle}
|
|
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
|
onQuoteSelection={handleQuoteSelection}
|
|
/>
|
|
|
|
|
|
<PromptInput
|
|
instanceId={props.instanceId}
|
|
instanceFolder={props.instanceFolder}
|
|
sessionId={activeSession.id}
|
|
onSend={handleSendMessage}
|
|
onRunShell={handleRunShell}
|
|
escapeInDebounce={props.escapeInDebounce}
|
|
isSessionBusy={sessionBusy()}
|
|
onAbortSession={handleAbortSession}
|
|
registerQuoteHandler={registerQuoteHandler}
|
|
/>
|
|
</div>
|
|
)
|
|
}}
|
|
</Show>
|
|
)
|
|
}
|
|
|
|
export default SessionView
|