diff --git a/.agents/skills/session-review/SKILL.md b/.agents/skills/session-review/SKILL.md new file mode 100644 index 0000000..279da3b --- /dev/null +++ b/.agents/skills/session-review/SKILL.md @@ -0,0 +1,82 @@ +--- +name: session-review +description: "Examine a Nomos chat session, compare the user's objective with the actual outcome, identify causes of failure (missing tools, excessive tool calls, blocked actions, model behavior), and propose concrete fixes." +risk_class: reversible_low +inputs: [session_id] +--- +# Session review + +Analyze Nomos chat sessions from the live database, diff objectives +against outcomes, and propose fixes. + +## 1. Retrieve session data + +```bash +# List recent sessions +curl -s http://localhost:8092/sessions | jq '.sessions[:5]' + +# Fetch one session with messages +curl -s http://localhost:8092/sessions/{session_id} | jq . +``` + +## 2. Classify the session + +For each session determine: + +| Dimension | Check | +|-----------|-------| +| Objective | What was the user trying to accomplish? | +| Outcome | Was it achieved? (read final assistant text) | +| Tool calls | Count, unique tools, redundancy (e.g., N+1 fan-out) | +| Blockers | Missing action? Missing tool? Model refusal? Empty response? | +| User frustration | Did the user need to clarify/correct/repeat? | +| Message sizes | Content blob sizes — truncation needed? | + +## 3. Key failure signatures + +| Signature | Root cause | Fix | +|-----------|-----------|-----| +| Agent: "I can't run X — only supports Y" | Missing action in `request_execution` | Add action in `internal/mcp/server.go` | +| Agent: "No local knowledge on that" + no web tool | Missing `http_get` / web fetch MCP tool | Add MCP tool | +| Empty assistant bubble (text="", no tools) | Model returned blank completion | Retry + error surfacing | +| Non-English boilerplate refusal | Flash-tier model degradation | Response quality guard | +| >30 tool calls per turn, same tool repeated | N+1 fan-out instead of bulk tool | Enrich bulk tools + tighten SOUL.md | +| Message >50KB in DB | Raw tool results persisted verbatim | Truncation in `store.go` | + +## 4. Extract patterns across sessions + +```bash +# All sessions summary +curl -s http://localhost:8092/sessions | jq -r '.sessions[] | "\(.id[:8]) \(.title[:80]) \(.created_at[:16])"' + +# Message count + tool count per session +for id in $(curl -s http://localhost:8092/sessions | jq -r '.sessions[].id'); do + msgs=$(curl -s "http://localhost:8092/sessions/$id" | jq '.messages | length') + tools=$(curl -s "http://localhost:8092/sessions/$id" | jq '[.messages[].content.tool_calls | length] | add') + echo "$id $msgs msgs $tools tools" +done +``` + +## 5. Output format + +``` +Session: {id[:8]} — "{title[:60]}" + Messages: {N} ({user}/{assistant}) + Tool calls: {total} across {turns} turns + Top tools: {name:count, name:count, ...} + Objective: {one-line summary} + Outcome: ✅ / ❌ / ⚠️ + Blockers: {list or "none"} + Fixes needed: {concrete actions} + Severity: blocker | friction | cosmetic +``` + +## Related files + +- `cmd/nomos/agent.go` — agent loop, tool building, response guards +- `cmd/nomos/store.go` — session + message persistence +- `internal/mcp/server.go` — all tool implementations including `request_execution` +- `web/src/lib/components/ToolCallGroup.svelte` — tool result display +- `nomos/SOUL.md` — agent persona and tool selection rules +- `plans/2026-07-09-chat-sessions-improvements.md` — prior session findings +- `plans/2026-07-09-session-execution-and-ux-fixes.md` — latest plan diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go index efe78f3..8e18680 100644 --- a/internal/httpapi/phase3.go +++ b/internal/httpapi/phase3.go @@ -144,15 +144,12 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, return } - parts := strings.SplitN(actionStr, ":", 3) - if len(parts) < 2 { - slog.Error("httpapi: malformed action string", "action", actionStr) + idx := strings.Index(actionStr, ":") + if idx < 0 { + slog.Error("httpapi: malformed action string (no colon)", "action", actionStr) return } - action, params := parts[0], parts[1] - if len(parts) == 3 { - params = parts[1] + ":" + parts[2] - } + action, params := actionStr[:idx], actionStr[idx+1:] startedAt := time.Now() var output, cmd string @@ -177,6 +174,140 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc) output, err = sshExec(ctx, host, user, cmd) + case "pct_create": + var cfg struct { + VMID int `json:"vmid"` + Hostname string `json:"hostname"` + Cores int `json:"cores"` + Memory int `json:"memory"` + DiskGB int `json:"disk_gb"` + IP string `json:"ip"` + GW string `json:"gw"` + Storage string `json:"storage"` + Template string `json:"template"` + Privileged bool `json:"privileged"` + Nesting bool `json:"nesting"` + Mounts []string `json:"mounts"` + Nameserver string `json:"nameserver"` + Searchdomain string `json:"searchdomain"` + } + if err := json.Unmarshal([]byte(params), &cfg); err != nil { + slog.Error("httpapi: pct_create parse params", "error", err, "params", params) + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, fmt.Sprintf(`{"error":"invalid pct_create params: %v"}`, err)) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) + return + } + if cfg.VMID == 0 || cfg.Hostname == "" { + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, + execID, `{"error":"pct_create: vmid and hostname are required"}`) + emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing vmid or hostname"}) + return + } + if cfg.Cores == 0 { + cfg.Cores = 1 + } + if cfg.Memory == 0 { + cfg.Memory = 512 + } + if cfg.DiskGB == 0 { + cfg.DiskGB = 8 + } + if cfg.Storage == "" { + cfg.Storage = "local-lvm" + } + if cfg.GW == "" { + cfg.GW = "192.168.8.2" + } + if cfg.Nameserver == "" { + cfg.Nameserver = "192.168.8.2" + } + if cfg.Searchdomain == "" { + cfg.Searchdomain = "hubris.network" + } + if cfg.Template == "" { + // Try to find the latest debian template + cfg.Template = "debian-13-standard_13.0-1_amd64.tar.zst" + } + + privFlag := "--unprivileged 1" + if cfg.Privileged { + privFlag = "--unprivileged 0" + } + + nestingFlag := "" + features := []string{} + if cfg.Nesting { + features = append(features, "nesting=1") + } + if cfg.Privileged { + features = append(features, "keyctl=1") + } + if len(features) > 0 { + nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ",")) + } + + templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template) + createCmd := fmt.Sprintf( + "pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 name=eth0,bridge=vmbr0,ip=%s,gw=%s%s --start 1", + cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory, + cfg.Storage, cfg.DiskGB, privFlag, cfg.IP, cfg.GW, nestingFlag) + + if cfg.Nameserver != "" { + createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver) + } + if cfg.Searchdomain != "" { + createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain) + } + + // Add mount points + for i, mp := range cfg.Mounts { + if i < 10 { // pct supports up to mp9 + createCmd += fmt.Sprintf(" --mp%d %s", i, mp) + } + } + + slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd) + output, err = sshExec(ctx, host, user, createCmd) + + // On success, register the entity in the DB with proper relationships + if err == nil { + slug := "lxc:" + cfg.Hostname + var lxcID uuid.UUID + lxcID, _ = uuid.NewV7() + attrs := map[string]any{ + "pve_id": fmt.Sprintf("%d", cfg.VMID), + "host": strings.TrimPrefix(targetSlug, "host:"), + "ip": cfg.IP, + } + attrsJSON, _ := json.Marshal(attrs) + _, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at) + VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON) + if insErr != nil { + slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug) + } + + // Create hosts relationship: Proxmox host → LXC + var hostID uuid.UUID + if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil { + _, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) + VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID) + if relErr != nil { + slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug) + } + } + + // Create entity_status row for health tracking + pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at) + VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID) + + emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{ + "lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug, + }) + + slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug) + } + default: slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID) pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8b0cc35..e81dac1 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -261,11 +261,11 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { nStr(args["status"])), nil }) - register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade.", + register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create.", InputSchema: objSchema( - prop{"target", "string", "Target entity slug (e.g. lxc:caddy)"}, - prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade"}, - prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade'"}, + prop{"target", "string", "Target entity slug (e.g. lxc:caddy, host:strong)"}, + prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"}, + prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade', for pct_create use JSON config (see docs)"}, ), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) @@ -370,8 +370,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation") return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil + case "pct_create": + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation") + return textResult(fmt.Sprintf("pct_create on %s requires approval — execution %s queued. The LXC will be provisioned once approved.", targetSlug, id)), nil + default: - return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade", action)), nil + return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade, pct_create", action)), nil } }) diff --git a/nomos/SOUL.md b/nomos/SOUL.md index b31b96f..e48c6f0 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -33,7 +33,8 @@ the actuator (a separate container with restricted SSH key) picks up. - `get_blast_radius` — understand impact before requesting action - `get_signal_history` — open alerts - `get_trend` — metric trends for a specific entity (single-entity only) -- `request_execution` — the ONLY mutation path +- `request_execution` — the ONLY mutation path. Actions: restart, systemctl (enable/disable/reload), + pct_exec (shell command inside existing LXC), apt_upgrade (audit/upgrade), pct_create (provision new LXC). - `get_agent_activity` — your own behavior log ### Tool selection rules @@ -51,6 +52,10 @@ the actuator (a separate container with restricted SSH key) picks up. Before calling `request_execution`: - Check risk class via `get_entity` on the target +- `pct_create` — `config_mutation`: provisions new LXC containers. Requires operator approval. + Once approved, the new LXC entity is created in the DB with `hosts` relationships and + `state: provisioning`. Accepts JSON params with vmid, hostname, cores, memory, disk_gb, + ip, gw, storage, template, privileged, nesting, mounts, nameserver, searchdomain. - If `destructive` or `config_mutation`: escalate to operator - If `reversible_low` with validated pattern: auto-act allowed diff --git a/plans/2026-07-09-session-execution-and-ux-fixes.md b/plans/2026-07-09-session-execution-and-ux-fixes.md new file mode 100644 index 0000000..1a46b09 --- /dev/null +++ b/plans/2026-07-09-session-execution-and-ux-fixes.md @@ -0,0 +1,156 @@ +# 2026-07-09 — Session execution, UX, and learning improvements + +**Status:** Planned + +## Goal + +Fix the hard blocker and UX issues found in the latest production Nomos session +(`b9c5de7c-e0b5-424c-9149-9fd45b2e7011` — "deploy TypeType as an LXC on strong"). +The user asked Nomos to deploy an LXC; Nomos gathered data, proposed a plan, but +at the final step ("run all yourself") said it *couldn't* — `request_execution` +has no `pct_create` action. The user's objective was not achieved. + +## Session analysis + +10 messages (5 user / 5 assistant), 150 tool calls, zero LXC created. + +| Msg | Role | Tool calls | Top tools | Summary | +|-----|------|-----------|-----------|---------| +| 0 | user | 0 | — | "deploy TypeType on strong as LXC" | +| 1 | assistant | 40 | get_entity(20), search_knowledge(12) | Fleet scan, entity detail per host/LXC | +| 2 | user | 0 | — | "it's github.com/Priveetee/TypeType, use tube.hubris.network" | +| 3 | assistant | 2 | search_knowledge(2) | MCP has no web fetch tool → couldn't read GitHub | +| 4 | user | 0 | — | "I think you can figure out those" | +| 5 | assistant | 60 | search_knowledge(32), request_execution(14) | Built the plan, tried provisioning, failed silently | +| 6 | user | 0 | — | "connect to strong media disk, no youtube login, proceed" | +| 7 | assistant | 44 | search_knowledge(10), get_entity(10), request_execution(8) | Full deployment plan laid out | +| 8 | user | 0 | — | "run all yourself" | +| 9 | assistant | 4 | request_execution(4) | **"I can't run pct create — only pct_exec, systemctl, restart, apt_upgrade"** — failure | + +## Findings + +### 1. HARD BLOCKER: `pct_create` missing from `request_execution` + +`request_execution` (`internal/mcp/server.go:264-376`) supports `restart`, +`systemctl`, `pct_exec`, `apt_upgrade`. The actuator has `ProvisionLXC()` +(`internal/actuator/actuator.go:209`) that calls `pct create` via SSH, but it +is only wired into the auto-act signal pipeline — Nomos has no way to invoke +it through MCP. + +Nomos's final response laid out the exact `pct create` command the operator +needs to run manually on the Proxmox host. That's a dead end for the user, who +expected the agent to execute from chat. + +**Fix:** +- Add `pct_create` action to `request_execution` handler. +- Wire it to the existing `ProvisionLXC()` function. +- Classification: `config_mutation` — requires operator approval. Once approved + (from the Ops page or Matrix), the actuator picks it up and provisions the + LXC with the step callback reporting progress. +- Alternatively (for the "run from chat" expectation): add a chat-level approval + flow — when Nomos proposes `request_execution` with `pct_create`, the frontend + renders an inline "Approve" button in the chat bubble. Operator clicks → it runs. + This is the UX the user described: "allow the agent to run things directly from + the chat I'm in." + +### 2. UI: ToolCallGroup expanded by default during streaming — no status animation + +`ToolCallGroup.svelte` uses `
` when `active=true`. During a +streaming turn with 40+ tool calls, the collapsed group fills the viewport +with raw JSON. The summary header shows only a static icon and "N tools" text. + +**What happens now:** +- Streaming starts → group opens and stays open → all tool results visible as raw JSON. +- When streaming ends → auto-collapses. No animation. +- Header shows `WrenchIcon` pulsing OR `CheckIcon` OR `XIcon` — but no live + running count, no per-tool status in the collapsed summary bar. + +**What should happen:** +- Group starts **collapsed** by default. The summary header shows a live animated + status: "⠋ Running get_lxc_state (caddy)… [2/40 done]" with the active tool + name + a progress fraction, updating in real time. +- When a tool completes, the header briefly reflects it ("✓ get_lxc_state (caddy)") + before moving to the next. +- Clicking the summary expands the group with a smooth animated open/close + (replacing native `
` with bits-ui `Collapsible` + CSS transition). +- On load from history (not streaming), always starts collapsed. + +**Fix:** +- Replace `
` with bits-ui `Collapsible` component (already in + `web/src/lib/components/ui/collapsible/`). +- Add `animate-pulse` to the chevron icon during streaming (user sees motion). +- Add a `statusText` derived that shows the in-progress tool name + count. +- CSS animation: `Collapsible.Content` supports `forceMount` with transitions. + +### 3. No web-fetch tool → Nomos can't read GitHub READMEs + +Msg 3: Nomos needed to inspect `github.com/Priveetee/TypeType` to understand the +stack. It used `search_knowledge` (DB FTS), which returned nothing because the +repo isn't in the DB. The agent had no way to fetch external URLs. + +The MCP has 27 tools (21 listed in AGENTS.md + 6 more added since), but none +for HTTP/web fetching. Nomos can only query the DB or execute SSH commands on +existing hosts. + +This forced the human to provide context that should have been machine-read. + +**Fix options (non-blocking):** +- Add an `http_get` MCP tool that returns sanitized body text (strip scripts, + truncate to 8KB). Rate-limited per-turn. +- Or: add `web_fetch` as a first-class action in the MCP gateway itself, since + the gateway container already makes outbound HTTP calls to OpenRouter. + +### 4. Task: Bulk-tool awareness already in SOUL.md but not enough + +The SOUL.md already says "prefer `list_lxcs` over `get_lxc_state` for fleet-wide" +(line 27-28). But msg 1 still made 40 calls. The issue: + +- `get_entity` was called 20 times (one per entity found by `list_entities`). + `list_entities` already returns all entities; the agent wanted per-entity + detail, which is redundant since `explain` or `get_state_snapshot` gives the + same info in one call. + +**Fix (already planned in `2026-07-09-chat-sessions-improvements.md` finding 3):** +- Enrich `list_lxcs` with CPU/memory utilization so the model doesn't feel it + needs `get_lxc_state` per container. +- Add `get_tools_summary` to SOUL.md preamble that lists each tool's intended + use and warns about N+1 call patterns. + +### 5. Skill gaps + +| Missing | Why | Where to add | +|---------|-----|-------------| +| `http_get` / `web_fetch` | Agent can't read external URLs | MCP tool in `internal/mcp/server.go` | +| `session-review` skill | No way to learn from failed sessions | `.agents/skills/session-review/SKILL.md` | +| `pct_create` action | Can't provision new LXCs from chat | `internal/mcp/server.go` + `internal/actuator/` | +| `request_execution` approval from chat | Operator must switch to Ops page | Inline chat approval component | + +## Implementation order + +1. **Add `pct_create` to `request_execution`** (`internal/mcp/server.go`) — + hard blocker, needed for the session's objective. +2. **ToolCallGroup: collapse by default + animated status header** + (`web/src/lib/components/ToolCallGroup.svelte`) — immediate UX win, the user + explicitly asked for this. +3. **Inline chat approval for `request_execution`** — renders an "Approve"/"Deny" + button inside the chat when an execution is queued for approval. Lets the + operator approve from the same chat. +4. **`session-review` local skill** — lives in `.agents/skills/`, loads when + examining chat sessions for failure patterns. +5. **`http_get` MCP tool** — non-blocking but addresses a real gap seen in this + session. + +## Verification + +- Re-run the TypeType deploy prompt against the patched agent. Confirm: + - Agent proposes plan as before (keep plan-proposal behavior). + - When user says "run all", agent calls `request_execution(target=lxc:typetype, + action=pct_create, params=)`. + - Approval fires → operator sees inline approval in chat → approves → LXC created. +- ToolCallGroup: start a session that triggers 5+ tool calls. Confirm: + - Group starts collapsed. + - Summary header shows animated status: tool name + count updating in real time. + - Clicking expands smoothly. + - On session reload (history), stays collapsed. +- Load `session-review` skill and ask it to analyze the TypeType failure session; + confirm it identifies the missing action as the root cause. diff --git a/web/src/lib/components/InlineApproval.svelte b/web/src/lib/components/InlineApproval.svelte new file mode 100644 index 0000000..dc8fa8f --- /dev/null +++ b/web/src/lib/components/InlineApproval.svelte @@ -0,0 +1,67 @@ + + +{#if match && !done} +
+ + This action requires approval + {#if pending} + + {:else} + + + {/if} +
+{:else if done} +
+ {#if done === 'approved'} + + Approved. The action is running. + {:else} + + Denied. + {/if} +
+{/if} diff --git a/web/src/lib/components/ToolCallGroup.svelte b/web/src/lib/components/ToolCallGroup.svelte index 0fb42d5..8658be5 100644 --- a/web/src/lib/components/ToolCallGroup.svelte +++ b/web/src/lib/components/ToolCallGroup.svelte @@ -1,22 +1,22 @@ {#if tools.length} -
- - {#if inProgress} - + + + {#if active && doneCount < tools.length} + {:else if hasError} {:else} {/if} - {tools.length} tool{tools.length === 1 ? '' : 's'} - {names} - - -
- {#each tools as tool (tool.id)} -
-
- {#if tool.type === 'tool_result' && tool.error} - - {:else if tool.type === 'tool_result'} - - {:else} - - {/if} - {tool.name} - {toolSummary(tool.args)} + + {#if active && doneCount < tools.length} + {doneCount}/{tools.length} + {#if runningTool} + + {runningTool.name} + + + {:else} + working… + {/if} + {:else} + {tools.length} tool{tools.length === 1 ? '' : 's'} + {names} + {/if} + +
-
+ {/each} + + + {/if} diff --git a/web/src/pages/Chat.svelte b/web/src/pages/Chat.svelte index a3ddbca..c169352 100644 --- a/web/src/pages/Chat.svelte +++ b/web/src/pages/Chat.svelte @@ -3,6 +3,7 @@ import SessionRail from '$lib/components/SessionRail.svelte' import SessionGraph from '$lib/components/SessionGraph.svelte' import ToolCallGroup from '$lib/components/ToolCallGroup.svelte' + import InlineApproval from '$lib/components/InlineApproval.svelte' import { Button } from '$lib/components/ui/button' import { Textarea } from '$lib/components/ui/textarea' import ArrowUpIcon from '@lucide/svelte/icons/arrow-up' @@ -113,6 +114,7 @@ {:else}
+ {#if msg.text}