Working messages display
This commit is contained in:
49
src/components/empty-state.tsx
Normal file
49
src/components/empty-state.tsx
Normal 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
|
||||
61
src/components/instance-tab.tsx
Normal file
61
src/components/instance-tab.tsx
Normal 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
|
||||
41
src/components/instance-tabs.tsx
Normal file
41
src/components/instance-tabs.tsx
Normal 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
|
||||
70
src/components/message-item.tsx
Normal file
70
src/components/message-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/message-part.tsx
Normal file
37
src/components/message-part.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
src/components/message-stream.tsx
Normal file
138
src/components/message-stream.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
src/components/session-picker.tsx
Normal file
149
src/components/session-picker.tsx
Normal 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
|
||||
56
src/components/session-tab.tsx
Normal file
56
src/components/session-tab.tsx
Normal 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
|
||||
46
src/components/session-tabs.tsx
Normal file
46
src/components/session-tabs.tsx
Normal 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
|
||||
139
src/components/tool-call.tsx
Normal file
139
src/components/tool-call.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user