fix(infra): rebrand, workspace path, model config, chat SSE streaming

- Rebrand Tarzan → Tiger in layout metadata and header
- Bridge: point WORKSPACE_SYMLINK at the docker volume path post-migration
  (/var/lib/docker/volumes/tiger_tiger-workspace/_data) — the old
  /root/tiger-workspace symlink was orphaned after the April standalone
  migration, causing workspace endpoint to return empty.
- Bridge: read agents.defaults.model.primary for model info + expose all
  configured availableModels so the dashboard card can show them.
- Dashboard: page.tsx renders currentModel/fallbackModels/availableModels.
- Chat streaming fix (client side):
  * proper SSE buffering across TCP chunks (split on \n\n, keep tail in
    buffer, {stream: true} decoder for multi-byte UTF-8)
  * separate status vs chunk handlers — status no longer pollutes content
  * fall back to data.content in done event if streamingRef is empty
  * visible parse errors instead of silent catch
  * plain-text rendering while streaming, ReactMarkdown only after done —
    avoids per-token markdown reparse which was killing the typing feel

Root causes:
  1. tiger-bridge crash-looped for 36h on EADDRINUSE because a manual
     nohup restart squatted on port 3456; systemd's tsx version couldn't
     bind. Killing the squatter restored the expected tsx-src workflow.
  2. ChunkLoadError on /chat: npm run build ran under a live next start
     prod server, creating an in-memory manifest vs on-disk build split.
     Fixed by disciplined build-then-restart.
  3. Dashboard chat silently dropped responses: SSE 'status' event text
     was being concatenated into the agent message content.
This commit is contained in:
Manohar Gupta 2026-04-18 19:10:47 +00:00
parent d4a3f2b869
commit 8fe6694a21
6 changed files with 271 additions and 213 deletions

17
.gitignore vendored
View file

@ -36,3 +36,20 @@ config/*.json
!config/*.example.json
config/mcporter.json
config/cron.json
# ─── Added by housecleaning Apr 2026 ───
# Claude Code session worktrees (local workspace artifacts)
.claude/
# Runtime SQLite databases — schema is in db.ts, not data/
data/
*.db
*.db-shm
*.db-wal
# Backup files from patching sessions
*.bak
*.bak.*
# Compiled bridge (regenerable from src/)
bridge/dist/
# macOS artifacts that can slip in via Mutagen
.DS_Store
._*

View file

@ -42,7 +42,7 @@ import "./db.js";
// ─── Configuration ─────────────────────────────────────────────────────────
const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10);
const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS
const HOST = process.env.TIGER_BRIDGE_HOST || "0.0.0.0"; // Bind to all interfaces for Docker access
const app = express();
@ -85,6 +85,11 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an
app.use("/tiger/projects", projectsRouter);
app.use("/tiger/tasks", tasksRouter);
app.use("/tiger/dispatch", dispatchRouter);
app.use("/tiger/chat", (await import("./routes/chat.js")).default);
// Gateway proxy — forwards to gateway inside Tiger container
// This is needed because the dashboard runs in Dokploy which can't reach the container directly
app.use("/api/gateway", (await import("./routes/gateway.js")).default);
// ─── Error handling ─────────────────────────────────────────────────────────

View file

@ -1,10 +1,8 @@
/**
* tiger.ts Core executor for Tiger agent inside Dockerk3ssandbox
* tiger.ts Core executor for Tiger agent inside Docker container
*
* The key insight: Tiger lives 3 layers deep. Every command must traverse:
* Host Docker (openshell-cluster-nemoclaw) k3s (kubectl exec) sandbox pod (tiger)
*
* This module wraps that complexity into clean async functions.
* Tiger runs directly in tiger-openclaw container (no more k3s layers).
* Commands are executed via docker exec inside the container.
*/
import { exec, execFile, spawn } from "child_process";
@ -15,30 +13,28 @@ import { createHash } from "crypto";
const execAsync = promisify(exec);
// ─── Configuration ───────────────────────────────────────────────
// These match your known paths from the Tiger setup
const DOCKER_CONTAINER = "openshell-cluster-nemoclaw";
// Tiger runs directly in the tiger-openclaw container
const DOCKER_CONTAINER = "tiger-openclaw";
const K8S_NAMESPACE = "openshell";
const POD_NAME = "tiger";
const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json";
const OPENCLAW_CONFIG_HOST = "/root/.openclaw/openclaw.json";
const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash";
const WORKSPACE_SYMLINK = "/root/tiger-workspace";
const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data";
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
// Timeout for commands (30s default, some ops need longer)
const DEFAULT_TIMEOUT = 30_000;
/**
* Execute a command inside the Tiger sandbox pod.
* This is the fundamental operation everything else builds on it.
*
* The full command chain:
* docker exec <container> kubectl exec -n <ns> <pod> -- <cmd>
* Execute a command inside the Tiger container.
* Commands run directly via docker exec (no kubectl needed).
*/
export async function execInSandbox(
command: string,
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`;
// Run command directly inside tiger-openclaw container
const fullCmd = `docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`;
try {
const { stdout, stderr } = await execAsync(fullCmd, {
@ -97,10 +93,10 @@ export async function getTigerStatus() {
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
// 4. Last heartbeat content
execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
execInSandbox("cat /home/node/.openclaw/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
// 5. Agent identity from SOUL.md
execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
execInSandbox("head -20 /home/node/.openclaw/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
]);
// Parse container state
@ -133,15 +129,38 @@ export async function getTigerStatus() {
}
}
// Read host config for model info
// Read host config for model info.
// OpenClaw stores the default agent model at agents.defaults.model.primary
// and a list of fallbacks at agents.defaults.model.fallbacks. Some runtime
// paths (e.g. channels.telegram) or session overrides may pick a different
// model at request time — we also capture the provider list so the UI can
// show what's actually available.
let currentModel = "unknown";
let fallbackModels: string[] = [];
let availableModels: string[] = [];
try {
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
const config = JSON.parse(configRaw);
// Navigate the OpenClaw config structure for model info
currentModel = config?.model?.primary || config?.model || "unknown";
fallbackModels = config?.model?.fallbacks || [];
const agentDefaults = config?.agents?.defaults?.model;
if (typeof agentDefaults === "string") {
currentModel = agentDefaults;
} else if (agentDefaults && typeof agentDefaults === "object") {
currentModel = agentDefaults.primary || "unknown";
fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : [];
}
// Also surface all configured provider/model IDs so the UI shows what's
// available, not just what's selected. Format: "provider/model-id"
const providers = config?.providers || {};
for (const [provName, provCfg] of Object.entries<any>(providers)) {
const models = provCfg?.models;
if (Array.isArray(models)) {
for (const m of models) {
if (m?.id) availableModels.push(`${provName}/${m.id}`);
}
}
}
} catch { /* config not readable */ }
return {
@ -163,6 +182,7 @@ export async function getTigerStatus() {
agent: {
currentModel,
fallbackModels,
availableModels,
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null,
},
@ -171,7 +191,7 @@ export async function getTigerStatus() {
/**
* Read the OpenClaw config from the host.
* Config lives at /root/.nemoclaw/openclaw.json on the host,
* Config lives at /root/.openclaw/openclaw.json on the host,
* gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json
*/
export async function getConfig(): Promise<Record<string, any>> {
@ -194,7 +214,7 @@ export async function updateConfig(patch: Record<string, any>): Promise<void> {
// 3. Backup current config before writing
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`);
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.openclaw/backups/openclaw-${timestamp}.json`);
// 4. Write updated config
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");

View file

@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/theme-provider"
import { ModeToggle } from "@/components/mode-toggle"
import "./globals.css";
import { Agentation } from 'agentation';
import { ChatProvider } from "@/contexts/chat-context";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -19,8 +20,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Command Center",
description: "Tarzan's Dashboard for Agent Management",
title: "Tiger Command Center",
description: "Tiger Agent Management Dashboard",
};
export default function RootLayout({
@ -40,14 +41,15 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<SidebarProvider>
<ChatProvider>
<SidebarProvider>
<TooltipProvider>
<AppSidebar />
<main className="w-full bg-background text-foreground relative">
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
<div className="flex items-center gap-2">
<SidebarTrigger />
<h1 className="text-sm font-medium text-muted-foreground">Tarzan's Dashboard</h1>
<h1 className="text-sm font-medium text-muted-foreground">Tiger Dashboard</h1>
</div>
<ModeToggle />
</div>
@ -57,7 +59,8 @@ export default function RootLayout({
<Agentation />
</main>
</TooltipProvider>
</SidebarProvider>
</SidebarProvider>
</ChatProvider>
</ThemeProvider>
</body>
</html>

View file

@ -43,6 +43,7 @@ interface TigerStatus {
agent: {
currentModel: string
fallbackModels: string[]
availableModels?: string[]
heartbeat: string | null
soul: string | null
}
@ -331,6 +332,20 @@ export default function DashboardPage() {
</div>
)}
{/* Available Models (from providers config) */}
{status?.agent?.availableModels && status.agent.availableModels.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Available Models</div>
<div className="space-y-1">
{status.agent.availableModels.map((model, i) => (
<div key={i} className="p-2 rounded-md border border-border/50 bg-background/30 text-xs font-mono text-muted-foreground">
{model}
</div>
))}
</div>
</div>
)}
{/* Heartbeat */}
{status?.agent?.heartbeat && (
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">

View file

@ -1,153 +1,25 @@
"use client"
import * as React from "react"
import { Send, Square, Bot, User, AlertCircle, Loader2 } from "lucide-react"
import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react"
import ReactMarkdown from "react-markdown"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway"
type Message = {
id: string
role: "user" | "agent" | "system"
content: string
streaming?: boolean
timestamp: number
}
function extractContent(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content)) {
return content
.map((block: unknown) => {
if (typeof block === "string") return block
if (block && typeof block === "object" && "text" in block) return String((block as { text: string }).text)
return ""
})
.filter(Boolean)
.join("\n")
}
if (content && typeof content === "object") {
const obj = content as Record<string, unknown>
if ("text" in obj) return String(obj.text)
if ("content" in obj) return extractContent(obj.content)
}
return ""
}
import { useChatContext } from "@/contexts/chat-context"
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
const [input, setInput] = React.useState("")
const [messages, setMessages] = React.useState<Message[]>([])
// Persistent chat state — survives navigation between routes.
// See contexts/chat-context.tsx for the rationale.
const { messages, setMessages, clearChat } = useChatContext()
const [sending, setSending] = React.useState(false)
const [agentTyping, setAgentTyping] = React.useState(false)
const scrollRef = React.useRef<HTMLDivElement>(null)
const { request } = useGatewayRequest()
const streamingContentRef = React.useRef("")
const abortRef = React.useRef<AbortController | null>(null)
const streamingRef = React.useRef("")
// Load chat history on mount
React.useEffect(() => {
request("chat.history", { sessionKey: "agent:main:main", limit: 50 })
.then((data: unknown) => {
const history = data as { messages?: Array<{ role: string; content: unknown; ts?: number }> }
if (history?.messages?.length) {
setMessages(
history.messages.map((m, i) => ({
id: `hist-${i}`,
role: m.role === "user" ? "user" : "agent",
content: extractContent(m.content),
timestamp: m.ts || Date.now(),
}))
)
}
})
.catch(() => {
setMessages([{
id: "welcome",
role: "agent",
content: "Connected to Tarzan via gateway. Send a message to start chatting.",
timestamp: Date.now(),
}])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Subscribe to gateway events for streaming responses
const { connected } = useGatewayEvents((event, payload) => {
const data = payload as Record<string, unknown>
if (event === "chat") {
// Incoming chat message (from other channels or agent completion)
const role = (data.role as string) === "user" ? "user" : "agent"
const content = extractContent(data.text || data.content || "")
if (!content) return
setMessages(prev => {
// Remove any streaming message and add final
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `chat-${Date.now()}`,
role,
content,
timestamp: Date.now(),
}]
})
setAgentTyping(false)
streamingContentRef.current = ""
}
if (event === "agent") {
// Streaming agent response chunks
const chunk = data.chunk as string | undefined
const done = data.done as boolean | undefined
const text = data.text as string | undefined
if (chunk || text) {
streamingContentRef.current += (chunk || text || "")
setAgentTyping(true)
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: "streaming",
role: "agent",
content: streamingContentRef.current,
streaming: true,
timestamp: Date.now(),
}]
})
}
if (done) {
setAgentTyping(false)
setSending(false)
// Finalize streaming message
if (streamingContentRef.current) {
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: streamingContentRef.current,
timestamp: Date.now(),
}]
})
}
streamingContentRef.current = ""
}
}
}, [])
// Auto-scroll to bottom
React.useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
@ -161,9 +33,9 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
const text = input.trim()
setInput("")
setSending(true)
streamingContentRef.current = ""
streamingRef.current = ""
// Add user message immediately
// Add user message
setMessages(prev => [...prev, {
id: `user-${Date.now()}`,
role: "user",
@ -172,44 +44,165 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
}])
try {
await request("chat.send", {
sessionKey: "agent:main:main",
message: text,
idempotencyKey: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
const controller = new AbortController()
abortRef.current = controller
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: controller.signal,
})
// Response will come via SSE events (agent/chat events)
} catch {
if (!res.ok || !res.body) throw new Error("Failed to connect")
const reader = res.body.getReader()
const decoder = new TextDecoder()
// Buffer across reads — a single SSE event ("data: ...\n\n") may be
// split across TCP chunks. Accumulate, then split on the SSE delimiter.
let buffer = ""
const streamId = `streaming-${Date.now()}`
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) break
// {stream: true} preserves decoder state for multi-byte UTF-8 chars
// (e.g. emoji) that happen to land across chunk boundaries.
buffer += decoder.decode(value, { stream: true })
// SSE events end with a blank line (\n\n). Anything after the last
// \n\n is a partial event — keep it in `buffer` for the next read.
const events = buffer.split("\n\n")
buffer = events.pop() || ""
for (const eventBlock of events) {
const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: "))
if (!dataLine) continue
let data: { type: string; content?: string }
try {
data = JSON.parse(dataLine.slice(6))
} catch (err) {
// Don't swallow silently — log so real parse bugs are visible.
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
continue
}
console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50))
if (data.type === "status") {
// Transient 'Tiger is thinking...' indicator. Do NOT append to
// the message content — that was Bug A. Just ensure a streaming
// placeholder exists so the UI shows activity.
setMessages(prev => {
if (prev.some(m => m.streaming)) return prev
return [...prev, {
id: streamId,
role: "agent",
content: "",
streaming: true,
timestamp: Date.now(),
}]
})
} else if (data.type === "chunk") {
streamingRef.current += data.content || ""
setMessages(prev => {
const existing = prev.find(m => m.streaming)
if (existing) {
return prev.map(m =>
m.streaming ? { ...m, content: streamingRef.current } : m
)
}
return [...prev, {
id: streamId,
role: "agent",
content: streamingRef.current,
streaming: true,
timestamp: Date.now(),
}]
})
} else if (data.type === "message") {
// Non-streaming full message
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: data.content || "",
timestamp: Date.now(),
}]
})
} else if (data.type === "done") {
// Fall back to data.content if the chunk event somehow didn't
// land — Bug D. This is a belt-and-suspenders safety.
const finalContent = streamingRef.current || data.content || ""
setMessages(prev => {
const filtered = prev.filter(m => !m.streaming)
if (!finalContent) return filtered
return [...filtered, {
id: `agent-${Date.now()}`,
role: "agent",
content: finalContent,
timestamp: Date.now(),
}]
})
streamingRef.current = ""
setSending(false)
} else if (data.type === "error") {
setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`,
role: "system",
content: data.content || "Something went wrong",
timestamp: Date.now(),
}])
setSending(false)
}
}
}
} catch (err: any) {
if (err.name !== "AbortError") {
setMessages(prev => [...prev.filter(m => !m.streaming), {
id: `err-${Date.now()}`,
role: "system",
content: "Failed to send message. Is Tiger running?",
timestamp: Date.now(),
}])
}
setSending(false)
setMessages(prev => [...prev, {
id: `err-${Date.now()}`,
role: "system",
content: "Failed to send message. Is the gateway running?",
timestamp: Date.now(),
}])
}
abortRef.current = null
}
const handleAbort = async () => {
try {
await request("chat.abort", { sessionKey: "agent:main:main" })
} catch {
// ignore
}
const handleAbort = () => {
abortRef.current?.abort()
setSending(false)
setAgentTyping(false)
streamingRef.current = ""
setMessages(prev => prev.filter(m => !m.streaming))
}
return (
<Card className={cn("w-full flex flex-col", className)} {...props}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-5 w-5" />
Chat
<span className={cn(
"ml-auto h-2 w-2 rounded-full",
connected ? "bg-green-500" : "bg-red-500"
)} />
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-5 w-5" />
Chat with Tiger
</CardTitle>
<Button
type="button"
variant="ghost"
size="sm"
onClick={clearChat}
className="text-xs text-muted-foreground h-7"
title="Clear conversation"
>
<Eraser className="h-3 w-3 mr-1" />
Clear
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 min-h-0">
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
@ -235,22 +228,27 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
)}
</div>
)}
<div
className={cn(
"rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: message.role === "system"
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
: "bg-muted",
message.streaming ? "border border-primary/30" : ""
)}
>
<div className={cn(
"rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: message.role === "system"
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
: "bg-muted",
message.streaming ? "border border-primary/30" : ""
)}>
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
{message.role === "agent" ? (
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
// While streaming: render raw text (cheap, one DOM node update per token).
// After streaming completes: render full ReactMarkdown (expensive but
// only happens once). This is what makes the typing feel actually show up.
message.streaming ? (
<div className="whitespace-pre-wrap">{message.content}</div>
) : (
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)
) : (
message.content
)}
@ -260,7 +258,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
</div>
</div>
))}
{agentTyping && !messages.some(m => m.streaming) && (
{sending && !messages.some(m => m.streaming) && (
<div className="flex gap-2">
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
<Bot className="h-4 w-4" />
@ -276,19 +274,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
<CardFooter className="pt-3">
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
<Input
placeholder={connected ? "Message Tarzan..." : "Gateway offline..."}
placeholder="Message Tiger..."
className="flex-1"
autoComplete="off"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={!connected || sending}
disabled={sending}
/>
{sending ? (
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
<Square className="h-4 w-4" />
</Button>
) : (
<Button type="submit" size="icon" disabled={!input.trim() || !connected}>
<Button type="submit" size="icon" disabled={!input.trim()}>
<Send className="h-4 w-4" />
</Button>
)}