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:
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