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:
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
|
||||
Reference in New Issue
Block a user