Implement comprehensive tool call rendering with state persistence
- Implement tool-specific rendering for all 14 tool types (read, edit, write, bash, webfetch, todowrite, task, etc.) - Each tool shows contextually relevant information (file previews, diffs, command output, todo lists) - Add metadata-driven content display using preview, diff, output, and todos from tool state - Implement status-based rendering (pending, running, completed, error) with animations - Create global state store for expandable items (tool calls and reasoning sections) - Fix state persistence: expanded tool calls and reasoning sections remain expanded when new messages arrive - Fix scroll position preservation during live message updates - Fix reasoning toggle loop by replacing native details element with custom expandable - Add comprehensive documentation in TOOL_CALL_IMPLEMENTATION.md - Reduce font sizes for better readability in expanded tool content - Add proper keying to For loops to prevent component recreation - Match TUI patterns for tool names, actions, and content formatting
This commit is contained in:
228
TOOL_CALL_IMPLEMENTATION.md
Normal file
228
TOOL_CALL_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Tool Call Rendering Implementation
|
||||
|
||||
This document describes how tool calls are rendered in the OpenCode Client, following the patterns established in the TUI.
|
||||
|
||||
## Overview
|
||||
|
||||
Each tool type has specialized rendering logic that displays the most relevant information for that tool. This matches the TUI's approach of providing context-specific displays rather than generic input/output dumps.
|
||||
|
||||
## Tool-Specific Rendering
|
||||
|
||||
### 1. **read** - File Reading
|
||||
|
||||
- **Title**: `Read {filename}`
|
||||
- **Body**: Preview of file content (first 6 lines) from `metadata.preview`
|
||||
- **Use case**: Shows what file content the assistant is reading
|
||||
|
||||
### 2. **edit** - File Editing
|
||||
|
||||
- **Title**: `Edit {filename}`
|
||||
- **Body**: Diff/patch showing changes from `metadata.diff`
|
||||
- **Special**: Shows diagnostics if available in metadata
|
||||
- **Use case**: Shows what changes are being made to files
|
||||
|
||||
### 3. **write** - File Writing
|
||||
|
||||
- **Title**: `Write {filename}`
|
||||
- **Body**: File content being written (first 10 lines)
|
||||
- **Special**: Shows diagnostics if available in metadata
|
||||
- **Use case**: Shows new file content being created
|
||||
|
||||
### 4. **bash** - Shell Commands
|
||||
|
||||
- **Title**: `Shell {description}` (or command if no description)
|
||||
- **Body**: Console-style display with `$ command` and output
|
||||
|
||||
```
|
||||
$ npm install vitest
|
||||
added 50 packages...
|
||||
```
|
||||
|
||||
- **Output from**: `metadata.output`
|
||||
- **Use case**: Shows command execution and results
|
||||
|
||||
### 5. **webfetch** - Web Fetching
|
||||
|
||||
- **Title**: `Fetch {url}`
|
||||
- **Body**: Fetched content (first 10 lines)
|
||||
- **Use case**: Shows web content being retrieved
|
||||
|
||||
### 6. **todowrite** - Task Planning
|
||||
|
||||
- **Title**: Dynamic based on todo phase:
|
||||
- All pending: "Creating plan"
|
||||
- All completed: "Completing plan"
|
||||
- Mixed: "Updating plan"
|
||||
- **Body**: Formatted todo list:
|
||||
- `- [x] Completed task`
|
||||
- `- [ ] Pending task`
|
||||
- `- [ ] ~~Cancelled task~~`
|
||||
- `- [ ] In progress task` (highlighted)
|
||||
- **Use case**: Shows the AI's task planning
|
||||
|
||||
### 7. **task** - Delegated Tasks
|
||||
|
||||
- **Title**: `Task[subagent_type] {description}`
|
||||
- **Body**: List of delegated tool calls with icons:
|
||||
|
||||
```
|
||||
⚡ bash: npm install
|
||||
📖 read package.json
|
||||
✏️ edit src/app.ts
|
||||
```
|
||||
|
||||
- **Special**: In TUI, includes navigation hints for session tree
|
||||
- **Use case**: Shows what the delegated agent is doing
|
||||
|
||||
### 8. **todoread** - Plan Reading
|
||||
|
||||
- **Special**: Hidden in TUI, returns empty string
|
||||
- **Use case**: Internal tool, not displayed to user
|
||||
|
||||
### 9. **glob** - File Pattern Matching
|
||||
|
||||
- **Title**: `Glob {pattern}`
|
||||
- **Use case**: Shows file search patterns
|
||||
|
||||
### 10. **grep** - Content Search
|
||||
|
||||
- **Title**: `Grep "{pattern}"`
|
||||
- **Use case**: Shows what content is being searched
|
||||
|
||||
### 11. **list** - Directory Listing
|
||||
|
||||
- **Title**: `List`
|
||||
- **Use case**: Shows directory operations
|
||||
|
||||
### 12. **patch** - Patching Files
|
||||
|
||||
- **Title**: `Patch`
|
||||
- **Use case**: Shows patch operations
|
||||
|
||||
### 13. **invalid** - Invalid Tool Calls
|
||||
|
||||
- **Title**: Name of the actual tool attempted
|
||||
- **Use case**: Shows validation errors
|
||||
|
||||
### 14. **Default** - Unknown Tools
|
||||
|
||||
- **Title**: Capitalized tool name
|
||||
- **Body**: Output truncated to 10 lines
|
||||
- **Use case**: Fallback for any new or custom tools
|
||||
|
||||
## Status States
|
||||
|
||||
### Pending
|
||||
|
||||
- **Icon**: ⏸ (pause symbol)
|
||||
- **Title**: Action text (e.g., "Writing command...", "Preparing edit...")
|
||||
- **Border**: Accent color
|
||||
- **Animation**: Shimmer effect on title
|
||||
- **Expandable**: Shows "Waiting for permission..." message
|
||||
|
||||
### Running
|
||||
|
||||
- **Icon**: ⏳ (hourglass)
|
||||
- **Title**: Same as completed state
|
||||
- **Border**: Warning color (yellow/orange)
|
||||
- **Animation**: Pulse on status icon
|
||||
|
||||
### Completed
|
||||
|
||||
- **Icon**: ✓ (checkmark)
|
||||
- **Title**: Tool-specific title with arguments
|
||||
- **Border**: Success color (green)
|
||||
- **Body**: Tool-specific rendered content
|
||||
|
||||
### Error
|
||||
|
||||
- **Icon**: ✗ (X mark)
|
||||
- **Title**: Same format but in error color
|
||||
- **Border**: Error color (red)
|
||||
- **Body**: Error message in highlighted box
|
||||
|
||||
## Title Rendering Logic
|
||||
|
||||
The title follows this pattern:
|
||||
|
||||
1. **Pending state**: Show action text
|
||||
|
||||
```
|
||||
"Writing command..."
|
||||
"Preparing edit..."
|
||||
"Delegating..."
|
||||
```
|
||||
|
||||
2. **Completed/Running/Error**: Show specific info
|
||||
|
||||
```
|
||||
"Shell npm install"
|
||||
"Edit src/app.ts"
|
||||
"Read package.json"
|
||||
"Task[general] Search for files"
|
||||
```
|
||||
|
||||
3. **Special cases**:
|
||||
- `todowrite`: Shows plan phase
|
||||
- `todoread`: Just "Plan"
|
||||
- `bash`: Uses description if available, otherwise shows command
|
||||
|
||||
## Metadata Usage
|
||||
|
||||
Tool calls use `metadata` for rich content:
|
||||
|
||||
- **read**: `metadata.preview` - file preview content
|
||||
- **edit**: `metadata.diff` - patch/diff text
|
||||
- **bash**: `metadata.output` - command output
|
||||
- **todowrite**: `metadata.todos[]` - todo items with status
|
||||
- **task**: `metadata.summary[]` - delegated tool calls
|
||||
- **edit/write**: `metadata.diagnostics` - LSP diagnostics
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Context-specific**: Each tool shows the most relevant information
|
||||
2. **Progressive disclosure**: Collapsed by default, expand for details
|
||||
3. **Visual hierarchy**: Icons, colors, and borders indicate status
|
||||
4. **Truncation**: Long content is truncated (6-10 lines) to prevent overwhelming
|
||||
5. **Consistency**: All tools follow same header/body/error structure
|
||||
|
||||
## Component Structure
|
||||
|
||||
```tsx
|
||||
<div class="tool-call tool-call-status-{status}">
|
||||
<button class="tool-call-header" onClick={toggle}>
|
||||
<span class="tool-call-icon">▶/▼</span>
|
||||
<span class="tool-call-emoji">{icon}</span>
|
||||
<span class="tool-call-summary">{title}</span>
|
||||
<span class="tool-call-status">{statusIcon}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div class="tool-call-details">
|
||||
{/* Tool-specific body content */}
|
||||
{error && <div class="tool-call-error-content">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS Classes
|
||||
|
||||
- `.tool-call` - Base container
|
||||
- `.tool-call-status-{pending|running|completed|error}` - Status-specific styling
|
||||
- `.tool-call-header` - Clickable header with expand/collapse
|
||||
- `.tool-call-emoji` - Tool type icon
|
||||
- `.tool-call-summary` - Tool title/description
|
||||
- `.tool-call-details` - Expanded content area
|
||||
- `.tool-call-content` - Code/output content (monospace)
|
||||
- `.tool-call-todos` - Todo list container
|
||||
- `.tool-call-task-summary` - Delegated task list
|
||||
- `.tool-call-error-content` - Error message display
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Syntax highlighting**: Use Shiki for code blocks in bash, read, write
|
||||
2. **Diff rendering**: Better diff visualization for edit tool
|
||||
3. **Copy buttons**: Quick copy for code/output
|
||||
4. **File links**: Click filename to open in editor
|
||||
5. **Diagnostics display**: Show LSP errors/warnings inline
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -5,6 +5,7 @@ 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 PromptInput from "./components/prompt-input"
|
||||
import {
|
||||
hasInstances,
|
||||
isSelectingFolder,
|
||||
@@ -35,10 +36,11 @@ import {
|
||||
activeParentSessionId,
|
||||
getParentSessions,
|
||||
loadMessages,
|
||||
sendMessage,
|
||||
} from "./stores/sessions"
|
||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||
|
||||
const SessionMessages: Component<{
|
||||
const SessionView: Component<{
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
instanceId: string
|
||||
@@ -52,6 +54,10 @@ const SessionMessages: Component<{
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSendMessage(prompt: string) {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={session()}
|
||||
@@ -61,7 +67,17 @@ const SessionMessages: Component<{
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(s) => <MessageStream sessionId={s().id} messages={s().messages || []} messagesInfo={s().messagesInfo} />}
|
||||
{(s) => (
|
||||
<div class="session-view">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
messages={s().messages || []}
|
||||
messagesInfo={s().messagesInfo}
|
||||
/>
|
||||
<PromptInput instanceId={props.instanceId} sessionId={s().id} onSend={handleSendMessage} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -213,7 +229,7 @@ const App: Component = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionMessages
|
||||
<SessionView
|
||||
sessionId={activeSessionIdForInstance()!}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={activeInstance()!.id}
|
||||
|
||||
@@ -59,9 +59,17 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.message.parts}>{(part) => <MessagePart part={part} />}</For>
|
||||
<For each={props.message.parts}>
|
||||
{(part) => <MessagePart part={part} key={part.id || `${part.type}-${Math.random()}`} />}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">⚠ Message failed to send</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, Match, Switch } from "solid-js"
|
||||
import ToolCall from "./tool-call"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
|
||||
interface MessagePartProps {
|
||||
part: any
|
||||
@@ -7,6 +8,13 @@ interface MessagePartProps {
|
||||
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
const partType = () => props.part?.type || ""
|
||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||
|
||||
function handleReasoningClick(e: Event) {
|
||||
e.preventDefault()
|
||||
toggleItemExpanded(reasoningId())
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
@@ -17,7 +25,7 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
</Match>
|
||||
|
||||
<Match when={partType() === "tool"}>
|
||||
<ToolCall toolCall={props.part} />
|
||||
<ToolCall toolCall={props.part} toolCallId={props.part?.id} />
|
||||
</Match>
|
||||
|
||||
<Match when={partType() === "error"}>
|
||||
@@ -26,10 +34,15 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
|
||||
<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 class="reasoning-container">
|
||||
<div class="reasoning-header" onClick={handleReasoningClick}>
|
||||
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
|
||||
<span class="reasoning-label">Reasoning</span>
|
||||
</div>
|
||||
<Show when={isReasoningExpanded()}>
|
||||
<div class="message-text mt-2">{props.part.text || ""}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -2,8 +2,10 @@ 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"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
interface MessageStreamProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
messages: Message[]
|
||||
messagesInfo?: Map<string, any>
|
||||
@@ -21,6 +23,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollButton, setShowScrollButton] = createSignal(false)
|
||||
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
|
||||
function scrollToBottom() {
|
||||
if (containerRef) {
|
||||
containerRef.scrollTop = containerRef.scrollHeight
|
||||
@@ -81,6 +85,26 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
|
||||
return (
|
||||
<div class="message-stream-container">
|
||||
<div class="connection-status">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
<span class="status-dot" />
|
||||
Connected
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "connecting"}>
|
||||
<span class="status-indicator connecting">
|
||||
<span class="status-dot" />
|
||||
Connecting...
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
|
||||
<span class="status-indicator disconnected">
|
||||
<span class="status-dot" />
|
||||
Disconnected
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
|
||||
<Show when={!props.loading && displayItems().length === 0}>
|
||||
<div class="empty-state">
|
||||
@@ -107,24 +131,27 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
</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>
|
||||
<For each={displayItems()} fallback={null}>
|
||||
{(item, index) => {
|
||||
const key = item.type === "message" ? `msg-${item.data.id}` : `tool-${item.data.id}`
|
||||
return (
|
||||
<Show
|
||||
when={item.type === "message"}
|
||||
fallback={
|
||||
<div class="tool-call-message" data-key={key}>
|
||||
<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} toolCallId={item.data.id} />
|
||||
</div>
|
||||
<ToolCall toolCall={item.data} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MessageItem message={item.data} messageInfo={item.messageInfo} />
|
||||
</Show>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<MessageItem message={item.data} messageInfo={item.messageInfo} />
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
|
||||
79
src/components/prompt-input.tsx
Normal file
79
src/components/prompt-input.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
|
||||
interface PromptInputProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [sending, setSending] = createSignal(false)
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = prompt().trim()
|
||||
if (!text || sending() || props.disabled) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await props.onSend(text)
|
||||
setPrompt("")
|
||||
|
||||
if (textareaRef) {
|
||||
textareaRef.style.height = "auto"
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setSending(false)
|
||||
textareaRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
setPrompt(target.value)
|
||||
|
||||
target.style.height = "auto"
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
||||
}
|
||||
|
||||
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
|
||||
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div class="prompt-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input"
|
||||
placeholder="Type your message or /command..."
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={sending() || props.disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
|
||||
<Show when={sending()} fallback={<span class="send-icon">▶</span>}>
|
||||
<span class="spinner-small" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-input-hints">
|
||||
<span class="hint">
|
||||
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,73 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createSignal, Show, For, createEffect } from "solid-js"
|
||||
import { isToolCallExpanded, toggleToolCallExpanded } from "../stores/tool-call-state"
|
||||
|
||||
interface ToolCallProps {
|
||||
toolCall: any
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
function getToolIcon(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "⚡"
|
||||
case "edit":
|
||||
return "✏️"
|
||||
case "read":
|
||||
return "📖"
|
||||
case "write":
|
||||
return "📝"
|
||||
case "glob":
|
||||
return "🔍"
|
||||
case "grep":
|
||||
return "🔎"
|
||||
case "webfetch":
|
||||
return "🌐"
|
||||
case "task":
|
||||
return "🎯"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "📋"
|
||||
case "list":
|
||||
return "📁"
|
||||
case "patch":
|
||||
return "🔧"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
}
|
||||
|
||||
function getToolName(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "Shell"
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
case "invalid":
|
||||
return "Invalid"
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
default:
|
||||
const normalized = tool.replace(/^opencode_/, "")
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
function getRelativePath(path: string): string {
|
||||
if (!path) return ""
|
||||
const parts = path.split("/")
|
||||
return parts.slice(-1)[0] || path
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||
const expanded = () => isToolCallExpanded(toolCallId())
|
||||
|
||||
const statusIcon = () => {
|
||||
const status = props.toolCall?.state?.status || ""
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "⏳"
|
||||
return "⏸"
|
||||
case "running":
|
||||
return "⏳"
|
||||
case "completed":
|
||||
@@ -28,108 +84,393 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return `tool-call-status-${status}`
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded())
|
||||
function toggle() {
|
||||
toggleToolCallExpanded(toolCallId())
|
||||
}
|
||||
|
||||
function formatToolSummary() {
|
||||
const renderToolAction = () => {
|
||||
const toolName = props.toolCall?.tool || ""
|
||||
switch (toolName) {
|
||||
case "task":
|
||||
return "Delegating..."
|
||||
case "bash":
|
||||
return "Writing command..."
|
||||
case "edit":
|
||||
return "Preparing edit..."
|
||||
case "webfetch":
|
||||
return "Fetching from the web..."
|
||||
case "glob":
|
||||
return "Finding files..."
|
||||
case "grep":
|
||||
return "Searching content..."
|
||||
case "list":
|
||||
return "Listing directory..."
|
||||
case "read":
|
||||
return "Reading file..."
|
||||
case "write":
|
||||
return "Preparing write..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
default:
|
||||
return "Working..."
|
||||
}
|
||||
}
|
||||
|
||||
const getTodoTitle = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
if (state.status !== "completed") return "Plan"
|
||||
|
||||
const metadata = state.metadata || {}
|
||||
const todos = metadata.todos || []
|
||||
|
||||
if (!Array.isArray(todos) || todos.length === 0) return "Plan"
|
||||
|
||||
const counts = { pending: 0, completed: 0 }
|
||||
for (const todo of todos) {
|
||||
const status = todo.status || "pending"
|
||||
if (status in counts) counts[status as keyof typeof counts]++
|
||||
}
|
||||
|
||||
const total = todos.length
|
||||
if (counts.pending === total) return "Creating plan"
|
||||
if (counts.completed === total) return "Completing plan"
|
||||
return "Updating plan"
|
||||
}
|
||||
|
||||
const renderToolTitle = () => {
|
||||
const toolName = props.toolCall?.tool || ""
|
||||
const state = props.toolCall?.state || {}
|
||||
const input = state.input || {}
|
||||
|
||||
if (state.status === "pending") {
|
||||
return renderToolAction()
|
||||
}
|
||||
|
||||
if (state.title) {
|
||||
return state.title
|
||||
}
|
||||
|
||||
const name = getToolName(toolName)
|
||||
|
||||
switch (toolName) {
|
||||
case "bash":
|
||||
return `bash: ${input.command || ""}`
|
||||
case "edit":
|
||||
return `edit ${input.filePath || ""}`
|
||||
case "read":
|
||||
return `read ${input.filePath || ""}`
|
||||
if (input.filePath) {
|
||||
return `${name} ${getRelativePath(input.filePath)}`
|
||||
}
|
||||
return name
|
||||
|
||||
case "edit":
|
||||
case "write":
|
||||
return `write ${input.filePath || ""}`
|
||||
case "glob":
|
||||
return `glob ${input.pattern || ""}`
|
||||
case "grep":
|
||||
return `grep ${input.pattern || ""}`
|
||||
if (input.filePath) {
|
||||
return `${name} ${getRelativePath(input.filePath)}`
|
||||
}
|
||||
return name
|
||||
|
||||
case "bash":
|
||||
if (input.description) {
|
||||
return `${name} ${input.description}`
|
||||
}
|
||||
return name
|
||||
|
||||
case "task":
|
||||
const description = input.description
|
||||
const subagent = input.subagent_type
|
||||
if (description && subagent) {
|
||||
return `${name}[${subagent}] ${description}`
|
||||
} else if (description) {
|
||||
return `${name} ${description}`
|
||||
}
|
||||
return name
|
||||
|
||||
case "webfetch":
|
||||
if (input.url) {
|
||||
return `${name} ${input.url}`
|
||||
}
|
||||
return name
|
||||
|
||||
case "todowrite":
|
||||
return getTodoTitle()
|
||||
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
|
||||
case "invalid":
|
||||
if (input.tool) {
|
||||
return getToolName(input.tool)
|
||||
}
|
||||
return name
|
||||
|
||||
default:
|
||||
return toolName || "Unknown tool"
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
const renderToolBody = () => {
|
||||
const toolName = props.toolCall?.tool || ""
|
||||
const state = props.toolCall?.state || {}
|
||||
const input = state.input || {}
|
||||
const metadata = state.metadata || {}
|
||||
|
||||
if (toolName === "todoread") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.status === "pending") {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case "read":
|
||||
return renderReadTool()
|
||||
|
||||
case "edit":
|
||||
return renderEditTool()
|
||||
|
||||
case "write":
|
||||
return renderWriteTool()
|
||||
|
||||
case "bash":
|
||||
return renderBashTool()
|
||||
|
||||
case "webfetch":
|
||||
return renderWebfetchTool()
|
||||
|
||||
case "todowrite":
|
||||
return renderTodowriteTool()
|
||||
|
||||
case "task":
|
||||
return renderTaskTool()
|
||||
|
||||
default:
|
||||
return renderDefaultTool()
|
||||
}
|
||||
}
|
||||
|
||||
const renderReadTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const metadata = state.metadata || {}
|
||||
const input = state.input || {}
|
||||
const preview = metadata.preview
|
||||
|
||||
if (preview && input.filePath) {
|
||||
const lines = preview.split("\n")
|
||||
const truncated = lines.slice(0, 6).join("\n")
|
||||
return (
|
||||
<pre class="tool-call-content">
|
||||
<code>{truncated}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderEditTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const metadata = state.metadata || {}
|
||||
const diff = metadata.diff
|
||||
|
||||
if (diff) {
|
||||
return (
|
||||
<div class="tool-call-diff">
|
||||
<pre class="tool-call-content">
|
||||
<code>{diff}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderWriteTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const input = state.input || {}
|
||||
|
||||
if (input.content && input.filePath) {
|
||||
const lines = input.content.split("\n")
|
||||
const truncated = lines.slice(0, 10).join("\n")
|
||||
return (
|
||||
<pre class="tool-call-content">
|
||||
<code>{truncated}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderBashTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const input = state.input || {}
|
||||
const metadata = state.metadata || {}
|
||||
const output = metadata.output
|
||||
|
||||
if (input.command) {
|
||||
return (
|
||||
<div class="tool-call-bash">
|
||||
<pre class="tool-call-content">
|
||||
<code>
|
||||
$ {input.command}
|
||||
{output && "\n"}
|
||||
{output}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderWebfetchTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const output = state.output
|
||||
|
||||
if (output) {
|
||||
const lines = output.split("\n")
|
||||
const truncated = lines.slice(0, 10).join("\n")
|
||||
return (
|
||||
<pre class="tool-call-content">
|
||||
<code>{truncated}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTodowriteTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const metadata = state.metadata || {}
|
||||
const todos = metadata.todos || []
|
||||
|
||||
if (!Array.isArray(todos) || todos.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-todos">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const content = todo.content
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div class="tool-call-todo-item">
|
||||
{todo.status === "completed" && "- [x] "}
|
||||
{todo.status !== "completed" && "- [ ] "}
|
||||
{todo.status === "cancelled" && <s>{content}</s>}
|
||||
{todo.status === "in_progress" && <code>{content}</code>}
|
||||
{todo.status !== "cancelled" && todo.status !== "in_progress" && content}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTaskTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const metadata = state.metadata || {}
|
||||
const summary = metadata.summary || []
|
||||
|
||||
if (!Array.isArray(summary) || summary.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={summary}>
|
||||
{(item) => {
|
||||
const tool = item.tool || "unknown"
|
||||
const itemInput = item.state?.input || {}
|
||||
const icon = getToolIcon(tool)
|
||||
|
||||
let description = ""
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
description = itemInput.description || itemInput.command || ""
|
||||
break
|
||||
case "edit":
|
||||
case "read":
|
||||
case "write":
|
||||
description = `${tool} ${getRelativePath(itemInput.filePath || "")}`
|
||||
break
|
||||
default:
|
||||
description = tool
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-task-item">
|
||||
{icon} {description}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDefaultTool = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
const output = state.output
|
||||
|
||||
if (output) {
|
||||
const lines = output.split("\n")
|
||||
const truncated = lines.slice(0, 10).join("\n")
|
||||
return (
|
||||
<pre class="tool-call-content">
|
||||
<code>{truncated}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const renderError = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
if (state.status === "error" && state.error) {
|
||||
return (
|
||||
<div class="tool-call-error-content">
|
||||
<strong>Error:</strong> {state.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const toolName = () => props.toolCall?.tool || ""
|
||||
const status = () => props.toolCall?.state?.status || ""
|
||||
|
||||
return (
|
||||
<div class={`tool-call ${statusClass()}`}>
|
||||
<button class="tool-call-header" onClick={toggleExpanded} aria-expanded={expanded()}>
|
||||
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
|
||||
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
|
||||
<span class="tool-call-summary">{formatToolSummary()}</span>
|
||||
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
|
||||
<span class="tool-call-summary">{renderToolTitle()}</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>
|
||||
{renderToolBody()}
|
||||
{renderError()}
|
||||
|
||||
<Show when={hasResult()}>
|
||||
<div class="tool-call-section">
|
||||
<h4>Output:</h4>
|
||||
<pre>
|
||||
<code>{formatToolOutput()}</code>
|
||||
</pre>
|
||||
<Show when={status() === "pending"}>
|
||||
<div class="tool-call-pending-message">
|
||||
<span class="spinner-small"></span>
|
||||
<span>Waiting for permission...</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
297
src/index.css
297
src/index.css
@@ -58,6 +58,41 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--secondary-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-indicator.connected .status-dot {
|
||||
background-color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-indicator.connecting .status-dot {
|
||||
background-color: var(--warning-color);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected .status-dot {
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -226,21 +261,33 @@ body {
|
||||
background-color: var(--secondary-bg);
|
||||
}
|
||||
|
||||
.message-reasoning details {
|
||||
.reasoning-container {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.message-reasoning summary {
|
||||
.reasoning-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.message-reasoning summary:hover {
|
||||
.reasoning-header:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
font-size: 10px;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.reasoning-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
@@ -292,11 +339,32 @@ body {
|
||||
border-left: 3px solid var(--error-color);
|
||||
}
|
||||
|
||||
.tool-call-status-running,
|
||||
.tool-call-status-pending {
|
||||
.tool-call-status-running {
|
||||
border-left: 3px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.tool-call-status-running .tool-call-status {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-call-status-pending {
|
||||
border-left: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.tool-call-status-pending .tool-call-summary {
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-preview {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--code-bg);
|
||||
@@ -331,7 +399,8 @@ body {
|
||||
background-color: var(--code-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
@@ -376,6 +445,98 @@ body {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-pending-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tool-call-emoji {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tool-call-bash,
|
||||
.tool-call-diff {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-content code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.tool-call-todos {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-call-todo-item {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tool-call-todo-item code {
|
||||
background-color: rgba(0, 100, 255, 0.1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tool-call-task-summary {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tool-call-task-item {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tool-call-task-item::before {
|
||||
content: "∟ ";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-error-content {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
border-left: 3px solid var(--error-color);
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
color: var(--error-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-call-error-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
@@ -468,3 +629,127 @@ body {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
background-color: var(--background);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.prompt-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.prompt-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.prompt-input-hints {
|
||||
padding: 0 16px 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.session-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message-sending {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
144
src/lib/sse-manager.ts
Normal file
144
src/lib/sse-manager.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
interface SSEConnection {
|
||||
instanceId: string
|
||||
eventSource: EventSource
|
||||
reconnectAttempts: number
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
}
|
||||
|
||||
interface MessageUpdateEvent {
|
||||
type: "message_updated"
|
||||
sessionId: string
|
||||
messageId: string
|
||||
parts: any[]
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SessionUpdateEvent {
|
||||
type: "session_updated"
|
||||
session: any
|
||||
}
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<
|
||||
Map<string, "connecting" | "connected" | "disconnected" | "error">
|
||||
>(new Map())
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private maxReconnectAttempts = 5
|
||||
private baseReconnectDelay = 1000
|
||||
|
||||
connect(instanceId: string, port: number): void {
|
||||
if (this.connections.has(instanceId)) {
|
||||
this.disconnect(instanceId)
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}/event`
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
const connection: SSEConnection = {
|
||||
instanceId,
|
||||
eventSource,
|
||||
reconnectAttempts: 0,
|
||||
status: "connecting",
|
||||
}
|
||||
|
||||
this.connections.set(instanceId, connection)
|
||||
this.updateConnectionStatus(instanceId, "connecting")
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connection.status = "connected"
|
||||
connection.reconnectAttempts = 0
|
||||
this.updateConnectionStatus(instanceId, "connected")
|
||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleEvent(instanceId, data)
|
||||
} catch (error) {
|
||||
console.error("[SSE] Failed to parse event:", error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connection.status = "error"
|
||||
this.updateConnectionStatus(instanceId, "error")
|
||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
||||
this.handleReconnect(instanceId, port)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(instanceId: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (connection) {
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
console.log(`[SSE] Disconnected from instance ${instanceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(instanceId: string, event: any): void {
|
||||
console.log("[SSE] Received event:", event.type, event)
|
||||
|
||||
switch (event.type) {
|
||||
case "message.updated":
|
||||
case "message.part.updated":
|
||||
this.onMessageUpdate?.(instanceId, event)
|
||||
break
|
||||
case "session.updated":
|
||||
this.onSessionUpdate?.(instanceId, event)
|
||||
break
|
||||
case "session.idle":
|
||||
console.log("[SSE] Session idle")
|
||||
break
|
||||
default:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
}
|
||||
}
|
||||
|
||||
private handleReconnect(instanceId: string, port: number): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
|
||||
connection.status = "disconnected"
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts)
|
||||
connection.reconnectAttempts++
|
||||
|
||||
console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect(instanceId, port)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
|
||||
setConnectionStatus((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, status)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
||||
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
|
||||
|
||||
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
|
||||
return connectionStatus().get(instanceId) ?? null
|
||||
}
|
||||
|
||||
getStatuses() {
|
||||
return connectionStatus()
|
||||
}
|
||||
}
|
||||
|
||||
export const sseManager = new SSEManager()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { fetchSessions, fetchAgents, fetchProviders } from "./sessions"
|
||||
import { showSessionPicker } from "./ui"
|
||||
|
||||
@@ -66,6 +67,8 @@ async function createInstance(folder: string): Promise<string> {
|
||||
|
||||
setActiveInstanceId(tempId)
|
||||
|
||||
sseManager.connect(tempId, port)
|
||||
|
||||
try {
|
||||
await fetchSessions(tempId)
|
||||
await fetchAgents(tempId)
|
||||
@@ -90,6 +93,8 @@ async function stopInstance(id: string) {
|
||||
const instance = instances().get(id)
|
||||
if (!instance) return
|
||||
|
||||
sseManager.disconnect(id)
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSignal } from "solid-js"
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
import { instances } from "./instances"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
@@ -394,6 +395,209 @@ async function loadMessages(instanceId: string, sessionId: string): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: any): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties?.part
|
||||
if (!part) return
|
||||
|
||||
const session = instanceSessions.get(part.sessionID)
|
||||
if (!session) return
|
||||
|
||||
let message = session.messages.find((m) => m.id === part.messageID)
|
||||
|
||||
if (!message) {
|
||||
message = {
|
||||
id: part.messageID,
|
||||
sessionId: part.sessionID,
|
||||
type: "assistant",
|
||||
parts: [part],
|
||||
timestamp: Date.now(),
|
||||
status: "streaming",
|
||||
}
|
||||
session.messages.push(message)
|
||||
} else {
|
||||
const partIndex = message.parts.findIndex((p: any) => p.id === part.id)
|
||||
if (partIndex === -1) {
|
||||
message.parts.push(part)
|
||||
} else {
|
||||
message.parts[partIndex] = part
|
||||
}
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
instanceSessions.set(part.sessionID, { ...session })
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
} else if (event.type === "message.updated") {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const session = instanceSessions.get(info.sessionID)
|
||||
if (!session) return
|
||||
|
||||
let message = session.messages.find((m) => m.id === info.id)
|
||||
|
||||
if (!message) {
|
||||
message = {
|
||||
id: info.id,
|
||||
sessionId: info.sessionID,
|
||||
type: info.role === "user" ? "user" : "assistant",
|
||||
parts: [],
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete",
|
||||
}
|
||||
session.messages.push(message)
|
||||
} else {
|
||||
// Update existing message - replace temp message with real one
|
||||
message.id = info.id
|
||||
message.status = "complete"
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(info.sessionID)
|
||||
if (updatedSession) {
|
||||
const messagesInfo = new Map(updatedSession.messagesInfo)
|
||||
messagesInfo.set(info.id, info)
|
||||
instanceSessions.set(info.sessionID, { ...updatedSession, messagesInfo })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionUpdate(instanceId: string, event: any): void {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const existingSession = instanceSessions.get(info.id)
|
||||
|
||||
if (!existingSession) {
|
||||
const newSession: Session = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Untitled",
|
||||
parentId: info.parentID || null,
|
||||
agent: info.agent || "",
|
||||
model: {
|
||||
providerId: info.model?.providerID || "",
|
||||
modelId: info.model?.modelID || "",
|
||||
},
|
||||
time: {
|
||||
created: info.time?.created || Date.now(),
|
||||
updated: info.time?.updated || Date.now(),
|
||||
},
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
instanceSessions.set(newSession.id, newSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||
} else {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
title: info.title || existingSession.title,
|
||||
agent: info.agent || existingSession.agent,
|
||||
model: info.model
|
||||
? {
|
||||
providerId: info.model.providerID || existingSession.model.providerId,
|
||||
modelId: info.model.modelID || existingSession.model.modelId,
|
||||
}
|
||||
: existingSession.model,
|
||||
time: {
|
||||
...existingSession.time,
|
||||
updated: info.time?.updated || Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
instanceSessions.set(existingSession.id, updatedSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: string[] = [],
|
||||
): Promise<void> {
|
||||
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")
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
...(session.agent && { agent: session.agent }),
|
||||
...(session.model.providerId &&
|
||||
session.model.modelId && {
|
||||
model: {
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
console.log("[sendMessage] Sending prompt:", {
|
||||
sessionId,
|
||||
requestBody,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
console.log("[sendMessage] Response:", response)
|
||||
|
||||
if (response.error) {
|
||||
console.error("[sendMessage] Server returned error:", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[sendMessage] Failed to send prompt:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
sseManager.onMessageUpdate = handleMessageUpdate
|
||||
sseManager.onSessionUpdate = handleSessionUpdate
|
||||
|
||||
export {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
@@ -407,6 +611,7 @@ export {
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
loadMessages,
|
||||
sendMessage,
|
||||
setActiveSession,
|
||||
setActiveParentSession,
|
||||
clearActiveParentSession,
|
||||
|
||||
36
src/stores/tool-call-state.ts
Normal file
36
src/stores/tool-call-state.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [expandedItems, setExpandedItems] = createSignal<Set<string>>(new Set())
|
||||
|
||||
export function isItemExpanded(itemId: string): boolean {
|
||||
return expandedItems().has(itemId)
|
||||
}
|
||||
|
||||
export function toggleItemExpanded(itemId: string): void {
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId)
|
||||
} else {
|
||||
next.add(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function setItemExpanded(itemId: string, expanded: boolean): void {
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (expanded) {
|
||||
next.add(itemId)
|
||||
} else {
|
||||
next.delete(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Backward compatibility aliases
|
||||
export const isToolCallExpanded = isItemExpanded
|
||||
export const toggleToolCallExpanded = toggleItemExpanded
|
||||
export const setToolCallExpanded = setItemExpanded
|
||||
443
tasks/done/008-sse-integration.md
Normal file
443
tasks/done/008-sse-integration.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# Task 008: SSE Integration - Real-time Message Streaming
|
||||
|
||||
## Status: TODO
|
||||
|
||||
## Objective
|
||||
|
||||
Implement Server-Sent Events (SSE) integration to enable real-time message streaming from OpenCode servers. Each instance will maintain its own EventSource connection to receive live updates for sessions and messages.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 006 (Instance/Session tabs) complete
|
||||
- Task 007 (Message display) complete
|
||||
- SDK client configured per instance
|
||||
- Understanding of EventSource API
|
||||
|
||||
## Context
|
||||
|
||||
The OpenCode server emits events via SSE at the `/events` endpoint. These events include:
|
||||
|
||||
- Message updates (streaming content)
|
||||
- Session updates (new sessions, title changes)
|
||||
- Tool execution status updates
|
||||
- Server status changes
|
||||
|
||||
We need to:
|
||||
|
||||
1. Create an SSE manager to handle connections
|
||||
2. Connect one EventSource per instance
|
||||
3. Route events to the correct instance/session
|
||||
4. Update reactive state to trigger UI updates
|
||||
5. Implement reconnection logic for dropped connections
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create SSE Manager Module
|
||||
|
||||
Create `src/lib/sse-manager.ts`:
|
||||
|
||||
```typescript
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
interface SSEConnection {
|
||||
instanceId: string
|
||||
eventSource: EventSource
|
||||
reconnectAttempts: number
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
}
|
||||
|
||||
interface MessageUpdateEvent {
|
||||
type: "message_updated"
|
||||
sessionId: string
|
||||
messageId: string
|
||||
parts: any[]
|
||||
status: string
|
||||
}
|
||||
|
||||
interface SessionUpdateEvent {
|
||||
type: "session_updated"
|
||||
session: any
|
||||
}
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private maxReconnectAttempts = 5
|
||||
private baseReconnectDelay = 1000
|
||||
|
||||
connect(instanceId: string, port: number): void {
|
||||
if (this.connections.has(instanceId)) {
|
||||
this.disconnect(instanceId)
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}/events`
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
const connection: SSEConnection = {
|
||||
instanceId,
|
||||
eventSource,
|
||||
reconnectAttempts: 0,
|
||||
status: "connecting",
|
||||
}
|
||||
|
||||
this.connections.set(instanceId, connection)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connection.status = "connected"
|
||||
connection.reconnectAttempts = 0
|
||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleEvent(instanceId, data)
|
||||
} catch (error) {
|
||||
console.error("[SSE] Failed to parse event:", error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connection.status = "error"
|
||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
||||
this.handleReconnect(instanceId, port)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(instanceId: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (connection) {
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
console.log(`[SSE] Disconnected from instance ${instanceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(instanceId: string, event: any): void {
|
||||
switch (event.type) {
|
||||
case "message_updated":
|
||||
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
|
||||
break
|
||||
case "session_updated":
|
||||
this.onSessionUpdate?.(instanceId, event as SessionUpdateEvent)
|
||||
break
|
||||
default:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
}
|
||||
}
|
||||
|
||||
private handleReconnect(instanceId: string, port: number): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`[SSE] Max reconnection attempts reached for ${instanceId}`)
|
||||
connection.status = "disconnected"
|
||||
return
|
||||
}
|
||||
|
||||
const delay = this.baseReconnectDelay * Math.pow(2, connection.reconnectAttempts)
|
||||
connection.reconnectAttempts++
|
||||
|
||||
console.log(`[SSE] Reconnecting to ${instanceId} in ${delay}ms (attempt ${connection.reconnectAttempts})`)
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect(instanceId, port)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
||||
onSessionUpdate?: (instanceId: string, event: SessionUpdateEvent) => void
|
||||
|
||||
getStatus(instanceId: string): SSEConnection["status"] | null {
|
||||
return this.connections.get(instanceId)?.status ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const sseManager = new SSEManager()
|
||||
```
|
||||
|
||||
### Step 2: Integrate SSE Manager with Instance Store
|
||||
|
||||
Update `src/stores/instances.ts` to use SSE manager:
|
||||
|
||||
```typescript
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
// In createInstance function, after SDK client is created:
|
||||
async function createInstance(folder: string) {
|
||||
// ... existing code to spawn server and create SDK client ...
|
||||
|
||||
// Connect SSE
|
||||
sseManager.connect(instance.id, port)
|
||||
|
||||
// Set up event handlers
|
||||
sseManager.onMessageUpdate = (instanceId, event) => {
|
||||
handleMessageUpdate(instanceId, event)
|
||||
}
|
||||
|
||||
sseManager.onSessionUpdate = (instanceId, event) => {
|
||||
handleSessionUpdate(instanceId, event)
|
||||
}
|
||||
}
|
||||
|
||||
// In removeInstance function:
|
||||
async function removeInstance(id: string) {
|
||||
// Disconnect SSE before removing
|
||||
sseManager.disconnect(id)
|
||||
|
||||
// ... existing cleanup code ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Handle Message Update Events
|
||||
|
||||
Create message update handler in instance store:
|
||||
|
||||
```typescript
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
const session = instance.sessions.get(event.sessionId)
|
||||
if (!session) return
|
||||
|
||||
// Find or create message
|
||||
let message = session.messages.find((m) => m.id === event.messageId)
|
||||
|
||||
if (!message) {
|
||||
// New message - add it
|
||||
message = {
|
||||
id: event.messageId,
|
||||
sessionId: event.sessionId,
|
||||
type: "assistant", // Determine from event
|
||||
parts: event.parts,
|
||||
timestamp: Date.now(),
|
||||
status: event.status,
|
||||
}
|
||||
session.messages.push(message)
|
||||
} else {
|
||||
// Update existing message
|
||||
message.parts = event.parts
|
||||
message.status = event.status
|
||||
}
|
||||
|
||||
// Trigger reactivity - update the map reference
|
||||
instances.set(instanceId, { ...instance })
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Handle Session Update Events
|
||||
|
||||
Create session update handler:
|
||||
|
||||
```typescript
|
||||
function handleSessionUpdate(instanceId: string, event: SessionUpdateEvent) {
|
||||
const instance = instances.get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
const existingSession = instance.sessions.get(event.session.id)
|
||||
|
||||
if (!existingSession) {
|
||||
// New session - add it
|
||||
const newSession = {
|
||||
id: event.session.id,
|
||||
instanceId,
|
||||
title: event.session.title || "Untitled",
|
||||
parentId: event.session.parentId,
|
||||
agent: event.session.agent,
|
||||
model: event.session.model,
|
||||
messages: [],
|
||||
status: "idle",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
instance.sessions.set(event.session.id, newSession)
|
||||
|
||||
// Auto-create tab for child sessions
|
||||
if (event.session.parentId) {
|
||||
console.log(`[SSE] New child session created: ${event.session.id}`)
|
||||
// Optionally auto-switch to new session
|
||||
// instance.activeSessionId = event.session.id
|
||||
}
|
||||
} else {
|
||||
// Update existing session
|
||||
existingSession.title = event.session.title || existingSession.title
|
||||
existingSession.agent = event.session.agent || existingSession.agent
|
||||
existingSession.model = event.session.model || existingSession.model
|
||||
existingSession.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
instances.set(instanceId, { ...instance })
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add Connection Status Indicator
|
||||
|
||||
Update `src/components/message-stream.tsx` to show connection status:
|
||||
|
||||
```typescript
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
function MessageStream(props) {
|
||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full">
|
||||
{/* Connection status indicator */}
|
||||
<div class="flex items-center justify-end px-4 py-2 text-xs text-gray-500">
|
||||
{connectionStatus() === "connected" && (
|
||||
<span class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus() === "connecting" && (
|
||||
<span class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
Connecting...
|
||||
</span>
|
||||
)}
|
||||
{connectionStatus() === "error" && (
|
||||
<span class="flex items-center gap-1">
|
||||
<div class="w-2 h-2 bg-red-500 rounded-full" />
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing message list */}
|
||||
{/* ... */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Test SSE Connection
|
||||
|
||||
Create a test utility to verify SSE is working:
|
||||
|
||||
```typescript
|
||||
// In browser console or test file:
|
||||
async function testSSE() {
|
||||
// Manually trigger a message
|
||||
const response = await fetch("http://localhost:4096/session/SESSION_ID/message", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: "Hello, world!",
|
||||
attachments: [],
|
||||
}),
|
||||
})
|
||||
|
||||
// Check console for SSE events
|
||||
// Should see message_updated events arriving
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Handle Edge Cases
|
||||
|
||||
Add error handling for:
|
||||
|
||||
```typescript
|
||||
// Connection drops during message streaming
|
||||
// - Reconnect logic should handle this automatically
|
||||
// - Messages should resume from last known state
|
||||
|
||||
// Multiple instances with different ports
|
||||
// - Each instance has its own EventSource
|
||||
// - Events routed correctly via instanceId
|
||||
|
||||
// Instance removed while connected
|
||||
// - EventSource closed before instance cleanup
|
||||
// - No memory leaks
|
||||
|
||||
// Page visibility changes (browser tab inactive)
|
||||
// - EventSource may pause, reconnect on focus
|
||||
// - Consider using Page Visibility API to manage connections
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- [ ] Open instance, verify SSE connection established
|
||||
- [ ] Send message, verify streaming events arrive
|
||||
- [ ] Check browser DevTools Network tab for SSE connection
|
||||
- [ ] Verify connection status indicator shows "Connected"
|
||||
- [ ] Kill server process, verify reconnection attempts
|
||||
- [ ] Restart server, verify successful reconnection
|
||||
- [ ] Open multiple instances, verify independent connections
|
||||
- [ ] Switch between instances, verify events route correctly
|
||||
- [ ] Close instance tab, verify EventSource closed cleanly
|
||||
|
||||
### Testing Message Streaming
|
||||
|
||||
- [ ] Send message, watch events in console
|
||||
- [ ] Verify message parts update in real-time
|
||||
- [ ] Check assistant response streams character by character
|
||||
- [ ] Verify tool calls appear as they execute
|
||||
- [ ] Confirm message status updates (streaming → complete)
|
||||
|
||||
### Testing Child Sessions
|
||||
|
||||
- [ ] Trigger action that creates child session
|
||||
- [ ] Verify session_updated event received
|
||||
- [ ] Confirm new session tab appears
|
||||
- [ ] Check parentId correctly set
|
||||
|
||||
### Testing Reconnection
|
||||
|
||||
- [ ] Disconnect network, verify reconnection attempts
|
||||
- [ ] Reconnect network, verify successful reconnection
|
||||
- [ ] Verify exponential backoff delays
|
||||
- [ ] Confirm max attempts limit works
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] SSE connection established when instance created
|
||||
- [ ] Message updates arrive in real-time
|
||||
- [ ] Session updates handled correctly
|
||||
- [ ] Child sessions auto-create tabs
|
||||
- [ ] Connection status visible in UI
|
||||
- [ ] Reconnection logic works with exponential backoff
|
||||
- [ ] Multiple instances have independent connections
|
||||
- [ ] EventSource closed when instance removed
|
||||
- [ ] No console errors during normal operation
|
||||
- [ ] Events route to correct instance/session
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Note: Per MVP principles, don't over-optimize**
|
||||
|
||||
- Simple event handling - no batching needed
|
||||
- Direct state updates trigger reactivity
|
||||
- Reconnection uses exponential backoff
|
||||
- Only optimize if lag occurs in testing
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Event batching for high-frequency updates
|
||||
- Delta updates instead of full message parts
|
||||
- Offline queue for events missed during disconnect
|
||||
- Page Visibility API integration
|
||||
- Event compression for large payloads
|
||||
|
||||
## References
|
||||
|
||||
- [Technical Implementation - SSE Event Handling](../docs/technical-implementation.md#sse-event-handling)
|
||||
- [Architecture - Communication Layer](../docs/architecture.md#communication-layer)
|
||||
- [MDN - EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep reconnection logic simple for MVP
|
||||
- Log all SSE events to console for debugging
|
||||
- Test with long-running streaming responses
|
||||
- Verify memory usage doesn't grow over time
|
||||
- Consider adding SSE event debugging panel (optional)
|
||||
520
tasks/done/009-prompt-input-basic.md
Normal file
520
tasks/done/009-prompt-input-basic.md
Normal file
@@ -0,0 +1,520 @@
|
||||
# Task 009: Prompt Input Basic - Text Input with Send Functionality
|
||||
|
||||
## Status: TODO
|
||||
|
||||
## Objective
|
||||
|
||||
Implement a basic prompt input component that allows users to type messages and send them to the OpenCode server. This enables testing of the SSE integration and completes the core chat interface loop.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 007 (Message display) complete
|
||||
- Task 008 (SSE integration) complete
|
||||
- Active session available
|
||||
- SDK client configured
|
||||
|
||||
## Context
|
||||
|
||||
The prompt input is the primary way users interact with OpenCode. For the MVP, we need:
|
||||
|
||||
- Simple text input (multi-line textarea)
|
||||
- Send button
|
||||
- Basic keyboard shortcuts (Enter to send, Shift+Enter for new line)
|
||||
- Loading state while assistant is responding
|
||||
- Basic validation (empty message prevention)
|
||||
|
||||
Advanced features (slash commands, file attachments, @ mentions) will come in Task 021-024.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create Prompt Input Component
|
||||
|
||||
Create `src/components/prompt-input.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show } from "solid-js"
|
||||
|
||||
interface PromptInputProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [sending, setSending] = createSignal(false)
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = prompt().trim()
|
||||
if (!text || sending() || props.disabled) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await props.onSend(text)
|
||||
setPrompt("")
|
||||
|
||||
// Auto-resize textarea back to initial size
|
||||
if (textareaRef) {
|
||||
textareaRef.style.height = "auto"
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setSending(false)
|
||||
textareaRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
setPrompt(target.value)
|
||||
|
||||
// Auto-resize textarea
|
||||
target.style.height = "auto"
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
||||
}
|
||||
|
||||
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
|
||||
|
||||
return (
|
||||
<div class="prompt-input-container">
|
||||
<div class="prompt-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input"
|
||||
placeholder="Type your message or /command..."
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={sending() || props.disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
class="send-button"
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Show when={sending()} fallback={<span class="send-icon">▶</span>}>
|
||||
<span class="spinner-small" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-input-hints">
|
||||
<span class="hint">
|
||||
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add Send Message Function to Sessions Store
|
||||
|
||||
Update `src/stores/sessions.ts` to add message sending:
|
||||
|
||||
```typescript
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: string[] = [],
|
||||
): Promise<void> {
|
||||
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")
|
||||
}
|
||||
|
||||
// Add user message optimistically
|
||||
const userMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
sessionId,
|
||||
type: "user",
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
timestamp: Date.now(),
|
||||
status: "sending",
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(sessionId)
|
||||
if (updatedSession) {
|
||||
const newMessages = [...updatedSession.messages, userMessage]
|
||||
instanceSessions.set(sessionId, { ...updatedSession, messages: newMessages })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
// Send to server using session.prompt (not session.message)
|
||||
await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
messageID: userMessage.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Update user message status
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(sessionId)
|
||||
if (updatedSession) {
|
||||
const messages = updatedSession.messages.map((m) =>
|
||||
m.id === userMessage.id ? { ...m, status: "sent" as const } : m,
|
||||
)
|
||||
instanceSessions.set(sessionId, { ...updatedSession, messages })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
// Update user message with error
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const updatedSession = instanceSessions.get(sessionId)
|
||||
if (updatedSession) {
|
||||
const messages = updatedSession.messages.map((m) =>
|
||||
m.id === userMessage.id ? { ...m, status: "error" as const } : m,
|
||||
)
|
||||
instanceSessions.set(sessionId, { ...updatedSession, messages })
|
||||
}
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Export it
|
||||
export { sendMessage }
|
||||
```
|
||||
|
||||
### Step 3: Integrate Prompt Input into App
|
||||
|
||||
Update `src/App.tsx` to add the prompt input:
|
||||
|
||||
```typescript
|
||||
import PromptInput from "./components/prompt-input"
|
||||
import { sendMessage } from "./stores/sessions"
|
||||
|
||||
// In the SessionMessages component or create a new wrapper component
|
||||
const SessionView: 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)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSendMessage(prompt: string) {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt)
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div class="session-view">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
messages={s().messages || []}
|
||||
messagesInfo={s().messagesInfo}
|
||||
/>
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
onSend={handleSendMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Replace SessionMessages usage with SessionView
|
||||
```
|
||||
|
||||
### Step 4: Add Styling
|
||||
|
||||
Add to `src/index.css`:
|
||||
|
||||
```css
|
||||
.prompt-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
background-color: var(--background);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.prompt-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.prompt-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.prompt-input-hints {
|
||||
padding: 0 16px 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.session-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Message Display for User Messages
|
||||
|
||||
Make sure user messages display correctly in `src/components/message-item.tsx`:
|
||||
|
||||
```typescript
|
||||
// User messages should show with user styling
|
||||
// Message status should be visible (sending, sent, error)
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">Failed to send message</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
```
|
||||
|
||||
### Step 6: Handle Real-time Response
|
||||
|
||||
The SSE integration from Task 008 should automatically:
|
||||
|
||||
1. Receive message_updated events
|
||||
2. Create assistant message in the session
|
||||
3. Stream message parts as they arrive
|
||||
4. Update the UI in real-time
|
||||
|
||||
No additional code needed - this should "just work" if SSE is connected.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Basic Functionality
|
||||
|
||||
- [ ] Prompt input renders at bottom of session view
|
||||
- [ ] Can type text in the textarea
|
||||
- [ ] Textarea auto-expands as you type (up to max height)
|
||||
- [ ] Send button is disabled when input is empty
|
||||
- [ ] Send button is enabled when text is present
|
||||
|
||||
### Sending Messages
|
||||
|
||||
- [ ] Click send button - message appears in stream
|
||||
- [ ] Press Enter - message sends
|
||||
- [ ] Press Shift+Enter - adds new line (doesn't send)
|
||||
- [ ] Input clears after sending
|
||||
- [ ] Focus returns to input after sending
|
||||
|
||||
### User Message Display
|
||||
|
||||
- [ ] User message appears immediately (optimistic update)
|
||||
- [ ] User message shows "Sending..." state briefly
|
||||
- [ ] User message updates to "sent" after API confirms
|
||||
- [ ] Error state shows if send fails
|
||||
|
||||
### Assistant Response
|
||||
|
||||
- [ ] After sending, SSE receives message updates
|
||||
- [ ] Assistant message appears in stream
|
||||
- [ ] Message parts stream in real-time
|
||||
- [ ] Tool calls appear as they execute
|
||||
- [ ] Connection status indicator shows "Connected"
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- [ ] Can't send while previous message is processing
|
||||
- [ ] Empty/whitespace-only messages don't send
|
||||
- [ ] Very long messages work correctly
|
||||
- [ ] Multiple rapid sends are queued properly
|
||||
- [ ] Network error shows helpful message
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Can type and send text messages
|
||||
- [ ] Enter key sends message
|
||||
- [ ] Shift+Enter creates new line
|
||||
- [ ] Send button works correctly
|
||||
- [ ] User messages appear immediately
|
||||
- [ ] Assistant responses stream in real-time via SSE
|
||||
- [ ] Input auto-expands up to max height
|
||||
- [ ] Loading states are clear
|
||||
- [ ] Error handling works
|
||||
- [ ] No console errors during normal operation
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Per MVP principles - keep it simple:**
|
||||
|
||||
- Direct API calls - no batching
|
||||
- Optimistic updates for user messages
|
||||
- SSE handles streaming automatically
|
||||
- No debouncing or throttling needed
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Slash command autocomplete (Task 021)
|
||||
- File attachment support (Task 022)
|
||||
- Drag & drop files (Task 023)
|
||||
- Attachment chips (Task 024)
|
||||
- Message history navigation (Task 025)
|
||||
- Multi-line paste handling
|
||||
- Rich text formatting
|
||||
- Message drafts persistence
|
||||
|
||||
## References
|
||||
|
||||
- [User Interface - Prompt Input](../docs/user-interface.md#5-prompt-input)
|
||||
- [Technical Implementation - Message Rendering](../docs/technical-implementation.md#message-rendering)
|
||||
- [Task 008 - SSE Integration](./008-sse-integration.md)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
2-3 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Focus on core functionality - no fancy features yet
|
||||
- Test thoroughly with SSE to ensure real-time streaming works
|
||||
- This completes the basic chat loop - users can now interact with OpenCode
|
||||
- Keep error messages user-friendly and actionable
|
||||
- Ensure keyboard shortcuts work as expected
|
||||
603
tasks/todo/010-tool-call-rendering.md
Normal file
603
tasks/todo/010-tool-call-rendering.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# Task 010: Tool Call Rendering - Display Tool Executions Inline
|
||||
|
||||
## Status: TODO
|
||||
|
||||
## Objective
|
||||
|
||||
Implement interactive tool call rendering that displays tool executions inline within assistant messages. Users should be able to expand/collapse tool calls to see input, output, and execution status.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Task 007 (Message display) complete
|
||||
- Task 008 (SSE integration) complete
|
||||
- Task 009 (Prompt input) complete
|
||||
- Messages streaming from API
|
||||
- Tool call data available in message parts
|
||||
|
||||
## Context
|
||||
|
||||
When OpenCode executes tools (bash commands, file edits, etc.), these should be visible to the user in the message stream. Tool calls need:
|
||||
|
||||
- Collapsed state showing summary (tool name + brief description)
|
||||
- Expanded state showing full input/output
|
||||
- Status indicators (pending, running, success, error)
|
||||
- Click to toggle expand/collapse
|
||||
- Syntax highlighting for code in input/output
|
||||
|
||||
This provides transparency into what OpenCode is doing and helps users understand the assistant's actions.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Define Tool Call Types
|
||||
|
||||
Create or update `src/types/message.ts`:
|
||||
|
||||
```typescript
|
||||
export interface ToolCallPart {
|
||||
type: "tool_call"
|
||||
id: string
|
||||
tool: string
|
||||
input: any
|
||||
output?: any
|
||||
status: "pending" | "running" | "success" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
type: "text" | "tool_call"
|
||||
text?: string
|
||||
id?: string
|
||||
tool?: string
|
||||
input?: any
|
||||
output?: any
|
||||
status?: "pending" | "running" | "success" | "error"
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create Tool Call Component
|
||||
|
||||
Create `src/components/tool-call.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createSignal, Show, Switch, Match } from "solid-js"
|
||||
import type { ToolCallPart } from "../types/message"
|
||||
|
||||
interface ToolCallProps {
|
||||
part: ToolCallPart
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded())
|
||||
}
|
||||
|
||||
function getToolIcon(tool: string): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return "⚡"
|
||||
case "edit":
|
||||
return "✏️"
|
||||
case "read":
|
||||
return "📖"
|
||||
case "write":
|
||||
return "📝"
|
||||
case "glob":
|
||||
return "🔍"
|
||||
case "grep":
|
||||
return "🔎"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "⏳"
|
||||
case "running":
|
||||
return "⟳"
|
||||
case "success":
|
||||
return "✓"
|
||||
case "error":
|
||||
return "✗"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function getToolSummary(part: ToolCallPart): string {
|
||||
const { tool, input } = part
|
||||
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return input?.command || "Execute command"
|
||||
case "edit":
|
||||
return `Edit ${input?.filePath || "file"}`
|
||||
case "read":
|
||||
return `Read ${input?.filePath || "file"}`
|
||||
case "write":
|
||||
return `Write ${input?.filePath || "file"}`
|
||||
case "glob":
|
||||
return `Find ${input?.pattern || "files"}`
|
||||
case "grep":
|
||||
return `Search for "${input?.pattern || "pattern"}"`
|
||||
default:
|
||||
return tool
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(obj: any): string {
|
||||
if (typeof obj === "string") return obj
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="tool-call"
|
||||
classList={{
|
||||
"tool-call-expanded": expanded(),
|
||||
"tool-call-error": props.part.status === "error",
|
||||
"tool-call-success": props.part.status === "success",
|
||||
"tool-call-running": props.part.status === "running",
|
||||
}}
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<div class="tool-call-header">
|
||||
<span class="tool-call-expand-icon">{expanded() ? "▼" : "▶"}</span>
|
||||
<span class="tool-call-icon">{getToolIcon(props.part.tool)}</span>
|
||||
<span class="tool-call-tool">{props.part.tool}:</span>
|
||||
<span class="tool-call-summary">{getToolSummary(props.part)}</span>
|
||||
<span class="tool-call-status">{getStatusIcon(props.part.status)}</span>
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>
|
||||
<div class="tool-call-body" onClick={(e) => e.stopPropagation()}>
|
||||
<Show when={props.part.input}>
|
||||
<div class="tool-call-section">
|
||||
<div class="tool-call-section-title">Input:</div>
|
||||
<pre class="tool-call-content">
|
||||
<code>{formatJson(props.part.input)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.part.output !== undefined}>
|
||||
<div class="tool-call-section">
|
||||
<div class="tool-call-section-title">Output:</div>
|
||||
<pre class="tool-call-content">
|
||||
<code>{formatJson(props.part.output)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.part.error}>
|
||||
<div class="tool-call-section tool-call-error-section">
|
||||
<div class="tool-call-section-title">Error:</div>
|
||||
<pre class="tool-call-content tool-call-error-content">
|
||||
<code>{props.part.error}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.part.status === "running"}>
|
||||
<div class="tool-call-running-indicator">
|
||||
<span class="spinner-small" />
|
||||
<span>Executing...</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update Message Item to Render Tool Calls
|
||||
|
||||
Update `src/components/message-item.tsx`:
|
||||
|
||||
```typescript
|
||||
import { For, Show, Switch, Match } from "solid-js"
|
||||
import type { Message, MessagePart } from "../types/message"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const isUser = () => props.message.type === "user"
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-item"
|
||||
classList={{
|
||||
"message-user": isUser(),
|
||||
"message-assistant": !isUser(),
|
||||
}}
|
||||
>
|
||||
<div class="message-header">
|
||||
<span class="message-author">{isUser() ? "You" : "Assistant"}</span>
|
||||
<span class="message-timestamp">
|
||||
{new Date(props.message.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
<For each={props.message.parts}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={part.type === "text"}>
|
||||
<div class="message-text">{part.text}</div>
|
||||
</Match>
|
||||
<Match when={part.type === "tool_call"}>
|
||||
<ToolCall part={part as any} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "error"}>
|
||||
<div class="message-error">Failed to send message</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Add Tool Call Styling
|
||||
|
||||
Add to `src/index.css`:
|
||||
|
||||
```css
|
||||
/* Tool Call Styles */
|
||||
.tool-call {
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--secondary-bg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.tool-call:hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tool-call-expanded {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tool-call-success {
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.tool-call-error {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.tool-call-running {
|
||||
border-left: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-expand-icon {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.tool-call-expanded .tool-call-expand-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tool-call-tool {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
font-size: 14px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tool-call-body {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.tool-call-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tool-call-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-call-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: var(--secondary-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-content code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-error-section {
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tool-call-error-content {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.tool-call-running-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tool-call {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.tool-call-body {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update SSE Handler to Parse Tool Calls
|
||||
|
||||
Update `src/lib/sse-manager.ts` to correctly parse tool call parts from SSE events:
|
||||
|
||||
```typescript
|
||||
function handleMessageUpdate(event: MessageUpdateEvent, instanceId: string) {
|
||||
// When a message part arrives via SSE, check if it's a tool call
|
||||
const part = event.part
|
||||
|
||||
if (part.type === "tool_call") {
|
||||
// Parse tool call data
|
||||
const toolCallPart: ToolCallPart = {
|
||||
type: "tool_call",
|
||||
id: part.id || `tool-${Date.now()}`,
|
||||
tool: part.tool || "unknown",
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
status: part.status || "pending",
|
||||
error: part.error,
|
||||
}
|
||||
|
||||
// Add or update in messages
|
||||
updateMessagePart(instanceId, event.sessionId, event.messageId, toolCallPart)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Handle Tool Call Updates
|
||||
|
||||
Ensure that tool calls can update their status as they execute:
|
||||
|
||||
```typescript
|
||||
// In sessions store
|
||||
function updateMessagePart(instanceId: string, sessionId: string, messageId: string, part: MessagePart) {
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = new Map(prev.get(instanceId))
|
||||
const session = instanceSessions.get(sessionId)
|
||||
|
||||
if (session) {
|
||||
const messages = session.messages.map((msg) => {
|
||||
if (msg.id === messageId) {
|
||||
// Find existing part by ID and update, or append
|
||||
const partIndex = msg.parts.findIndex((p) => p.type === "tool_call" && p.id === part.id)
|
||||
|
||||
if (partIndex !== -1) {
|
||||
const updatedParts = [...msg.parts]
|
||||
updatedParts[partIndex] = part
|
||||
return { ...msg, parts: updatedParts }
|
||||
} else {
|
||||
return { ...msg, parts: [...msg.parts, part] }
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
|
||||
instanceSessions.set(sessionId, { ...session, messages })
|
||||
}
|
||||
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Rendering
|
||||
|
||||
- [ ] Tool calls render in collapsed state by default
|
||||
- [ ] Tool icon displays correctly for each tool type
|
||||
- [ ] Tool summary shows meaningful description
|
||||
- [ ] Status icon displays correctly (pending, running, success, error)
|
||||
- [ ] Styling is consistent with design
|
||||
|
||||
### Expand/Collapse
|
||||
|
||||
- [ ] Click tool call header - expands to show details
|
||||
- [ ] Click again - collapses back to summary
|
||||
- [ ] Expand icon rotates correctly
|
||||
- [ ] Clicking inside expanded body doesn't collapse
|
||||
- [ ] Multiple tool calls can be expanded independently
|
||||
|
||||
### Content Display
|
||||
|
||||
- [ ] Input section shows tool input data
|
||||
- [ ] Output section shows tool output data
|
||||
- [ ] JSON is formatted with proper indentation
|
||||
- [ ] Code/text is displayed in monospace font
|
||||
- [ ] Long output is scrollable horizontally
|
||||
|
||||
### Status Indicators
|
||||
|
||||
- [ ] Pending status shows waiting icon (⏳)
|
||||
- [ ] Running status shows spinner and "Executing..."
|
||||
- [ ] Success status shows checkmark (✓)
|
||||
- [ ] Error status shows X (✗) and error message
|
||||
- [ ] Border color changes based on status
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
- [ ] Tool calls appear as SSE events arrive
|
||||
- [ ] Status updates from pending → running → success
|
||||
- [ ] Output appears when tool completes
|
||||
- [ ] Error state shows if tool fails
|
||||
- [ ] UI updates smoothly without flashing
|
||||
|
||||
### Different Tool Types
|
||||
|
||||
- [ ] Bash commands display correctly
|
||||
- [ ] File edits show file path and changes
|
||||
- [ ] File reads show file path
|
||||
- [ ] Glob/grep show patterns
|
||||
- [ ] Unknown tools have fallback icon
|
||||
|
||||
### Error Handling
|
||||
|
||||
- [ ] Tool errors display error message
|
||||
- [ ] Error section has red styling
|
||||
- [ ] Error state is clearly visible
|
||||
- [ ] Can expand to see full error details
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Tool calls render inline in assistant messages
|
||||
- [ ] Default collapsed state shows summary
|
||||
- [ ] Click to expand shows full input/output
|
||||
- [ ] Status indicators work correctly
|
||||
- [ ] Real-time updates via SSE work
|
||||
- [ ] Multiple tool calls in one message work
|
||||
- [ ] Error states are clear and helpful
|
||||
- [ ] Styling matches design specifications
|
||||
- [ ] No performance issues with many tool calls
|
||||
- [ ] No console errors during normal operation
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
**Per MVP principles - keep it simple:**
|
||||
|
||||
- Render all tool calls - no virtualization
|
||||
- No lazy loading of tool content
|
||||
- Simple JSON.stringify for formatting
|
||||
- Direct DOM updates via SolidJS reactivity
|
||||
- Add optimizations only if problems arise
|
||||
|
||||
## Future Enhancements (Post-MVP)
|
||||
|
||||
- Syntax highlighting for code in input/output (using Shiki)
|
||||
- Diff view for file edits
|
||||
- Copy button for tool output
|
||||
- Link to file in file operations
|
||||
- Collapsible sections within tool calls
|
||||
- Tool execution time display
|
||||
- Retry failed tools
|
||||
- Export tool output
|
||||
|
||||
## References
|
||||
|
||||
- [User Interface - Tool Call Rendering](../docs/user-interface.md#3-messages-area)
|
||||
- [Technical Implementation - Tool Call Rendering](../docs/technical-implementation.md#message-rendering)
|
||||
- [Build Roadmap - Phase 2](../docs/build-roadmap.md#phase-2-core-chat-interface-week-2)
|
||||
|
||||
## Estimated Time
|
||||
|
||||
3-4 hours
|
||||
|
||||
## Notes
|
||||
|
||||
- Focus on clear visual hierarchy - collapsed view should be scannable
|
||||
- Status indicators help users understand what's happening
|
||||
- Errors should be prominent but not alarming
|
||||
- Tool calls are a key differentiator - make them shine
|
||||
- Test with real OpenCode responses to ensure data format matches
|
||||
- Consider adding debug logging to verify SSE data structure
|
||||
Reference in New Issue
Block a user