diff --git a/src/App.tsx b/src/App.tsx index 5b6de067..1b47897e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSi import { Toaster } from "solid-toast" import type { Session } from "./types/session" import type { Attachment } from "./types/attachment" +import type { SDKPart, ClientPart } from "./types/message" import FolderSelectionView from "./components/folder-selection-view" import InstanceWelcomeView from "./components/instance-welcome-view" import CommandPalette from "./components/command-palette" @@ -99,12 +100,12 @@ const SessionView: Component<{ return null } - const textParts = targetMessage.parts.filter((p: any) => p.type === "text") + const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text") if (textParts.length === 0) { return null } - return textParts.map((p: any) => p.text).join("\n") + return textParts.map((p) => p.text).join("\n") } async function handleRevert(messageId: string) { @@ -555,9 +556,9 @@ const App: Component = () => { modelID: session.model.modelId, }, }) - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to compact session:", error) - const message = error?.message || "Failed to compact session" + const message = error instanceof Error ? error.message : "Failed to compact session" alert(`Compact failed: ${message}`) } }, @@ -630,11 +631,11 @@ const App: Component = () => { // Restore the reverted user message to the prompt input if (revertedMessage && revertedInfo?.role === "user") { - const textParts = revertedMessage.parts.filter((p: any) => p.type === "text") + const textParts = revertedMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text") if (textParts.length > 0) { const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement if (textarea) { - textarea.value = textParts.map((p: any) => p.text).join("\n") + textarea.value = textParts.map((p) => p.text).join("\n") textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.focus() } diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx index 2aa4b2e7..d986b3f4 100644 --- a/src/components/diff-viewer.tsx +++ b/src/components/diff-viewer.tsx @@ -1,5 +1,6 @@ import { createMemo, Show, onMount, createEffect } from "solid-js" import { DiffView, DiffModeEnum } from "@git-diff-view/solid" +import type { DiffHighlighterLang } from "@git-diff-view/core" import { getLanguageFromPath } from "../lib/markdown" import { normalizeDiffText } from "../lib/diff-utils" import { setToolRenderCache } from "../lib/tool-render-cache" @@ -34,11 +35,11 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) { return { oldFile: { fileName, - fileLang: language as any, // Cast to any to avoid type issues with DiffHighlighterLang + fileLang: (language || "text") as DiffHighlighterLang | null, }, newFile: { fileName, - fileLang: language as any, // Cast to any to avoid type issues with DiffHighlighterLang + fileLang: (language || "text") as DiffHighlighterLang | null, }, hunks: [normalized], } diff --git a/src/components/file-picker.tsx b/src/components/file-picker.tsx index f7d9b3e6..eacbb2bc 100644 --- a/src/components/file-picker.tsx +++ b/src/components/file-picker.tsx @@ -1,5 +1,7 @@ import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" +import type { OpencodeClient } from "@opencode-ai/sdk/client" + interface FileItem { path: string added?: number @@ -12,7 +14,7 @@ interface FilePickerProps { onSelect: (path: string) => void onNavigate: (direction: "up" | "down") => void onClose: () => void - instanceClient: any + instanceClient: OpencodeClient searchQuery: string textareaRef?: HTMLTextAreaElement workspaceFolder: string diff --git a/src/components/instance-info.tsx b/src/components/instance-info.tsx index 3f0e9c02..acd3775e 100644 --- a/src/components/instance-info.tsx +++ b/src/components/instance-info.tsx @@ -1,5 +1,5 @@ import { Component, Show, For, onMount, createSignal } from "solid-js" -import type { Instance } from "../types/instance" +import type { Instance, RawMcpStatus } from "../types/instance" interface InstanceInfoProps { instance: Instance @@ -12,12 +12,12 @@ type ParsedMcpStatus = { error?: string } -function parseMcpStatus(status: unknown): ParsedMcpStatus[] { +function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] { if (!status || typeof status !== "object") return [] const result: ParsedMcpStatus[] = [] - for (const [name, value] of Object.entries(status as Record)) { + for (const [name, value] of Object.entries(status)) { if (!value || typeof value !== "object") continue const rawStatus = (value as { status?: string }).status if (!rawStatus) continue @@ -47,7 +47,7 @@ const InstanceInfo: Component = (props) => { const metadata = () => props.instance.metadata const mcpServers = () => { const status = metadata()?.mcpStatus - return parseMcpStatus(status) + return status ? parseMcpStatus(status) : [] } onMount(async () => { @@ -64,7 +64,7 @@ const InstanceInfo: Component = (props) => { ]) const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined - const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data : undefined + const mcpStatus = mcpResult.status === "fulfilled" ? mcpResult.value.data as RawMcpStatus : undefined const { updateInstance } = await import("../stores/instances") updateInstance(props.instance.id, { diff --git a/src/components/message-item.tsx b/src/components/message-item.tsx index 9617e256..cede2145 100644 --- a/src/components/message-item.tsx +++ b/src/components/message-item.tsx @@ -1,14 +1,13 @@ import { For, Show } from "solid-js" -import type { Message } from "../types/message" +import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message" import { partHasRenderableText } from "../types/message" import MessagePart from "./message-part" - interface MessageItemProps { message: Message - messageInfo?: any + messageInfo?: MessageInfo isQueued?: boolean - parts?: any[] + parts?: ClientPart[] onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void } @@ -23,9 +22,10 @@ export default function MessageItem(props: MessageItemProps) { const messageParts = () => props.parts ?? props.message.parts const errorMessage = () => { - if (!props.messageInfo?.error) return null + const info = props.messageInfo + if (!info || info.role !== "assistant" || !info.error) return null - const error = props.messageInfo.error + const error = info.error if (error.name === "ProviderAuthError") { return error.data?.message || "Authentication error" } @@ -50,7 +50,8 @@ export default function MessageItem(props: MessageItemProps) { } const isGenerating = () => { - return !hasContent() && props.messageInfo?.time?.completed === 0 + const info = props.messageInfo + return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0 } const handleRevert = () => { @@ -66,29 +67,17 @@ export default function MessageItem(props: MessageItemProps) { const agentIdentifier = () => { if (isUser()) return "" - return ( - props.messageInfo?.agent || - props.messageInfo?.mode || - props.messageInfo?.agentID || - props.messageInfo?.agentId || - props.messageInfo?.metadata?.agentID || - "" - ) + const info = props.messageInfo + if (!info || info.role !== "assistant") return "" + return info.mode || "" } - + const modelIdentifier = () => { if (isUser()) return "" - const modelID = - props.messageInfo?.modelID || - props.messageInfo?.modelId || - props.messageInfo?.model?.modelID || - props.messageInfo?.model?.id || - "" - const providerID = - props.messageInfo?.providerID || - props.messageInfo?.providerId || - props.messageInfo?.model?.providerID || - "" + const info = props.messageInfo + if (!info || info.role !== "assistant") return "" + const modelID = info.modelID || "" + const providerID = info.providerID || "" if (modelID && providerID) return `${providerID}/${modelID}` return modelID } diff --git a/src/components/message-part.tsx b/src/components/message-part.tsx index 5bbe4465..93f016e7 100644 --- a/src/components/message-part.tsx +++ b/src/components/message-part.tsx @@ -4,10 +4,12 @@ import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { Markdown } from "./markdown" import { useTheme } from "../lib/theme" import { preferences } from "../stores/preferences" -import { partHasRenderableText } from "../types/message" +import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" + +type ToolCallPart = Extract interface MessagePartProps { - part: any + part: ClientPart messageType?: "user" | "assistant" } export default function MessagePart(props: MessagePartProps) { @@ -21,22 +23,30 @@ export default function MessagePart(props: MessagePartProps) { const plainTextContent = () => { const part = props.part - if (typeof part?.text === "string") { + if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") { return part.text } - const segments: string[] = [] - const contentArray = Array.isArray(part?.content) ? part.content : [] + return "" + } - for (const item of contentArray) { - if (typeof item === "string") { - segments.push(item) - } else if (item && typeof item === "object" && typeof (item as { text?: unknown }).text === "string") { - segments.push((item as { text: string }).text) + const createTextPartForMarkdown = (): TextPart => { + const part = props.part + if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") { + return { + id: part.id, + type: "text", + text: part.text, + synthetic: part.type === "text" ? part.synthetic : false, + version: (part as { version?: number }).version } } - - return segments.join("\n") + return { + id: part.id, + type: "text", + text: "", + synthetic: false + } } function handleReasoningClick(e: Event) { @@ -47,25 +57,23 @@ export default function MessagePart(props: MessagePartProps) { return ( - +
{plainTextContent()}} > - +
- + - -
⚠ {props.part.message}
-
+ @@ -77,7 +85,7 @@ export default function MessagePart(props: MessagePartProps) {
- +
diff --git a/src/components/message-stream.tsx b/src/components/message-stream.tsx index 4e9c06fd..a62c9f5c 100644 --- a/src/components/message-stream.tsx +++ b/src/components/message-stream.tsx @@ -1,5 +1,31 @@ import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js" -import type { Message, MessageDisplayParts } from "../types/message" +import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message" + +type ToolCallPart = Extract + +// Import ToolState types from SDK +type ToolState = import("@opencode-ai/sdk").ToolState +type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning +type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted +type ToolStateError = import("@opencode-ai/sdk").ToolStateError + +// Type guards +function isToolStateRunning(state: ToolState): state is ToolStateRunning { + return state.status === "running" +} + +function isToolStateCompleted(state: ToolState): state is ToolStateCompleted { + return state.status === "completed" +} + +function isToolStateError(state: ToolState): state is ToolStateError { + return state.status === "error" +} + +// Type guard to check if a part is a tool part +function isToolPart(part: ClientPart): part is ToolCallPart { + return part.type === "tool" +} import MessageItem from "./message-item" import ToolCall from "./tool-call" import { sseManager } from "../lib/sse-manager" @@ -51,25 +77,26 @@ function navigateToTaskSession(location: TaskSessionLocation) { } // Calculate session tokens and cost from messagesInfo (matches TUI logic) -function calculateSessionInfo(messagesInfo?: Map, instanceId?: string) { +function calculateSessionInfo(messagesInfo?: Map, instanceId?: string) { if (!messagesInfo || messagesInfo.size === 0) return { tokens: 0, cost: 0, contextWindow: 0, isSubscriptionModel: false } let tokens = 0 let cost = 0 let contextWindow = 0 - let isSubscriptionModel = false let modelID = "" let providerID = "" + let isSubscriptionModel = false // Go backwards through messages to find the last relevant assistant message (like TUI) const messageArray = Array.from(messagesInfo.values()).reverse() for (const info of messageArray) { + if (!info) continue if (info.role === "assistant" && info.tokens) { const usage = info.tokens - if (usage.output > 0) { + if (usage.output && usage.output > 0) { if (info.summary) { // If summary message, only count output tokens and stop (like TUI) tokens = usage.output || 0 @@ -145,7 +172,7 @@ interface MessageStreamProps { instanceId: string sessionId: string messages: Message[] - messagesInfo?: Map + messagesInfo?: Map revert?: { messageID: string partID?: string @@ -160,16 +187,16 @@ interface MessageStreamProps { interface MessageDisplayItem { type: "message" message: Message - combinedParts: any[] + combinedParts: ClientPart[] isQueued: boolean - messageInfo?: any + messageInfo?: MessageInfo } interface ToolDisplayItem { type: "tool" key: string - toolPart: any - messageInfo?: any + toolPart: ToolCallPart + messageInfo?: MessageInfo messageId: string messageVersion: number partVersion: number @@ -178,22 +205,25 @@ interface ToolDisplayItem { type DisplayItem = MessageDisplayItem | ToolDisplayItem interface MessageCacheEntry { + message: Message version: number showThinking: boolean isQueued: boolean - messageInfo?: any + messageInfo?: MessageInfo displayParts: MessageDisplayParts item: MessageDisplayItem } interface ToolCacheEntry { - toolPart: any - messageInfo?: any + toolPart: ClientPart + messageInfo?: MessageInfo signature: string contentKey: string item: ToolDisplayItem } + + interface SessionCache { messageItemCache: Map toolItemCache: Map @@ -231,18 +261,17 @@ export default function MessageStream(props: MessageStreamProps) { const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId) const connectionStatus = () => sseManager.getStatus(props.instanceId) - function createToolSignature(message: Message, toolPart: any, toolIndex: number, messageInfo?: any): string { + function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string { const messageId = message.id const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}` return `${messageId}:${partId}` } - function createToolContentKey(toolPart: any, messageInfo?: any): string { - const state = toolPart?.state ?? {} - const version = typeof toolPart?.version === "number" ? toolPart.version : null + function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string { + const state = isToolPart(toolPart) ? toolPart.state : undefined + const version = typeof toolPart?.version === "number" ? toolPart.version : 0 const status = state?.status ?? "unknown" return `${toolPart.id}:${version}:${status}` - } const sessionInfo = createMemo(() => { @@ -383,7 +412,7 @@ export default function MessageStream(props: MessageStreamProps) { const hasRenderableContent = message.type !== "assistant" || combinedParts.length > 0 || - Boolean(messageInfo?.error) || + Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) || message.status === "error" if (hasRenderableContent) { @@ -415,6 +444,7 @@ export default function MessageStream(props: MessageStreamProps) { messageInfo, } newMessageCache.set(message.id, { + message, version, showThinking, isQueued, @@ -426,13 +456,15 @@ export default function MessageStream(props: MessageStreamProps) { } } - for (let toolIndex = 0; toolIndex < displayParts.tool.length; toolIndex++) { - const toolPart = displayParts.tool[toolIndex] - const toolKey = typeof toolPart?.id === "string" ? toolPart.id : `${message.id}-tool-${toolIndex}` + const toolParts = displayParts.tool.filter(isToolPart) + for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) { + const toolPart = toolParts[toolIndex] + const originalIndex = displayParts.tool.indexOf(toolPart) + const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}` const messageVersion = typeof message.version === "number" ? message.version : 0 const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0 - const toolSignature = createToolSignature(message, toolPart, toolIndex, messageInfo) + const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo) const contentKey = createToolContentKey(toolPart, messageInfo) const toolEntry = toolItemCache.get(toolKey) if (toolEntry && toolEntry.signature === toolSignature) { @@ -450,7 +482,7 @@ export default function MessageStream(props: MessageStreamProps) { toolEntry.signature = toolSignature toolEntry.contentKey = contentKey toolEntry.item = updatedItem - console.debug("[ToolCall] update", toolKey, toolPart?.state?.status) + console.debug("[ToolCall] update", toolKey, toolPart.state?.status) newToolCache.set(toolKey, toolEntry) items.push(updatedItem) } else { @@ -475,7 +507,7 @@ export default function MessageStream(props: MessageStreamProps) { messageVersion, partVersion, } - console.debug("[ToolCall] create", toolKey, toolPart?.state?.status) + console.debug("[ToolCall] create", toolKey, toolPart.state?.status) newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem }) items.push(toolItem) } @@ -674,7 +706,9 @@ export default function MessageStream(props: MessageStreamProps) { const toolPart = item.toolPart const taskSessionId = - typeof toolPart?.state?.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : "" + (isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state)) + ? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : "" + : "" const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null const handleGoToTaskSession = (event: Event) => { diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index b39b6c24..a6ca6a63 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -4,6 +4,7 @@ import { addToHistory, getHistory } from "../stores/message-history" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment" import type { Attachment } from "../types/attachment" +import type { Agent } from "../types/session" import Kbd from "./kbd" import HintRow from "./hint-row" import { getActiveInstance } from "../stores/instances" @@ -516,7 +517,7 @@ export default function PromptInput(props: PromptInputProps) { setAtPosition(null) } - function handlePickerSelect(item: { type: "agent"; agent: any } | { type: "file"; file: any }) { + function handlePickerSelect(item: { type: "agent"; agent: Agent } | { type: "file"; file: { path: string; isGitFile: boolean } }) { if (item.type === "agent") { const agentName = item.agent.name const existingAttachments = attachments() @@ -642,7 +643,7 @@ export default function PromptInput(props: PromptInputProps) { for (let i = 0; i < files.length; i++) { const file = files[i] - const path = (file as any).path || file.name + const path = (file as File & { path?: string }).path || file.name const filename = file.name const mime = file.type || "text/plain" diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index cca0f6e4..f5e779ff 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -7,20 +7,40 @@ import { getLanguageFromPath } from "../lib/markdown" import { isRenderableDiffText } from "../lib/diff-utils" import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache" import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences" -import type { TextPart } from "../types/message" +import type { TextPart, SDKPart, ClientPart } from "../types/message" + +type ToolCallPart = Extract + +// Import ToolState types from SDK +type ToolState = import("@opencode-ai/sdk").ToolState +type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning +type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted +type ToolStateError = import("@opencode-ai/sdk").ToolStateError + +// Type guards +function isToolStateRunning(state: ToolState): state is ToolStateRunning { + return state.status === "running" +} + +function isToolStateCompleted(state: ToolState): state is ToolStateCompleted { + return state.status === "completed" +} + +function isToolStateError(state: ToolState): state is ToolStateError { + return state.status === "error" +} const toolScrollState = new Map() function makeRenderCacheKey( - baseId?: string | null, + toolCallId?: string | null, messageId?: string, messageVersion?: number, partVersion?: number, ) { - if (!baseId && !messageId) return undefined const suffix = `${messageVersion ?? 0}:${partVersion ?? 0}` - const keyBase = baseId || messageId || "tool" + const keyBase = `${messageId}:${toolCallId}` return `${keyBase}::${suffix}` } @@ -55,7 +75,7 @@ function restoreScrollState(id: string, element: HTMLElement) { interface ToolCallProps { - toolCall: any + toolCall: Extract toolCallId?: string messageId?: string messageVersion?: number @@ -122,12 +142,17 @@ interface DiffPayload { filePath?: string } -function extractDiffPayload(toolName: string, state: any): DiffPayload | null { +function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | null { if (!diffCapableTools.has(toolName)) return null if (!state) return null - const metadata = state.metadata || {} - const candidates = [metadata.diff, state.output, metadata.output] + + const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.metadata || {} + : {} + + const output = isToolStateCompleted(state) ? state.output : undefined + const candidates = [metadata.diff, output, metadata.output] let diffText: string | null = null for (const candidate of candidates) { @@ -141,8 +166,12 @@ function extractDiffPayload(toolName: string, state: any): DiffPayload | null { return null } - const input = state.input || {} - const filePath = input.filePath || metadata.filePath || input.path + const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.input as Record + : {} + const filePath = (typeof input.filePath === "string" ? input.filePath : undefined) || + (typeof metadata.filePath === "string" ? metadata.filePath : undefined) || + (typeof input.path === "string" ? input.path : undefined) return { diffText, filePath } } @@ -202,7 +231,12 @@ export default function ToolCall(props: ToolCallProps) { createEffect(() => { if (props.toolCall?.tool !== "task") return - const summarySignature = JSON.stringify(props.toolCall?.state?.metadata?.summary ?? []) + const state = props.toolCall?.state + const summarySignature = JSON.stringify( + state && (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.metadata?.summary ?? [] + : [] + ) requestAnimationFrame(() => { void summarySignature handleScrollRendered() @@ -288,14 +322,20 @@ export default function ToolCall(props: ToolCallProps) { const renderToolTitle = () => { const toolName = props.toolCall?.tool || "" - const state = props.toolCall?.state || {} - const input = state.input || {} + const state = props.toolCall?.state - if (state.status === "pending") { - return renderToolAction() + if (!state) return renderToolAction() + if (state.status === "pending") return renderToolAction() + + const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? (state.input as Record) + : {} as Record + + if (isToolStateRunning(state) && state.title) { + return state.title } - - if (state.title) { + + if (isToolStateCompleted(state)) { return state.title } @@ -303,20 +343,20 @@ export default function ToolCall(props: ToolCallProps) { switch (toolName) { case "read": - if (input.filePath) { + if (typeof input.filePath === "string") { return `${name} ${getRelativePath(input.filePath)}` } return name case "edit": case "write": - if (input.filePath) { + if (typeof input.filePath === "string") { return `${name} ${getRelativePath(input.filePath)}` } return name case "bash": - if (input.description) { + if (typeof input.description === "string") { return `${name} ${input.description}` } return name @@ -344,7 +384,7 @@ export default function ToolCall(props: ToolCallProps) { return "Plan" case "invalid": - if (input.tool) { + if (typeof input.tool === "string") { return getToolName(input.tool) } return name @@ -454,7 +494,7 @@ export default function ToolCall(props: ToolCallProps) { ) } - function renderMarkdownTool(toolName: string, state: any) { + function renderMarkdownTool(toolName: string, state: ToolState) { const content = getMarkdownContent(toolName, state) if (!content) { return null @@ -496,53 +536,69 @@ export default function ToolCall(props: ToolCallProps) { ) } - function getMarkdownContent(toolName: string, state: any): string | null { - const input = state?.input || {} - const metadata = state?.metadata || {} + function getMarkdownContent(toolName: string, state: ToolState): string | null { + if (!state) return null + + const input = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.input as Record + : {} + const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.metadata || {} + : {} switch (toolName) { case "read": { const preview = typeof metadata.preview === "string" ? metadata.preview : null - const language = getLanguageFromPath(input.filePath || "") + const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "") return ensureMarkdownContent(preview, language, true) } case "edit": { const diffText = typeof metadata.diff === "string" ? metadata.diff : null - const fallback = typeof state.output === "string" ? state.output : null + const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null return ensureMarkdownContent(diffText || fallback, "diff", true) } case "write": { const content = typeof input.content === "string" ? input.content : null const metadataContent = typeof metadata.content === "string" ? metadata.content : null - const language = getLanguageFromPath(input.filePath || "") + const language = getLanguageFromPath(typeof input.filePath === "string" ? input.filePath : "") return ensureMarkdownContent(content || metadataContent, language, true) } case "patch": { const patchContent = typeof metadata.diff === "string" ? metadata.diff : null - const fallback = typeof state.output === "string" ? state.output : null + const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null return ensureMarkdownContent(patchContent || fallback, "diff", true) } case "bash": { const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown(metadata.output ?? state.output) + const outputResult = formatUnknown( + isToolStateCompleted(state) ? state.output : + (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : + undefined + ) const parts = [command, outputResult?.text].filter(Boolean) const combined = parts.join("\n") return ensureMarkdownContent(combined, "bash", true) } case "webfetch": { - const result = formatUnknown(state.output ?? metadata.output) + const result = formatUnknown( + isToolStateCompleted(state) ? state.output : + (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : + undefined + ) if (!result) return null return ensureMarkdownContent(result.text, result.language, true) } default: { const result = formatUnknown( - state.output ?? metadata.output ?? metadata.diff ?? metadata.preview ?? input.content, + isToolStateCompleted(state) ? state.output : + (isToolStateRunning(state) || isToolStateError(state)) && metadata.output ? metadata.output : + metadata.diff ?? metadata.preview ?? input.content, ) if (!result) return null return ensureMarkdownContent(result.text, result.language, true) @@ -618,8 +674,12 @@ export default function ToolCall(props: ToolCallProps) { } const renderTodowriteTool = () => { - const state = props.toolCall?.state || {} - const metadata = state.metadata || {} + const state = props.toolCall?.state + if (!state) return null + + const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.metadata || {} + : {} const todos = metadata.todos || [] if (!Array.isArray(todos) || todos.length === 0) { @@ -677,8 +737,12 @@ export default function ToolCall(props: ToolCallProps) { } const renderTaskTool = () => { - const state = props.toolCall?.state || {} - const metadata = state.metadata || {} + const state = props.toolCall?.state + if (!state) return null + + const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.metadata || {} + : {} const summary = metadata.summary || [] if (!Array.isArray(summary) || summary.length === 0) { diff --git a/src/components/unified-picker.tsx b/src/components/unified-picker.tsx index 814fb745..757f1dde 100644 --- a/src/components/unified-picker.tsx +++ b/src/components/unified-picker.tsx @@ -1,5 +1,6 @@ import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import type { Agent } from "../types/session" +import type { OpencodeClient } from "@opencode-ai/sdk/client" interface FileItem { path: string @@ -15,7 +16,7 @@ interface UnifiedPickerProps { onSelect: (item: PickerItem) => void onClose: () => void agents: Agent[] - instanceClient: any + instanceClient: OpencodeClient | null searchQuery: string textareaRef?: HTMLTextAreaElement workspaceFolder: string diff --git a/src/lib/sse-manager.ts b/src/lib/sse-manager.ts index 338948df..50f06ef5 100644 --- a/src/lib/sse-manager.ts +++ b/src/lib/sse-manager.ts @@ -1,4 +1,16 @@ import { createSignal } from "solid-js" +import { + MessageUpdateEvent, + MessageRemovedEvent, + MessagePartUpdatedEvent, + MessagePartRemovedEvent +} from "../types/message" +import type { + EventSessionUpdated, + EventSessionCompacted, + EventSessionError, + EventSessionIdle +} from "@opencode-ai/sdk" interface SSEConnection { instanceId: string @@ -7,19 +19,6 @@ interface SSEConnection { status: "connecting" | "connected" | "disconnected" | "error" } -interface MessageUpdateEvent { - type: "message_updated" - sessionId: string - messageId: string - parts: any[] - status: string -} - -interface SessionUpdateEvent { - type: "session_updated" - session: any -} - interface TuiToastEvent { type: "tui.toast.show" properties: { @@ -30,12 +29,17 @@ interface TuiToastEvent { } } -interface SessionIdleEvent { - type: "session.idle" - properties: { - sessionID: string - } -} +type SSEEvent = + | MessageUpdateEvent + | MessageRemovedEvent + | MessagePartUpdatedEvent + | MessagePartRemovedEvent + | EventSessionUpdated + | EventSessionCompacted + | EventSessionError + | EventSessionIdle + | TuiToastEvent + | { type: string; properties?: Record } // Fallback for unknown event types const [connectionStatus, setConnectionStatus] = createSignal< Map @@ -98,34 +102,36 @@ class SSEManager { } } - private handleEvent(instanceId: string, event: any): void { + private handleEvent(instanceId: string, event: SSEEvent): void { console.log("[SSE] Received event:", event.type, event) switch (event.type) { case "message.updated": + this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent) + break case "message.part.updated": - this.onMessageUpdate?.(instanceId, event) + this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent) break case "message.removed": - this.onMessageRemoved?.(instanceId, event) + this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent) break case "message.part.removed": - this.onMessagePartRemoved?.(instanceId, event) + this.onMessagePartRemoved?.(instanceId, event as MessagePartRemovedEvent) break case "session.updated": - this.onSessionUpdate?.(instanceId, event) + this.onSessionUpdate?.(instanceId, event as EventSessionUpdated) break case "session.compacted": - this.onSessionCompacted?.(instanceId, event) + this.onSessionCompacted?.(instanceId, event as EventSessionCompacted) break case "session.error": - this.onSessionError?.(instanceId, event) + this.onSessionError?.(instanceId, event as EventSessionError) break case "tui.toast.show": this.onTuiToast?.(instanceId, event as TuiToastEvent) break case "session.idle": - this.onSessionIdle?.(instanceId, event as SessionIdleEvent) + this.onSessionIdle?.(instanceId, event as EventSessionIdle) break default: console.warn("[SSE] Unknown event type:", event.type) @@ -162,13 +168,14 @@ class SSEManager { } onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void - onMessageRemoved?: (instanceId: string, event: any) => void - onMessagePartRemoved?: (instanceId: string, event: any) => void - onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void - onSessionCompacted?: (instanceId: string, event: any) => void - onSessionError?: (instanceId: string, event: any) => void + onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void + onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void + onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void + onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void + onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void + onSessionError?: (instanceId: string, event: EventSessionError) => void onTuiToast?: (instanceId: string, event: TuiToastEvent) => void - onSessionIdle?: (instanceId: string, event: SessionIdleEvent) => void + onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null { return connectionStatus().get(instanceId) ?? null diff --git a/src/lib/storage.ts b/src/lib/storage.ts index eb4c99bb..1113c6e2 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -4,6 +4,7 @@ export interface ConfigData { preferences: Preferences recentFolders: RecentFolder[] opencodeBinaries: OpenCodeBinary[] + theme?: "light" | "dark" | "system" } export interface InstanceData { diff --git a/src/lib/theme.tsx b/src/lib/theme.tsx index a6b551c7..ac2c0eda 100644 --- a/src/lib/theme.tsx +++ b/src/lib/theme.tsx @@ -1,5 +1,5 @@ import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js" -import { storage } from "./storage" +import { storage, type ConfigData } from "./storage" interface ThemeContextValue { isDark: () => boolean @@ -28,7 +28,7 @@ export function ThemeProvider(props: { children: JSX.Element }) { async function loadTheme() { try { const config = await storage.loadConfig() - const savedTheme = (config as any)?.theme + const savedTheme = config.theme let themeDark: boolean if (savedTheme === "system") { @@ -60,7 +60,7 @@ export function ThemeProvider(props: { children: JSX.Element }) { try { const config = await storage.loadConfig() const nextPreference = dark ? "dark" : "light" - ;(config as any).theme = nextPreference + config.theme = nextPreference themePreference = nextPreference await storage.saveConfig(config) } catch (error) { diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 04139ff7..3c306f3a 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" import type { Session, Agent, Provider } from "../types/session" -import type { Message, MessageDisplayParts } from "../types/message" +import type { Message, MessageDisplayParts, MessagePartRemovedEvent, MessagePartUpdatedEvent, MessageRemovedEvent, MessageUpdateEvent } from "../types/message" import { partHasRenderableText } from "../types/message" import { instances } from "./instances" @@ -8,6 +8,22 @@ import { sseManager } from "../lib/sse-manager" import { decodeHtmlEntities } from "../lib/markdown" import { showToastNotification, ToastVariant } from "../lib/notifications" import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences" +import type { + EventSessionUpdated, + EventSessionCompacted, + EventSessionError, + EventSessionIdle +} from "@opencode-ai/sdk" + +interface TuiToastEvent { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} interface SessionInfo { tokens: number @@ -275,13 +291,17 @@ export function computeDisplayParts(message: Message, showThinking: boolean): Me function initializePartVersion(part: any, version = 0) { if (!part || typeof part !== "object") return const partAny = part as any - partAny.version = typeof partAny.version === "number" ? partAny.version : version + // Ensure version is always set for client-side part tracking + if (typeof partAny.version !== "number") { + partAny.version = version + } } function bumpPartVersion(previousPart: any, nextPart: any): number { const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1 const nextVersion = prevVersion + 1 - initializePartVersion(nextPart, nextVersion) + // Always initialize the version on the new part + nextPart.version = nextVersion return nextVersion } @@ -383,6 +403,7 @@ async function fetchSessions(instanceId: string): Promise { parentId: apiSession.parentID || null, agent: "", model: { providerId: "", modelId: "" }, + version: apiSession.version, // Include version from SDK time: { created: apiSession.time.created, updated: apiSession.time.updated, @@ -639,6 +660,7 @@ async function createSession(instanceId: string, agent?: string): Promise { - // Session already mutated in place + // All the message mutation logic should go here to ensure reactivity + const index = getSessionIndex(instanceId, part.sessionID) + let messageIndex = index.messageIndex.get(part.messageID) + let replacedTemp = false + + if (messageIndex === undefined) { + // Search for queued message with status 'sending' and no server id + for (let i = 0; i < session.messages.length; i++) { + const msg = session.messages[i] + if (msg.sessionId === part.sessionID && msg.status === "sending") { + messageIndex = i + replacedTemp = true + break + } + } + } + + if (messageIndex === undefined) { + // Create new message + const newMessage: Message = { + id: part.messageID, + sessionId: part.sessionID, + type: "assistant" as const, + parts: [part], + timestamp: Date.now(), + status: "streaming" as const, + version: 0, + } + + initializePartVersion(part) + newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks) + + let insertIndex = session.messages.length + for (let i = session.messages.length - 1; i >= 0; i--) { + if (session.messages[i].id < newMessage.id) { + insertIndex = i + 1 + break + } + } + + session.messages.splice(insertIndex, 0, newMessage) + rebuildSessionIndex(instanceId, part.sessionID, session.messages) + } else { + // Update existing message + const message = session.messages[messageIndex] + if (typeof message.version !== "number") { + message.version = 0 + } + + // Strip synthetic parts when real data arrives + let filteredSynthetics = false + if (message.parts.some((partItem: any) => partItem.synthetic === true)) { + message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true) + filteredSynthetics = true + // Clear render cache from remaining parts when synthetic parts are removed + message.parts.forEach((partItem: any) => { + if (partItem.type === "text") { + partItem.renderCache = undefined + } + }) + } + + let baseParts: any[] + if (replacedTemp) { + baseParts = message.parts.filter((partItem: any) => partItem.type !== "text") + message.parts = baseParts + // Clear render cache when replacing temp content + baseParts.forEach((partItem: any) => { + if (partItem.type === "text") { + partItem.renderCache = undefined + } + }) + } else { + baseParts = message.parts + } + + // Update part in place + let partMap = index.partIndex.get(message.id) + if (!partMap) { + partMap = new Map() + index.partIndex.set(message.id, partMap) + } + + let shouldIncrementVersion = filteredSynthetics || replacedTemp + const partIndex = partMap.get(part.id) + + if (partIndex === undefined) { + initializePartVersion(part) + baseParts.push(part) + if (part.id && typeof part.id === "string") { + partMap.set(part.id, baseParts.length - 1) + } + shouldIncrementVersion = true + // Clear render cache for new text parts + if (part.type === "text") { + part.renderCache = undefined + } + } else { + const previousPart = baseParts[partIndex] + const textUnchanged = + !filteredSynthetics && + !replacedTemp && + part.type === "text" && + previousPart?.type === "text" && + previousPart.text === part.text + + if (textUnchanged) { + return + } + + bumpPartVersion(previousPart, part) + baseParts[partIndex] = part + if (part.type !== "text" || !previousPart || previousPart.text !== part.text) { + shouldIncrementVersion = true + // Clear render cache when text changes + if (part.type === "text") { + part.renderCache = undefined + } + } + } + + const oldId = message.id + message.id = replacedTemp ? part.messageID : message.id + message.status = message.status === "sending" ? "streaming" : message.status + message.parts = baseParts + + if (shouldIncrementVersion) { + message.version += 1 + message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) + } else if ( + !message.displayParts || + message.displayParts.showThinking !== preferences().showThinkingBlocks || + message.displayParts.version !== message.version + ) { + message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) + } + + // Update message index if ID changed + if (oldId !== message.id) { + index.messageIndex.delete(oldId) + index.messageIndex.set(message.id, messageIndex) + const existingPartMap = index.partIndex.get(oldId) + if (existingPartMap) { + index.partIndex.delete(oldId) + index.partIndex.set(message.id, existingPartMap) + } + } + + // Refresh part indexes after filtering synthetic parts or replacing optimistic content + if (filteredSynthetics || replacedTemp) { + const partMap = new Map() + message.parts.forEach((partItem, idx) => { + if (partItem.id && typeof partItem.id === "string") { + partMap.set(partItem.id, idx) + } + }) + index.partIndex.set(message.id, partMap) + } + } }) updateSessionInfo(instanceId, part.sessionID) @@ -1432,14 +1613,102 @@ function handleMessageUpdate(instanceId: string, event: any): void { session.messagesInfo.set(info.id, info) withSession(instanceId, info.sessionID, (session) => { - // Session already mutated in place + const index = getSessionIndex(instanceId, info.sessionID) + let messageIndex = index.messageIndex.get(info.id) + + if (messageIndex === undefined) { + // Look for queued message to replace + let tempMessageIndex = -1 + for (let i = 0; i < session.messages.length; i++) { + const msg = session.messages[i] + if ( + msg.sessionId === info.sessionID && + msg.type === (info.role === "user" ? "user" : "assistant") && + msg.status === "sending" + ) { + tempMessageIndex = i + break + } + } + + if (tempMessageIndex === -1) { + for (let i = 0; i < session.messages.length; i++) { + const msg = session.messages[i] + if (msg.sessionId === info.sessionID && msg.status === "sending") { + tempMessageIndex = i + break + } + } + } + + if (tempMessageIndex > -1) { + // Replace queued message + const message = session.messages[tempMessageIndex] + if (typeof message.version !== "number") { + message.version = 0 + } + + const oldId = message.id + message.id = info.id + message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant" + message.timestamp = info.time?.created || Date.now() + message.status = "complete" as const + message.version += 1 + message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) + + if (oldId !== message.id) { + index.messageIndex.delete(oldId) + index.messageIndex.set(message.id, tempMessageIndex) + const existingPartMap = index.partIndex.get(oldId) + if (existingPartMap) { + index.partIndex.delete(oldId) + index.partIndex.set(message.id, existingPartMap) + } + } + } else { + // Append new message + const newMessage: Message = { + id: info.id, + sessionId: info.sessionID, + type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant", + parts: [], + timestamp: info.time?.created || Date.now(), + status: "complete" as const, + version: 0, + } + + newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks) + + let insertIndex = session.messages.length + for (let i = session.messages.length - 1; i >= 0; i--) { + if (session.messages[i].id < newMessage.id) { + insertIndex = i + 1 + break + } + } + + session.messages.splice(insertIndex, 0, newMessage) + rebuildSessionIndex(instanceId, info.sessionID, session.messages) + } + } else { + // Update existing message status + const message = session.messages[messageIndex] + if (typeof message.version !== "number") { + message.version = 0 + } + message.status = "complete" as const + message.version += 1 + message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks) + } + + session.messagesInfo.set(info.id, info) }) updateSessionInfo(instanceId, info.sessionID) } } -function handleSessionUpdate(instanceId: string, event: any): void { +function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { const info = event.properties?.info if (!info) return @@ -1455,11 +1724,12 @@ function handleSessionUpdate(instanceId: string, event: any): void { instanceId, title: info.title || "Untitled", parentId: info.parentID || null, - agent: info.agent || "", + agent: "", model: { - providerId: info.model?.providerID || "", - modelId: info.model?.modelID || "", + providerId: "", + modelId: "", }, + version: info.version || "0", time: { created: info.time?.created || Date.now(), updated: info.time?.updated || Date.now(), @@ -1481,13 +1751,6 @@ function handleSessionUpdate(instanceId: string, event: any): void { const updatedSession = { ...existingSession, title: info.title || existingSession.title, - agent: info.agent || existingSession.agent, - model: info.model - ? { - providerId: info.model.providerID || existingSession.model.providerId, - modelId: info.model.modelID || existingSession.model.modelId, - } - : existingSession.model, time: { ...existingSession.time, updated: info.time?.updated || Date.now(), @@ -1499,7 +1762,7 @@ function handleSessionUpdate(instanceId: string, event: any): void { snapshot: info.revert.snapshot, diff: info.revert.diff, } - : undefined, + : existingSession.revert, } setSessions((prev) => { @@ -1512,6 +1775,15 @@ function handleSessionUpdate(instanceId: string, event: any): void { } } +function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { + const sessionId = event.properties?.sessionID + if (!sessionId) return + + console.log(`[SSE] Session idle: ${sessionId}`) + // Could be used to show user that the session is idle/finished processing + // For now, just log it - could be extended to show a notification or update UI state +} + function resolvePastedPlaceholders(prompt: string, attachments: any[] = []): string { if (!prompt || !prompt.includes("[pasted #")) { return prompt @@ -1779,7 +2051,7 @@ async function updateSessionModel( updateSessionInfo(instanceId, sessionId) } -function handleSessionCompacted(instanceId: string, event: any): void { +function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void { const sessionID = event.properties?.sessionID if (!sessionID) return @@ -1800,26 +2072,25 @@ function handleSessionCompacted(instanceId: string, event: any): void { }) } -function handleSessionError(instanceId: string, event: any): void { +function handleSessionError(instanceId: string, event: EventSessionError): void { const error = event.properties?.error const sessionID = event.properties?.sessionID console.error(`[SSE] Session error:`, error) - let message = error?.data?.message || error?.message || "Unknown error" + let message = "Unknown error" - if (error?.data?.responseBody) { - try { - const body = JSON.parse(error.data.responseBody) - if (body.error) { - message = body.error - } - } catch {} + if (error) { + if ('data' in error && error.data && typeof error.data === 'object' && 'message' in error.data) { + message = error.data.message as string + } else if ('message' in error && typeof error.message === 'string') { + message = error.message + } } alert(`Error: ${message}`) } -function handleMessageRemoved(instanceId: string, event: any): void { +function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void { const sessionID = event.properties?.sessionID if (!sessionID) return @@ -1827,7 +2098,7 @@ function handleMessageRemoved(instanceId: string, event: any): void { loadMessages(instanceId, sessionID, true).catch(console.error) } -function handleMessagePartRemoved(instanceId: string, event: any): void { +function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void { const sessionID = event.properties?.sessionID if (!sessionID) return @@ -1835,7 +2106,7 @@ function handleMessagePartRemoved(instanceId: string, event: any): void { loadMessages(instanceId, sessionID, true).catch(console.error) } -function handleTuiToast(_instanceId: string, event: any): void { +function handleTuiToast(_instanceId: string, event: TuiToastEvent): void { const payload = event?.properties if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return if (!payload.message.trim()) return @@ -1853,11 +2124,13 @@ function handleTuiToast(_instanceId: string, event: any): void { } sseManager.onMessageUpdate = handleMessageUpdate +sseManager.onMessagePartUpdated = handleMessageUpdate sseManager.onMessageRemoved = handleMessageRemoved sseManager.onMessagePartRemoved = handleMessagePartRemoved sseManager.onSessionUpdate = handleSessionUpdate sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionError = handleSessionError +sseManager.onSessionIdle = handleSessionIdle sseManager.onTuiToast = handleTuiToast export { diff --git a/src/types/instance.ts b/src/types/instance.ts index 74adc998..a84e2151 100644 --- a/src/types/instance.ts +++ b/src/types/instance.ts @@ -1,4 +1,5 @@ import type { OpencodeClient } from "@opencode-ai/sdk/client" +import type { Project as SDKProject } from "@opencode-ai/sdk" export interface LogEntry { timestamp: number @@ -6,24 +7,23 @@ export interface LogEntry { message: string } -export interface ProjectInfo { - id: string - worktree: string - vcs?: "git" - time: { - created: number - initialized?: number - } -} +// Use SDK Project type instead of our own +export type ProjectInfo = SDKProject export interface McpServerStatus { name: string status: "running" | "stopped" | "error" } +// Raw MCP status from server (SDK returns unknown for /mcp endpoint) +export type RawMcpStatus = Record + export interface InstanceMetadata { project?: ProjectInfo - mcpStatus?: unknown + mcpStatus?: RawMcpStatus version?: string } diff --git a/src/types/message.ts b/src/types/message.ts index ec442c86..ecfe107a 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -1,3 +1,23 @@ +// SDK types +import type { + EventMessageUpdated as MessageUpdateEvent, + EventMessageRemoved as MessageRemovedEvent, + EventMessagePartUpdated as MessagePartUpdatedEvent, + EventMessagePartRemoved as MessagePartRemovedEvent, + Part as SDKPart, + Message as SDKMessage +} from "@opencode-ai/sdk" + +// Re-export for other modules +export type { + MessageUpdateEvent, + MessageRemovedEvent, + MessagePartUpdatedEvent, + MessagePartRemovedEvent, + SDKPart, + SDKMessage +} + export interface RenderCache { text: string html: string @@ -5,11 +25,20 @@ export interface RenderCache { mode?: string } +// Client-specific part extensions (using intersection type since SDKPart is a union) +export type ClientPart = SDKPart & { + sessionID?: string + messageID?: string + synthetic?: boolean + version?: number + renderCache?: RenderCache +} + export interface MessageDisplayParts { - text: any[] - tool: any[] - reasoning: any[] - combined: any[] + text: ClientPart[] + tool: ClientPart[] + reasoning: ClientPart[] + combined: ClientPart[] showThinking: boolean version: number } @@ -18,7 +47,7 @@ export interface Message { id: string sessionId: string type: "user" | "assistant" - parts: any[] + parts: ClientPart[] timestamp: number status: "sending" | "sent" | "streaming" | "complete" | "error" version: number @@ -34,42 +63,41 @@ export interface TextPart { renderCache?: RenderCache } -function hasTextSegment(segment: unknown): boolean { +export type MessageInfo = SDKMessage + +function hasTextSegment(segment: string | { text?: string }): boolean { if (typeof segment === "string") { return segment.trim().length > 0 } - if (segment && typeof segment === "object") { - const maybeText = (segment as { text?: unknown }).text - if (typeof maybeText === "string") { - return maybeText.trim().length > 0 - } + if (segment && typeof segment === "object" && segment.text) { + return typeof segment.text === "string" && segment.text.trim().length > 0 } return false } -export function partHasRenderableText(part: any): boolean { +export function partHasRenderableText(part: ClientPart): boolean { if (!part || typeof part !== "object") { return false } - if (hasTextSegment(part.text)) { + const typedPart = part as SDKPart + + if (typedPart.type === "text" && hasTextSegment(typedPart.text)) { return true } - const contentArray = Array.isArray(part?.content) ? part.content : [] - for (const item of contentArray) { - if (hasTextSegment(item)) { - return true - } + if (typedPart.type === "file" && (typedPart as any).filename) { + return true } - const thinkingContent = Array.isArray(part?.thinking?.content) ? part.thinking.content : [] - for (const chunk of thinkingContent) { - if (hasTextSegment(chunk)) { - return true - } + if (typedPart.type === "tool") { + return true // Tool parts are always renderable + } + + if (typedPart.type === "reasoning" && hasTextSegment(typedPart.text)) { + return true } return false diff --git a/src/types/session.ts b/src/types/session.ts index e599c07c..cf34af62 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -1,29 +1,64 @@ -import type { Message } from "./message" +import type { Message, MessageInfo } from "./message" +import type { + Session as SDKSession, + Agent as SDKAgent, + Provider as SDKProvider, + Model as SDKModel +} from "@opencode-ai/sdk" -export interface Session { - id: string - instanceId: string - title: string - parentId: string | null - agent: string - model: { +// Export SDK types for external use +export type { + Session as SDKSession, + Agent as SDKAgent, + Provider as SDKProvider, + Model as SDKModel +} from "@opencode-ai/sdk" + +// Our client-specific Session interface extending SDK Session +export interface Session extends Omit { + instanceId: string // Client-specific field + parentId: string | null // Client-specific field (override parentID) + agent: string // Client-specific field + model: { // Client-specific field providerId: string modelId: string } - time: { - created: number - updated: number - } - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } - messages: Message[] - messagesInfo: Map + messages: Message[] // Client-specific field + messagesInfo: Map // Client-specific field + version: string // Include version from SDK Session } +// Adapter function to convert SDK Session to client Session +export function createClientSession( + sdkSession: import("@opencode-ai/sdk").Session, + instanceId: string, + agent: string = "", + model: { providerId: string; modelId: string } = { providerId: "", modelId: "" } +): Session { + return { + ...sdkSession, + instanceId, + parentId: sdkSession.parentID || null, + agent, + model, + messages: [], + messagesInfo: new Map(), + } +} + +// Type guard to check if object is SDK Session +export function isSdkSession(obj: unknown): obj is import("@opencode-ai/sdk").Session { + return ( + typeof obj === "object" && + obj !== null && + "id" in obj && + "title" in obj && + "version" in obj && + "time" in obj + ) +} + +// Our client-specific Agent interface (simplified version of SDK Agent) export interface Agent { name: string description: string @@ -34,6 +69,7 @@ export interface Agent { } } +// Our client-specific Provider interface (simplified version of SDK Provider) export interface Provider { id: string name: string @@ -41,6 +77,7 @@ export interface Provider { defaultModelId?: string } +// Our client-specific Model interface (simplified version of SDK Model) export interface Model { id: string name: string