Working messages display

This commit is contained in:
Shantur Rathore
2025-10-22 22:10:51 +01:00
commit fa77b4e82e
53 changed files with 9336 additions and 0 deletions

247
src/App.tsx Normal file
View File

@@ -0,0 +1,247 @@
import { Component, onMount, Show, createMemo, createEffect } from "solid-js"
import type { Session } from "./types/session"
import EmptyState from "./components/empty-state"
import SessionPicker from "./components/session-picker"
import InstanceTabs from "./components/instance-tabs"
import SessionTabs from "./components/session-tabs"
import MessageStream from "./components/message-stream"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
setHasInstances,
sessionPickerInstance,
hideSessionPicker,
showSessionPicker,
} from "./stores/ui"
import {
createInstance,
instances,
updateInstance,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
} from "./stores/instances"
import {
getSessions,
activeSessionId,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
createSession,
deleteSession,
getSessionFamily,
activeParentSessionId,
getParentSessions,
loadMessages,
} from "./stores/sessions"
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
const SessionMessages: Component<{
sessionId: string
activeSessions: Map<string, Session>
instanceId: string
}> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.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>
}
>
{(s) => <MessageStream sessionId={s().id} messages={s().messages || []} messagesInfo={s().messagesInfo} />}
</Show>
)
}
const App: Component = () => {
const activeInstance = createMemo(() => getActiveInstance())
const activeSessions = createMemo(() => {
const instance = activeInstance()
if (!instance) return new Map()
const instanceId = instance.id
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return new Map()
const sessionFamily = getSessionFamily(instanceId, parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
if (!instance) return null
return activeSessionId().get(instance.id) || null
})
async function handleSelectFolder() {
setIsSelectingFolder(true)
try {
const folder = await window.electronAPI.selectFolder()
if (!folder) {
return
}
const instanceId = await createInstance(folder)
setHasInstances(true)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
} catch (error) {
console.error("Failed to create instance:", error)
} finally {
setIsSelectingFolder(false)
}
}
async function handleCloseInstance(instanceId: string) {
if (confirm("Stop OpenCode instance? This will stop the server.")) {
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
}
async function handleNewSession(instanceId: string) {
try {
const session = await createSession(instanceId)
setActiveParentSession(instanceId, session.id)
} catch (error) {
console.error("Failed to create session:", error)
}
}
async function handleCloseSession(instanceId: string, sessionId: string) {
const sessions = getSessions(instanceId)
const session = sessions.find((s) => s.id === sessionId)
const isParent = session?.parentId === null
if (!isParent) {
return
}
clearActiveParentSession(instanceId)
showSessionPicker(instanceId)
}
onMount(() => {
setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession)
window.electronAPI.onNewInstance(() => {
handleSelectFolder()
})
window.electronAPI.onInstanceStarted(({ id, port, pid }) => {
console.log("Instance started:", { id, port, pid })
updateInstance(id, { port, pid, status: "ready" })
})
window.electronAPI.onInstanceError(({ id, error }) => {
console.error("Instance error:", { id, error })
updateInstance(id, { status: "error", error })
})
window.electronAPI.onInstanceStopped(({ id }) => {
console.log("Instance stopped:", id)
updateInstance(id, { status: "stopped" })
})
})
return (
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleSelectFolder}
/>
<Show when={activeInstance()}>
{(instance) => (
<>
<Show
when={activeSessions().size > 0}
fallback={
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-gray-500">
<p class="mb-2">No parent session selected</p>
<p class="text-sm">Select or create a parent session to begin</p>
</div>
</div>
}
>
<SessionTabs
instanceId={instance().id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={(id) => setActiveSession(instance().id, id)}
onClose={(id) => handleCloseSession(instance().id, id)}
onNew={() => handleNewSession(instance().id)}
/>
<div class="content-area flex-1 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "logs"}
fallback={
<Show
when={activeSessionIdForInstance()}
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
<SessionMessages
sessionId={activeSessionIdForInstance()!}
activeSessions={activeSessions()}
instanceId={activeInstance()!.id}
/>
</Show>
}
>
<div class="p-4 text-gray-600">
<p class="font-semibold mb-2">Server Logs</p>
<p class="text-sm">Log viewer will be implemented in Task 013</p>
</div>
</Show>
</div>
</Show>
</>
)}
</Show>
</>
}
>
<EmptyState onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} />
</Show>
<Show when={sessionPickerInstance()}>
{(instanceId) => <SessionPicker instanceId={instanceId()} open={true} onClose={hideSessionPicker} />}
</Show>
</div>
)
}
export default App

View File

@@ -0,0 +1,49 @@
import { Component } from "solid-js"
import { Folder, Loader2 } from "lucide-solid"
interface EmptyStateProps {
onSelectFolder: () => void
isLoading?: boolean
}
const EmptyState: Component<EmptyStateProps> = (props) => {
return (
<div class="flex h-full w-full items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center">
<Folder class="h-16 w-16 text-gray-400 dark:text-gray-600" />
</div>
<h1 class="mb-4 text-2xl font-semibold text-gray-900 dark:text-gray-100">Welcome to OpenCode Client</h1>
<p class="mb-8 text-base text-gray-600 dark:text-gray-400">Select a folder to start coding with AI</p>
<button
onClick={props.onSelectFolder}
disabled={props.isLoading}
class="mb-4 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{props.isLoading ? (
<>
<Loader2 class="h-4 w-4 animate-spin" />
Selecting...
</>
) : (
"Select Folder"
)}
</button>
<p class="text-sm text-gray-500 dark:text-gray-500">
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
</p>
<div class="mt-6 space-y-1 text-sm text-gray-400 dark:text-gray-600">
<p>Examples: ~/projects/my-app</p>
<p>You can have multiple instances of the same folder</p>
</div>
</div>
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,61 @@
import { Component } from "solid-js"
import type { Instance } from "../types/instance"
import { FolderOpen, X } from "lucide-solid"
interface InstanceTabProps {
instance: Instance
active: boolean
onSelect: () => void
onClose: () => void
}
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
const name = path.split("/").pop() || path
const duplicates = instances.filter((i) => {
const iName = i.folder.split("/").pop() || i.folder
return iName === name
})
if (duplicates.length > 1) {
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
return `~/${name} (${index + 1})`
}
return `~/${name}`
}
const InstanceTab: Component<InstanceTabProps> = (props) => {
return (
<div class="instance-tab-container group">
<button
class={`instance-tab inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors ${
props.active ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
onClick={props.onSelect}
title={props.instance.folder}
role="tab"
aria-selected={props.active}
>
<FolderOpen class="w-4 h-4 flex-shrink-0" />
<span class="tab-label truncate text-sm">
{props.instance.folder.split("/").pop() || props.instance.folder}
</span>
<span
class="tab-close opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-opacity ml-auto cursor-pointer"
onClick={(e) => {
e.stopPropagation()
props.onClose()
}}
role="button"
tabIndex={0}
aria-label="Close instance"
>
<X class="w-3 h-3" />
</span>
</button>
</div>
)
}
export default InstanceTab

View File

@@ -0,0 +1,41 @@
import { Component, For } from "solid-js"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import { Plus } from "lucide-solid"
interface InstanceTabsProps {
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
return (
<div class="instance-tabs bg-gray-50 border-b border-gray-200">
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-200 transition-colors"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
)
}
export default InstanceTabs

View File

@@ -0,0 +1,70 @@
import { For, Show } from "solid-js"
import type { Message } from "../types/message"
import MessagePart from "./message-part"
interface MessageItemProps {
message: Message
messageInfo?: any
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const timestamp = () => {
const date = new Date(props.message.timestamp)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const errorMessage = () => {
if (!props.messageInfo?.error) return null
const error = props.messageInfo.error
if (error.name === "ProviderAuthError") {
return error.data?.message || "Authentication error"
}
if (error.name === "MessageOutputLengthError") {
return "Message output length exceeded"
}
if (error.name === "MessageAbortedError") {
return "Request was aborted"
}
if (error.name === "UnknownError") {
return error.data?.message || "Unknown error occurred"
}
return null
}
const hasContent = () => {
return props.message.parts.length > 0 || errorMessage() !== null
}
const isGenerating = () => {
return !hasContent() && props.messageInfo?.time?.completed === 0
}
return (
<div class={`message-item ${isUser() ? "user" : "assistant"}`}>
<div class="message-header">
<span class="message-sender">{isUser() ? "You" : "Assistant"}</span>
<span class="message-timestamp">{timestamp()}</span>
</div>
<div class="message-content">
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
</Show>
<Show when={isGenerating()}>
<div class="message-generating">
<span class="generating-spinner"></span> Generating...
</div>
</Show>
<For each={props.message.parts}>{(part) => <MessagePart part={part} />}</For>
</div>
<Show when={props.message.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Show, Match, Switch } from "solid-js"
import ToolCall from "./tool-call"
interface MessagePartProps {
part: any
}
export default function MessagePart(props: MessagePartProps) {
const partType = () => props.part?.type || ""
return (
<Switch>
<Match when={partType() === "text"}>
<Show when={!props.part.synthetic && props.part.text}>
<div class="message-text">{props.part.text}</div>
</Show>
</Match>
<Match when={partType() === "tool"}>
<ToolCall toolCall={props.part} />
</Match>
<Match when={partType() === "error"}>
<div class="message-error-part"> {props.part.message}</div>
</Match>
<Match when={partType() === "reasoning"}>
<div class="message-reasoning">
<details>
<summary class="text-sm text-gray-500 cursor-pointer">Reasoning</summary>
<div class="message-text mt-2">{props.part.text || ""}</div>
</details>
</div>
</Match>
</Switch>
)
}

View File

@@ -0,0 +1,138 @@
import { For, Show, createSignal, createEffect, createMemo } from "solid-js"
import type { Message } from "../types/message"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
interface MessageStreamProps {
sessionId: string
messages: Message[]
messagesInfo?: Map<string, any>
loading?: boolean
}
interface DisplayItem {
type: "message" | "tool"
data: any
messageInfo?: any
}
export default function MessageStream(props: MessageStreamProps) {
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollButton, setShowScrollButton] = createSignal(false)
function scrollToBottom() {
if (containerRef) {
containerRef.scrollTop = containerRef.scrollHeight
setAutoScroll(true)
setShowScrollButton(false)
}
}
function handleScroll() {
if (!containerRef) return
const { scrollTop, scrollHeight, clientHeight } = containerRef
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50
setAutoScroll(isAtBottom)
setShowScrollButton(!isAtBottom)
}
const displayItems = createMemo(() => {
const items: DisplayItem[] = []
for (const message of props.messages) {
const messageInfo = props.messagesInfo?.get(message.id)
const textParts = message.parts.filter((p) => p.type === "text" && !p.synthetic)
const toolParts = message.parts.filter((p) => p.type === "tool")
const reasoningParts = message.parts.filter((p) => p.type === "reasoning")
if (textParts.length > 0 || reasoningParts.length > 0 || messageInfo?.error) {
items.push({
type: "message",
data: {
...message,
parts: [...textParts, ...reasoningParts],
},
messageInfo,
})
}
for (const toolPart of toolParts) {
items.push({
type: "tool",
data: toolPart,
messageInfo,
})
}
}
return items
})
const itemsLength = () => displayItems().length
createEffect(() => {
itemsLength()
if (autoScroll()) {
setTimeout(scrollToBottom, 0)
}
})
return (
<div class="message-stream-container">
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
<Show when={!props.loading && displayItems().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<h3>Start a conversation</h3>
<p>Type a message below or try:</p>
<ul>
<li>
<code>/init-project</code>
</li>
<li>Ask about your codebase</li>
<li>
Attach files with <code>@</code>
</li>
</ul>
</div>
</div>
</Show>
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>Loading messages...</p>
</div>
</Show>
<For each={displayItems()}>
{(item) => (
<Show
when={item.type === "message"}
fallback={
<div class="tool-call-message">
<div class="tool-call-header-label">
<span class="tool-call-icon">🔧</span>
<span>Tool Call</span>
<span class="tool-name">{item.data?.tool || "unknown"}</span>
</div>
<ToolCall toolCall={item.data} />
</div>
}
>
<MessageItem message={item.data} messageInfo={item.messageInfo} />
</Show>
)}
</For>
</div>
<Show when={showScrollButton()}>
<button class="scroll-to-bottom" onClick={scrollToBottom} aria-label="Scroll to bottom">
</button>
</Show>
</div>
)
}

View File

@@ -0,0 +1,149 @@
import { Component, createSignal, Show, For, createEffect } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import type { Session, Agent } from "../types/session"
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
import { instances, stopInstance } from "../stores/instances"
import { agents } from "../stores/sessions"
interface SessionPickerProps {
instanceId: string
open: boolean
onClose: () => void
}
const SessionPicker: Component<SessionPickerProps> = (props) => {
const [selectedAgent, setSelectedAgent] = createSignal<string>("")
const [isCreating, setIsCreating] = createSignal(false)
const instance = () => instances().get(props.instanceId)
const parentSessions = () => getParentSessions(props.instanceId)
const agentList = () => agents().get(props.instanceId) || []
createEffect(() => {
const list = agentList()
if (list.length > 0 && !selectedAgent()) {
setSelectedAgent(list[0].name)
}
})
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
}
async function handleSessionSelect(sessionId: string) {
setActiveParentSession(props.instanceId, sessionId)
props.onClose()
}
async function handleNewSession() {
setIsCreating(true)
try {
const session = await createSession(props.instanceId, selectedAgent())
setActiveParentSession(props.instanceId, session.id)
props.onClose()
} catch (error) {
console.error("Failed to create session:", error)
} finally {
setIsCreating(false)
}
}
async function handleCancel() {
await stopInstance(props.instanceId)
props.onClose()
}
return (
<Dialog open={props.open} onOpenChange={(open) => !open && handleCancel()}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50 z-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-2xl w-full max-w-lg p-6">
<Dialog.Title class="text-xl font-semibold text-gray-900 mb-4">
OpenCode {instance()?.folder.split("/").pop()}
</Dialog.Title>
<div class="space-y-6">
<Show
when={parentSessions().length > 0}
fallback={<div class="text-center py-4 text-gray-500 text-sm">No previous sessions</div>}
>
<div>
<h3 class="text-sm font-medium text-gray-700 mb-2">Resume a session ({parentSessions().length}):</h3>
<div class="space-y-1 max-h-[400px] overflow-y-auto">
<For each={parentSessions()}>
{(session) => (
<button
class="w-full text-left px-3 py-2 rounded hover:bg-gray-100 transition-colors group"
onClick={() => handleSessionSelect(session.id)}
>
<div class="flex justify-between items-start">
<span class="text-sm text-gray-900 truncate flex-1">{session.title || "Untitled"}</span>
<span class="text-xs text-gray-500 ml-2 flex-shrink-0">
{formatRelativeTime(session.time.updated)}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</Show>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300" />
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">or</span>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-gray-700 mb-2">Start new session:</h3>
<div class="space-y-3">
<Show
when={agentList().length > 0}
fallback={<div class="text-sm text-gray-500">Loading agents...</div>}
>
<select
class="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white hover:border-gray-400 transition-colors"
value={selectedAgent()}
onChange={(e) => setSelectedAgent(e.currentTarget.value)}
>
<For each={agentList()}>{(agent) => <option value={agent.name}>{agent.name}</option>}</For>
</select>
</Show>
<button
class="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
onClick={handleNewSession}
disabled={isCreating() || agentList().length === 0}
>
{isCreating() ? "Creating..." : "Start"}
</button>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button class="px-4 py-2 text-sm text-gray-700 hover:text-gray-900" onClick={handleCancel}>
Cancel
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default SessionPicker

View File

@@ -0,0 +1,56 @@
import { Component, Show } from "solid-js"
import type { Session } from "../types/session"
import { MessageSquare, Terminal, X } from "lucide-solid"
interface SessionTabProps {
session?: Session
special?: "logs"
active: boolean
isParent?: boolean
onSelect: () => void
onClose?: () => void
}
const SessionTab: Component<SessionTabProps> = (props) => {
const label = () => {
if (props.special === "logs") return "Logs"
return props.session?.title || "Untitled"
}
return (
<div class="session-tab-container group">
<button
class={`session-tab inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm ${
props.active
? "bg-white border-b-2 border-blue-500 font-medium text-gray-900"
: "text-gray-600 hover:bg-gray-100"
} ${props.special === "logs" ? "text-gray-500" : ""} ${props.isParent && !props.active ? "font-semibold" : ""}`}
onClick={props.onSelect}
title={label()}
role="tab"
aria-selected={props.active}
>
<Show when={props.special === "logs"} fallback={<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />}>
<Terminal class="w-3.5 h-3.5 flex-shrink-0" />
</Show>
<span class="tab-label truncate">{label()}</span>
<Show when={!props.special && props.onClose}>
<span
class="tab-close opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-opacity cursor-pointer"
onClick={(e) => {
e.stopPropagation()
props.onClose?.()
}}
role="button"
tabIndex={0}
aria-label="Close session"
>
<X class="w-3 h-3" />
</span>
</Show>
</button>
</div>
)
}
export default SessionTab

View File

@@ -0,0 +1,46 @@
import { Component, For } from "solid-js"
import type { Session } from "../types/session"
import SessionTab from "./session-tab"
import { Plus } from "lucide-solid"
interface SessionTabsProps {
instanceId: string
sessions: Map<string, Session>
activeSessionId: string | null
onSelect: (sessionId: string) => void
onClose: (sessionId: string) => void
onNew: () => void
}
const SessionTabs: Component<SessionTabsProps> = (props) => {
const sessionsList = () => Array.from(props.sessions.entries())
return (
<div class="session-tabs bg-white border-b border-gray-200">
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<For each={sessionsList()}>
{([id, session]) => (
<SessionTab
session={session}
active={id === props.activeSessionId}
isParent={session.parentId === null}
onSelect={() => props.onSelect(id)}
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
/>
)}
</For>
<SessionTab special="logs" active={props.activeSessionId === "logs"} onSelect={() => props.onSelect("logs")} />
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
onClick={props.onNew}
title="New parent session (Cmd/Ctrl+T)"
aria-label="New parent session"
>
<Plus class="w-4 h-4" />
</button>
</div>
</div>
)
}
export default SessionTabs

View File

@@ -0,0 +1,139 @@
import { createSignal, Show } from "solid-js"
interface ToolCallProps {
toolCall: any
}
export default function ToolCall(props: ToolCallProps) {
const [expanded, setExpanded] = createSignal(false)
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
switch (status) {
case "pending":
return "⏳"
case "running":
return "⏳"
case "completed":
return "✓"
case "error":
return "✗"
default:
return ""
}
}
const statusClass = () => {
const status = props.toolCall?.state?.status || "pending"
return `tool-call-status-${status}`
}
function toggleExpanded() {
setExpanded(!expanded())
}
function formatToolSummary() {
const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {}
const input = state.input || {}
if (state.title) {
return state.title
}
switch (toolName) {
case "bash":
return `bash: ${input.command || ""}`
case "edit":
return `edit ${input.filePath || ""}`
case "read":
return `read ${input.filePath || ""}`
case "write":
return `write ${input.filePath || ""}`
case "glob":
return `glob ${input.pattern || ""}`
case "grep":
return `grep ${input.pattern || ""}`
default:
return toolName || "Unknown tool"
}
}
function formatToolOutput() {
const state = props.toolCall?.state || {}
if (state.error) {
return `Error: ${state.error}`
}
if (state.output) {
return state.output
}
return "No output"
}
function formatOutputPreview() {
const state = props.toolCall?.state || {}
if (state.error) {
return state.error
}
if (state.output) {
const output = state.output
const lines = output.split("\n")
if (lines.length <= 10) {
return output
}
const firstTenLines = lines.slice(0, 10).join("\n")
return firstTenLines + "\n..."
}
return "No output"
}
const hasResult = () => {
const status = props.toolCall?.state?.status || ""
return status === "completed" || status === "error"
}
return (
<div class={`tool-call ${statusClass()}`}>
<button class="tool-call-header" onClick={toggleExpanded} aria-expanded={expanded()}>
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-summary">{formatToolSummary()}</span>
<span class="tool-call-status">{statusIcon()}</span>
</button>
<Show when={!expanded() && hasResult()}>
<div class="tool-call-preview">
<span class="tool-call-preview-label">Output:</span>
<span class="tool-call-preview-text">{formatOutputPreview()}</span>
</div>
</Show>
<Show when={expanded()}>
<div class="tool-call-details">
<div class="tool-call-section">
<h4>Input:</h4>
<pre>
<code>{JSON.stringify(props.toolCall?.state?.input || {}, null, 2)}</code>
</pre>
</div>
<Show when={hasResult()}>
<div class="tool-call-section">
<h4>Output:</h4>
<pre>
<code>{formatToolOutput()}</code>
</pre>
</div>
</Show>
</div>
</Show>
</div>
)
}

470
src/index.css Normal file
View File

@@ -0,0 +1,470 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--user-message-bg: #f0f7ff;
--assistant-message-bg: #faf5ff;
--success-color: #4caf50;
--error-color: #f44336;
--warning-color: #ff9800;
--code-bg: #f8f8f8;
--hover-bg: #e0e0e0;
--border-color: #e0e0e0;
--text-muted: #666666;
--accent-color: #0066ff;
--secondary-bg: #f5f5f5;
--background: #ffffff;
--user-border: #2196f3;
--assistant-border: #9c27b0;
}
[data-theme="dark"] {
--user-message-bg: #1a2332;
--assistant-message-bg: #251a2e;
--code-bg: #1a1a1a;
--hover-bg: #3a3a3a;
--border-color: #3a3a3a;
--text-muted: #999999;
--accent-color: #0080ff;
--secondary-bg: #2a2a2a;
--background: #1a1a1a;
--user-border: #42a5f5;
--assistant-border: #ba68c8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100vw;
height: 100vh;
}
.message-stream-container {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.message-stream {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border-radius: 8px;
width: 100%;
}
.message-item.user {
background-color: var(--user-message-bg);
border-left: 4px solid var(--user-border);
}
.message-item.assistant {
background-color: var(--assistant-message-bg);
border-left: 4px solid var(--assistant-border);
}
.tool-call-message {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border-radius: 8px;
width: 100%;
background-color: #f8f9fa;
border-left: 4px solid #6c757d;
}
[data-theme="dark"] .tool-call-message {
background-color: #212529;
border-left-color: #adb5bd;
}
.tool-call-header-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: #495057;
margin-bottom: 4px;
}
[data-theme="dark"] .tool-call-header-label {
color: #adb5bd;
}
.tool-call-header-label .tool-call-icon {
font-size: 16px;
}
.tool-call-header-label .tool-name {
font-family: monospace;
color: #212529;
background-color: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
[data-theme="dark"] .tool-call-header-label .tool-name {
color: #f8f9fa;
background-color: #343a40;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.message-sender {
font-weight: 600;
font-size: 14px;
}
.message-timestamp {
font-size: 12px;
color: var(--text-muted);
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.message-text {
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.message-text pre {
overflow-x: auto;
padding: 8px;
background-color: var(--code-bg);
border-radius: 4px;
}
.message-error {
color: var(--error-color);
font-size: 13px;
margin-top: 4px;
}
.message-error-part {
color: var(--error-color);
font-size: 14px;
padding: 8px;
background-color: rgba(244, 67, 54, 0.1);
border-radius: 4px;
}
.message-error-block {
color: var(--error-color);
font-size: 14px;
padding: 12px;
background-color: rgba(244, 67, 54, 0.1);
border-radius: 4px;
border-left: 3px solid var(--error-color);
margin: 8px 0;
}
.message-generating {
color: var(--text-muted);
font-size: 14px;
font-style: italic;
padding: 8px 0;
}
.generating-spinner {
display: inline-block;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.message-reasoning {
margin: 8px 0;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--secondary-bg);
}
.message-reasoning details {
padding: 8px 12px;
}
.message-reasoning summary {
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.message-reasoning summary:hover {
color: var(--accent-color);
}
.tool-call {
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
}
.tool-call-message .tool-call {
border: none;
border-radius: 0;
margin: 0;
}
.tool-call-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
width: 100%;
background-color: var(--secondary-bg);
border: none;
cursor: pointer;
font-family: monospace;
font-size: 13px;
text-align: left;
}
.tool-call-header:hover {
background-color: var(--hover-bg);
}
.tool-call-icon {
font-size: 10px;
}
.tool-call-summary {
flex: 1;
text-align: left;
}
.tool-call-status {
font-size: 14px;
}
.tool-call-status-success {
border-left: 3px solid var(--success-color);
}
.tool-call-status-error {
border-left: 3px solid var(--error-color);
}
.tool-call-status-running,
.tool-call-status-pending {
border-left: 3px solid var(--warning-color);
}
.tool-call-preview {
padding: 8px 12px;
background-color: var(--code-bg);
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-call-preview-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tool-call-preview-text {
font-family: monospace;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
max-height: calc(10 * 1.4em);
overflow-y: auto;
}
.tool-call-details {
padding: 12px;
background-color: var(--code-bg);
display: flex;
flex-direction: column;
gap: 12px;
}
.tool-call-section h4 {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-muted);
}
.tool-call-section pre {
margin: 0;
padding: 8px;
background-color: var(--background);
border-radius: 4px;
overflow-x: auto;
max-height: calc(25 * 1.4em);
overflow-y: auto;
}
.tool-call-section code {
font-family: monospace;
font-size: 12px;
line-height: 1.4;
}
.tool-call-section pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.tool-call-section pre::-webkit-scrollbar-track {
background: var(--secondary-bg);
border-radius: 4px;
}
.tool-call-section pre::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.scroll-to-bottom {
position: absolute;
bottom: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--accent-color);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 150ms ease;
}
.scroll-to-bottom:hover {
transform: scale(1.1);
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
}
.empty-state-content {
text-align: center;
max-width: 400px;
}
.empty-state-content h3 {
font-size: 18px;
margin-bottom: 12px;
}
.empty-state-content p {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 16px;
}
.empty-state-content ul {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-state-content li {
font-size: 14px;
color: var(--text-muted);
}
.empty-state-content code {
background-color: var(--code-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 13px;
}
.loading-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 48px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

43
src/lib/keyboard.ts Normal file
View File

@@ -0,0 +1,43 @@
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
import { activeSessionId, setActiveSession, getSessions } from "../stores/sessions"
export function setupTabKeyboardShortcuts(
handleNewInstance: () => void,
handleNewSession: (instanceId: string) => void,
handleCloseSession: (instanceId: string, sessionId: string) => void,
) {
window.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const index = parseInt(e.key) - 1
const instanceIds = Array.from(instances().keys())
if (instanceIds[index]) {
setActiveInstanceId(instanceIds[index])
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
e.preventDefault()
handleNewInstance()
}
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
e.preventDefault()
const instanceId = activeInstanceId()
if (instanceId) {
handleNewSession(instanceId)
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const sessionId = activeSessionId().get(instanceId)
if (sessionId && sessionId !== "logs") {
handleCloseSession(instanceId, sessionId)
}
}
})
}

32
src/lib/sdk-manager.ts Normal file
View File

@@ -0,0 +1,32 @@
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
class SDKManager {
private clients = new Map<number, OpencodeClient>()
createClient(port: number): OpencodeClient {
if (this.clients.has(port)) {
return this.clients.get(port)!
}
const client = createOpencodeClient({
baseUrl: `http://localhost:${port}`,
})
this.clients.set(port, client)
return client
}
getClient(port: number): OpencodeClient | null {
return this.clients.get(port) || null
}
destroyClient(port: number): void {
this.clients.delete(port)
}
destroyAll(): void {
this.clients.clear()
}
}
export const sdkManager = new SDKManager()

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { render } from "solid-js/web"
import App from "./App"
import "./index.css"
const root = document.getElementById("root")
if (!root) {
throw new Error("Root element not found")
}
render(() => <App />, root)

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCode Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../main.tsx"></script>
</body>
</html>

119
src/stores/instances.ts Normal file
View File

@@ -0,0 +1,119 @@
import { createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { sdkManager } from "../lib/sdk-manager"
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
import { showSessionPicker } from "./ui"
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
function addInstance(instance: Instance) {
setInstances((prev) => {
const next = new Map(prev)
next.set(instance.id, instance)
return next
})
}
function updateInstance(id: string, updates: Partial<Instance>) {
setInstances((prev) => {
const next = new Map(prev)
const instance = next.get(id)
if (instance) {
next.set(id, { ...instance, ...updates })
}
return next
})
}
function removeInstance(id: string) {
setInstances((prev) => {
const next = new Map(prev)
next.delete(id)
return next
})
if (activeInstanceId() === id) {
setActiveInstanceId(null)
}
}
async function createInstance(folder: string): Promise<string> {
const tempId = `temp-${Date.now()}`
const instance: Instance = {
id: tempId,
folder,
port: 0,
pid: 0,
status: "starting",
client: null,
}
addInstance(instance)
try {
const { port, pid } = await window.electronAPI.createInstance(folder)
const client = sdkManager.createClient(port)
updateInstance(tempId, {
port,
pid,
client,
status: "ready",
})
setActiveInstanceId(tempId)
try {
await fetchSessions(tempId)
await fetchAgents(tempId)
await fetchProviders(tempId)
} catch (error) {
console.error("Failed to fetch initial data:", error)
}
showSessionPicker(tempId)
return tempId
} catch (error) {
updateInstance(tempId, {
status: "error",
error: error instanceof Error ? error.message : String(error),
})
throw error
}
}
async function stopInstance(id: string) {
const instance = instances().get(id)
if (!instance) return
if (instance.port) {
sdkManager.destroyClient(instance.port)
}
if (instance.pid) {
await window.electronAPI.stopInstance(instance.pid)
}
removeInstance(id)
}
function getActiveInstance(): Instance | null {
const id = activeInstanceId()
return id ? instances().get(id) || null : null
}
export {
instances,
activeInstanceId,
setActiveInstanceId,
addInstance,
updateInstance,
removeInstance,
createInstance,
stopInstance,
getActiveInstance,
}

419
src/stores/sessions.ts Normal file
View File

@@ -0,0 +1,419 @@
import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import type { Message } from "../types/message"
import { instances } from "./instances"
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
const [agents, setAgents] = createSignal<Map<string, Agent[]>>(new Map())
const [providers, setProviders] = createSignal<Map<string, Provider[]>>(new Map())
const [loading, setLoading] = createSignal({
fetchingSessions: new Map<string, boolean>(),
creatingSession: new Map<string, boolean>(),
deletingSession: new Map<string, Set<string>>(),
loadingMessages: new Map<string, Set<string>>(),
})
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
async function fetchSessions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, true)
return next
})
try {
const response = await instance.client.session.list()
const sessionMap = new Map<string, Session>()
if (!response.data || !Array.isArray(response.data)) {
return
}
for (const apiSession of response.data) {
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentID || null,
agent: "",
model: { providerId: "", modelId: "" },
time: {
created: apiSession.time.created,
updated: apiSession.time.updated,
},
messages: [],
messagesInfo: new Map(),
})
}
setSessions((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionMap)
return next
})
} catch (error) {
console.error("Failed to fetch sessions:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, false)
return next
})
}
}
async function createSession(instanceId: string, agent?: string): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, true)
return next
})
try {
const response = await instance.client.session.create()
if (!response.data) {
throw new Error("Failed to create session: No data returned")
}
const session: Session = {
id: response.data.id,
instanceId,
title: response.data.title || "New Session",
parentId: null,
agent: agent || "",
model: { providerId: "", modelId: "" },
time: {
created: response.data.time.created,
updated: response.data.time.updated,
},
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId) || new Map()
instanceSessions.set(session.id, session)
next.set(instanceId, instanceSessions)
return next
})
return session
} catch (error) {
console.error("Failed to create session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
}
}
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId) || new Set()
deleting.add(sessionId)
next.deletingSession.set(instanceId, deleting)
return next
})
try {
await instance.client.session.delete({ path: { id: sessionId } })
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
instanceSessions.delete(sessionId)
}
return next
})
if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
} catch (error) {
console.error("Failed to delete session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const deleting = next.deletingSession.get(instanceId)
if (deleting) {
deleting.delete(sessionId)
}
return next
})
}
}
async function fetchAgents(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.app.agents()
const agentList = (response.data ?? [])
.filter((agent) => agent.mode !== "subagent")
.map((agent) => ({
name: agent.name,
description: agent.description || "",
mode: agent.mode,
}))
setAgents((prev) => {
const next = new Map(prev)
next.set(instanceId, agentList)
return next
})
} catch (error) {
console.error("Failed to fetch agents:", error)
}
}
async function fetchProviders(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
try {
const response = await instance.client.config.providers()
if (!response.data) return
const providerList = response.data.providers.map((provider) => ({
id: provider.id,
name: provider.name,
models: Object.entries(provider.models).map(([id, model]) => ({
id,
name: model.name,
providerId: provider.id,
})),
}))
setProviders((prev) => {
const next = new Map(prev)
next.set(instanceId, providerList)
return next
})
} catch (error) {
console.error("Failed to fetch providers:", error)
}
}
function setActiveSession(instanceId: string, sessionId: string): void {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionId)
return next
})
}
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
setActiveParentSessionId((prev) => {
const next = new Map(prev)
next.set(instanceId, parentSessionId)
return next
})
setActiveSession(instanceId, parentSessionId)
}
function clearActiveParentSession(instanceId: string): void {
setActiveParentSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
function getActiveParentSession(instanceId: string): Session | null {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return null
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(parentId) || null
}
function getActiveSession(instanceId: string): Session | null {
const sessionId = activeSessionId().get(instanceId)
if (!sessionId) return null
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(sessionId) || null
}
function getSessions(instanceId: string): Session[] {
const instanceSessions = sessions().get(instanceId)
return instanceSessions ? Array.from(instanceSessions.values()) : []
}
function getParentSessions(instanceId: string): Session[] {
const allSessions = getSessions(instanceId)
return allSessions.filter((s) => s.parentId === null)
}
function getChildSessions(instanceId: string, parentId: string): Session[] {
const allSessions = getSessions(instanceId)
return allSessions.filter((s) => s.parentId === parentId)
}
function getSessionFamily(instanceId: string, parentId: string): Session[] {
const parent = sessions().get(instanceId)?.get(parentId)
if (!parent) return []
const children = getChildSessions(instanceId, parentId)
return [parent, ...children]
}
async function loadMessages(instanceId: string, sessionId: string): Promise<void> {
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
if (alreadyLoaded) {
return
}
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
if (isLoading) {
return
}
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
if (!session) {
throw new Error("Session not found")
}
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
loadingSet.add(sessionId)
next.loadingMessages.set(instanceId, loadingSet)
return next
})
try {
const response = await instance.client.session.messages({ path: { id: sessionId } })
if (!response.data || !Array.isArray(response.data)) {
return
}
const messagesInfo = new Map<string, any>()
const messages: Message[] = response.data.map((apiMessage: any) => {
const info = apiMessage.info || apiMessage
const role = info.role || "assistant"
const messageId = info.id || String(Date.now())
messagesInfo.set(messageId, info)
return {
id: messageId,
sessionId,
type: role === "user" ? "user" : "assistant",
parts: apiMessage.parts || [],
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
}
})
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = next.get(instanceId)
if (instanceSessions) {
const session = instanceSessions.get(sessionId)
if (session) {
const updatedInstanceSessions = new Map(instanceSessions)
updatedInstanceSessions.set(sessionId, { ...session, messages, messagesInfo })
next.set(instanceId, updatedInstanceSessions)
}
}
return next
})
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
} catch (error) {
console.error("Failed to load messages:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
const loadingSet = next.loadingMessages.get(instanceId)
if (loadingSet) {
loadingSet.delete(sessionId)
}
return next
})
}
}
export {
sessions,
activeSessionId,
activeParentSessionId,
agents,
providers,
loading,
fetchSessions,
createSession,
deleteSession,
fetchAgents,
fetchProviders,
loadMessages,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
getActiveSession,
getActiveParentSession,
getSessions,
getParentSessions,
getChildSessions,
getSessionFamily,
}

47
src/stores/ui.ts Normal file
View File

@@ -0,0 +1,47 @@
import { createSignal } from "solid-js"
const [hasInstances, setHasInstances] = createSignal(false)
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
const [sessionPickerInstance, setSessionPickerInstance] = createSignal<string | null>(null)
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
function showSessionPicker(instanceId: string) {
setSessionPickerInstance(instanceId)
}
function hideSessionPicker() {
setSessionPickerInstance(null)
}
function reorderInstanceTabs(newOrder: string[]) {
setInstanceTabOrder(newOrder)
}
function reorderSessionTabs(instanceId: string, newOrder: string[]) {
setSessionTabOrder((prev) => {
const next = new Map(prev)
next.set(instanceId, newOrder)
return next
})
}
export {
hasInstances,
setHasInstances,
selectedFolder,
setSelectedFolder,
isSelectingFolder,
setIsSelectingFolder,
sessionPickerInstance,
showSessionPicker,
hideSessionPicker,
instanceTabOrder,
setInstanceTabOrder,
sessionTabOrder,
setSessionTabOrder,
reorderInstanceTabs,
reorderSessionTabs,
}

9
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { ElectronAPI } from "../../electron/preload/index"
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}

17
src/types/instance.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { OpencodeClient } from "@opencode-ai/sdk/client"
export interface Instance {
id: string
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
client: OpencodeClient | null
}
export interface LogEntry {
timestamp: number
level: "info" | "error"
message: string
}

8
src/types/message.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface Message {
id: string
sessionId: string
type: "user" | "assistant"
parts: any[]
timestamp: number
status: "sending" | "sent" | "streaming" | "complete" | "error"
}

37
src/types/session.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Message } from "./message"
export interface Session {
id: string
instanceId: string
title: string
parentId: string | null
agent: string
model: {
providerId: string
modelId: string
}
time: {
created: number
updated: number
}
messages: Message[]
messagesInfo: Map<string, any>
}
export interface Agent {
name: string
description: string
mode: string
}
export interface Provider {
id: string
name: string
models: Model[]
}
export interface Model {
id: string
name: string
providerId: string
}