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

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>
)
}