diff --git a/navi/core/agent.py b/navi/core/agent.py
index dbfc8f9..7686324 100644
--- a/navi/core/agent.py
+++ b/navi/core/agent.py
@@ -179,49 +179,20 @@
Used by the anti-stall detector to compare todo state before and after an
iteration — any status change means the model made real progress.
"""
- try:
- from navi.tools.todo import _plans
- return frozenset((t.text, t.status) for t in _plans.get(session_id, []))
- except Exception:
- return frozenset()
+ from navi.tools.todo import get_task_snapshot
+ return get_task_snapshot(session_id)
def _todo_failed_steps(session_id: str) -> frozenset[tuple[int, str]]:
"""Return a frozenset of (1-based index, task_text) for steps currently marked failed."""
- try:
- from navi.tools.todo import _plans
- return frozenset(
- (i + 1, t.text)
- for i, t in enumerate(_plans.get(session_id, []))
- if t.status == "failed"
- )
- except Exception:
- return frozenset()
+ from navi.tools.todo import get_failed_steps
+ return get_failed_steps(session_id)
def _todo_progress_message(session_id: str, *, first_iteration: bool = False) -> "Message | None":
"""Build a compact system reminder with current todo state and update discipline."""
- try:
- from navi.tools.todo import _plans, _STATUS_ICON
- tasks = _plans.get(session_id, [])
- if not tasks:
- return None
-
- lines = ["[Todo discipline]"]
- if first_iteration:
- lines.append("The todo list was auto-populated from the plan.")
- lines.append("Use 1-based todo indexes. When starting a step, mark it in_progress.")
- lines.append("After completing or failing a step, update todo before moving on.")
- lines.append("Do not mark a step done until you have verified it; include validation explaining the check.")
- lines.append("Before final response, close every completed step with todo update and validation.")
- lines.append("Current todo:")
- for i, t in enumerate(tasks, 1):
- icon = _STATUS_ICON.get(t.status, "?")
- validation_note = f"; verified: {t.validation}" if t.validation else ""
- lines.append(f" {icon} {i}. {t.text} ({t.status}{validation_note})")
- return Message(role="system", content="\n".join(lines))
- except Exception:
- return None
+ from navi.tools.todo import get_progress_message
+ return get_progress_message(session_id, first_iteration=first_iteration)
class Agent:
@@ -348,6 +319,8 @@
# Give each sub-agent its own scratchpad namespace so parallel or
# sequential sub-agents don't clobber each other's working notes.
from navi.tools.base import current_session_id as _sid_var, current_model as _model_var
+ _prev_sid = _sid_var.get(None)
+ _prev_model = _model_var.get(None)
_sid_var.set(f"subagent_{_uuid.uuid4().hex[:12]}")
profile = self._profiles.get(profile_id)
@@ -407,143 +380,148 @@
_start_time = _time.monotonic()
accumulated_text = ""
- # ── Optional planning phase ────────────────────────────────────────────
- if profile.subagent_planning_enabled:
- async for _ev in self._run_planning(
- context, profile, llm, mem, tool_schemas,
- system_prompt_override=subagent_sys_msg.content,
- is_subagent=True,
- ):
- if isinstance(_ev, AIHelperTokensUsed):
- pass # token accounting only, not forwarded
- elif sink is not None:
- await sink.put(_ev)
+ try:
+ # ── Optional planning phase ────────────────────────────────────────────
+ if profile.subagent_planning_enabled:
+ async for _ev in self._run_planning(
+ context, profile, llm, mem, tool_schemas,
+ system_prompt_override=subagent_sys_msg.content,
+ is_subagent=True,
+ ):
+ if isinstance(_ev, AIHelperTokensUsed):
+ pass # token accounting only, not forwarded
+ elif sink is not None:
+ await sink.put(_ev)
- # ── Tool-calling loop ──────────────────────────────────────────────────
- for iteration in range(max_iterations):
- if stop_event and stop_event.is_set():
- return accumulated_text, False
+ # ── Tool-calling loop ──────────────────────────────────────────────────
+ for iteration in range(max_iterations):
+ if stop_event and stop_event.is_set():
+ return accumulated_text, False
- elapsed = _time.monotonic() - _start_time
- if elapsed >= timeout_seconds:
- log.warning("agent.subagent.timeout", elapsed=elapsed, timeout=timeout_seconds)
- if sink is not None:
- await sink.put(SubagentComplete(token_count=_sub_tokens, tool_call_count=_sub_tool_count))
- return accumulated_text or "[Sub-agent timed out]", False
+ elapsed = _time.monotonic() - _start_time
+ if elapsed >= timeout_seconds:
+ log.warning("agent.subagent.timeout", elapsed=elapsed, timeout=timeout_seconds)
+ if sink is not None:
+ await sink.put(SubagentComplete(token_count=_sub_tokens, tool_call_count=_sub_tool_count))
+ return accumulated_text or "[Sub-agent timed out]", False
- log.debug("agent.subagent.iteration", iteration=iteration)
+ log.debug("agent.subagent.iteration", iteration=iteration)
- accumulated_text = ""
- accumulated_thinking = ""
- turn_tool_calls: list[ToolCallRequest] | None = None
- turn_tokens: int | None = None
+ accumulated_text = ""
+ accumulated_thinking = ""
+ turn_tool_calls: list[ToolCallRequest] | None = None
+ turn_tokens: int | None = None
- # Build context inline — no persona or profiles block for subagents.
- built_ctx: list[Message] = [subagent_sys_msg]
- if mem:
- built_ctx.append(mem)
- built_ctx.extend(m for m in context if m.role != "system")
- self._check_context_size(built_ctx)
+ # Build context inline — no persona or profiles block for subagents.
+ built_ctx: list[Message] = [subagent_sys_msg]
+ if mem:
+ built_ctx.append(mem)
+ built_ctx.extend(m for m in context if m.role != "system")
+ self._check_context_size(built_ctx)
- async for chunk in _iter_stream_guarded(
- llm.stream_complete(
- built_ctx,
- tools=tool_schemas if tools else None,
- temperature=profile.temperature,
- model=profile.model,
- think=profile.think_enabled,
- top_k=profile.top_k,
- top_p=profile.top_p,
- num_thread=profile.num_thread,
- ),
- stop_event=stop_event,
- first_chunk_timeout=settings.llm_stream_first_chunk_timeout,
- chunk_timeout=settings.llm_stream_chunk_timeout,
- ):
- if chunk.prompt_tokens is not None or chunk.completion_tokens is not None:
- turn_tokens = (chunk.prompt_tokens or 0) + (chunk.completion_tokens or 0)
- if chunk.thinking:
- accumulated_thinking += chunk.thinking
- if chunk.delta:
- accumulated_text += chunk.delta
- if chunk.tool_calls:
- turn_tool_calls = chunk.tool_calls
+ async for chunk in _iter_stream_guarded(
+ llm.stream_complete(
+ built_ctx,
+ tools=tool_schemas if tools else None,
+ temperature=profile.temperature,
+ model=profile.model,
+ think=profile.think_enabled,
+ top_k=profile.top_k,
+ top_p=profile.top_p,
+ num_thread=profile.num_thread,
+ ),
+ stop_event=stop_event,
+ first_chunk_timeout=settings.llm_stream_first_chunk_timeout,
+ chunk_timeout=settings.llm_stream_chunk_timeout,
+ ):
+ if chunk.prompt_tokens is not None or chunk.completion_tokens is not None:
+ turn_tokens = (chunk.prompt_tokens or 0) + (chunk.completion_tokens or 0)
+ if chunk.thinking:
+ accumulated_thinking += chunk.thinking
+ if chunk.delta:
+ accumulated_text += chunk.delta
+ if chunk.tool_calls:
+ turn_tool_calls = chunk.tool_calls
- if stop_event and stop_event.is_set():
- return accumulated_text, False
+ if stop_event and stop_event.is_set():
+ return accumulated_text, False
- if not turn_tool_calls:
- log.info("agent.subagent.complete", iterations=iteration + 1,
- result_len=len(accumulated_text))
- _sub_tokens = turn_tokens or 0
- if sink is not None:
- await sink.put(SubagentComplete(
- token_count=_sub_tokens,
- tool_call_count=_sub_tool_count,
- ))
- return accumulated_text, True
+ if not turn_tool_calls:
+ log.info("agent.subagent.complete", iterations=iteration + 1,
+ result_len=len(accumulated_text))
+ _sub_tokens = turn_tokens or 0
+ if sink is not None:
+ await sink.put(SubagentComplete(
+ token_count=_sub_tokens,
+ tool_call_count=_sub_tool_count,
+ ))
+ return accumulated_text, True
- # Emit accumulated thinking before tool calls
- if accumulated_thinking and sink is not None:
- log.debug("agent.subagent.turn_thinking", length=len(accumulated_thinking))
- await sink.put(TurnThinking(thinking=accumulated_thinking, is_subagent=True))
+ # Emit accumulated thinking before tool calls
+ if accumulated_thinking and sink is not None:
+ log.debug("agent.subagent.turn_thinking", length=len(accumulated_thinking))
+ await sink.put(TurnThinking(thinking=accumulated_thinking, is_subagent=True))
- context.append(Message(
- role="assistant",
- content=accumulated_text or None,
- tool_calls=turn_tool_calls,
- ))
+ context.append(Message(
+ role="assistant",
+ content=accumulated_text or None,
+ tool_calls=turn_tool_calls,
+ ))
- # Execute each tool call sequentially, emitting events to parent sink
- for tc in turn_tool_calls:
- _sub_tool_count += 1
- if sink is not None:
- await sink.put(ToolStarted(
- tool_name=tc.name, arguments=tc.arguments, is_subagent=True
- ))
+ # Execute each tool call sequentially, emitting events to parent sink
+ for tc in turn_tool_calls:
+ _sub_tool_count += 1
+ if sink is not None:
+ await sink.put(ToolStarted(
+ tool_name=tc.name, arguments=tc.arguments, is_subagent=True
+ ))
- tool = tool_map.get(tc.name)
- image_msg = None
- metadata: dict = {}
- if tool is None:
- content = f"Error: tool '{tc.name}' not found."
- success = False
- else:
- log.info("tool.execute.subagent", tool=tc.name, args=tc.arguments)
- try:
- result = await tool.execute(tc.arguments)
- content = result.to_message_content()
- success = result.success
- metadata = result.metadata or {}
- if result.success and result.metadata and result.metadata.get("is_image"):
- b64 = result.metadata.get("base64")
- if b64:
- image_msg = Message(
- role="user",
- content=f"[Image loaded via {tc.name} — analyse it]",
- images=[b64],
- )
- except Exception as exc:
- log.warning("agent.subagent.tool_exception", tool=tc.name, error=str(exc))
- content = f"Error: {exc}"
+ tool = tool_map.get(tc.name)
+ image_msg = None
+ metadata: dict = {}
+ if tool is None:
+ content = f"Error: tool '{tc.name}' not found."
success = False
- metadata = {}
+ else:
+ log.info("tool.execute.subagent", tool=tc.name, args=tc.arguments)
+ try:
+ result = await tool.execute(tc.arguments)
+ content = result.to_message_content()
+ success = result.success
+ metadata = result.metadata or {}
+ if result.success and result.metadata and result.metadata.get("is_image"):
+ b64 = result.metadata.get("base64")
+ if b64:
+ image_msg = Message(
+ role="user",
+ content=f"[Image loaded via {tc.name} — analyse it]",
+ images=[b64],
+ )
+ except Exception as exc:
+ log.warning("agent.subagent.tool_exception", tool=tc.name, error=str(exc))
+ content = f"Error: {exc}"
+ success = False
+ metadata = {}
- if sink is not None:
- await sink.put(ToolEvent(
- tool_name=tc.name, arguments=tc.arguments,
- result=content, success=success, is_subagent=True,
- ))
+ if sink is not None:
+ await sink.put(ToolEvent(
+ tool_name=tc.name, arguments=tc.arguments,
+ result=content, success=success, is_subagent=True,
+ ))
- context.append(Message(role="tool", content=content,
- tool_call_id=tc.id, name=tc.name, metadata=metadata))
- if image_msg:
- context.append(image_msg)
+ context.append(Message(role="tool", content=content,
+ tool_call_id=tc.id, name=tc.name, metadata=metadata))
+ if image_msg:
+ context.append(image_msg)
- log.warning("agent.subagent.max_iterations", max_iterations=max_iterations)
- if sink is not None:
- await sink.put(SubagentComplete(token_count=_sub_tokens, tool_call_count=_sub_tool_count))
- return accumulated_text or "[Sub-agent reached iteration limit without a final answer]", False
+ log.warning("agent.subagent.max_iterations", max_iterations=max_iterations)
+ if sink is not None:
+ await sink.put(SubagentComplete(token_count=_sub_tokens, tool_call_count=_sub_tool_count))
+ return accumulated_text or "[Sub-agent reached iteration limit without a final answer]", False
+ finally:
+ # Restore parent ContextVar values so background tasks don't inherit stale subagent IDs
+ _sid_var.set(_prev_sid)
+ _model_var.set(_prev_model)
async def run_stream(
self,
@@ -1338,10 +1316,10 @@
_todo_steps = _parse_plan_steps(plan_text)
if _todo_steps:
try:
- from navi.tools.todo import _plans, _Task
+ from navi.tools.todo import set_tasks
from navi.tools.base import current_session_id as _sid_var
_sid = _sid_var.get() or "__default__"
- _plans[_sid] = [_Task(text=s) for s in _todo_steps]
+ set_tasks(_sid, _todo_steps)
log.debug("agent.todo_auto_populated", steps=len(_todo_steps), session=_sid)
except Exception:
log.warning("agent.todo_auto_populate_failed", exc_info=True)
@@ -1488,14 +1466,11 @@
]
try:
- from navi.tools.todo import _plans
- tasks = _plans.get(session_id, [])
- if tasks:
+ from navi.tools.todo import render_todo_lines
+ todo_lines = render_todo_lines(session_id)
+ if todo_lines:
lines.append("Current todo:")
- for i, t in enumerate(tasks):
- from navi.tools.todo import _STATUS_ICON
- icon = _STATUS_ICON.get(t.status, "?")
- lines.append(f" {icon} [{i}] {t.text} ({t.status})")
+ lines.extend(todo_lines)
except Exception:
pass
diff --git a/navi/main.py b/navi/main.py
index be8edf7..4b1a639 100644
--- a/navi/main.py
+++ b/navi/main.py
@@ -6,8 +6,8 @@
import logging
import structlog
-from fastapi import FastAPI, Request
-from fastapi.responses import FileResponse, Response
+from fastapi import FastAPI
+from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from navi.api.routes import agents, health, messages, sessions
@@ -38,9 +38,6 @@
app.include_router(eval_router)
_base = Path(__file__).parent.parent
-_static_dir = _base / "old_webclient"
-if _static_dir.exists():
- app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
app.mount("/assets", StaticFiles(directory=str(_base / "webclient" / "dist" / "assets")), name="assets")
app.mount("/images", StaticFiles(directory=str(_base / "webclient" / "dist" / "images")), name="images")
app.mount("/content-viewers", StaticFiles(directory=str(_base / "webclient" / "dist" / "content-viewers")), name="content_viewers")
@@ -78,12 +75,11 @@
_cleanup_task = asyncio.create_task(cleanup_loop(get_session_store()))
-@app.middleware("http")
-async def no_cache_static(request: Request, call_next) -> Response:
- response = await call_next(request)
- if request.url.path.startswith("/static/"):
- response.headers["Cache-Control"] = "no-store"
- return response
+@app.on_event("shutdown")
+async def _on_shutdown() -> None:
+ from navi.tools.ssh_exec import close_all_connections
+
+ close_all_connections()
@app.get("/", include_in_schema=False)
diff --git a/navi/tools/ssh_exec.py b/navi/tools/ssh_exec.py
index 00414ad..67793f3 100644
--- a/navi/tools/ssh_exec.py
+++ b/navi/tools/ssh_exec.py
@@ -100,6 +100,16 @@
pass
+def close_all_connections() -> None:
+ """Close every pooled SSH connection. Called on server shutdown."""
+ for entry in list(_pool.values()):
+ try:
+ entry.conn.close()
+ except Exception:
+ pass
+ _pool.clear()
+
+
def _pool_key(session_id: str | None, host: str, port: int, username: str) -> str:
sid = session_id or "anonymous"
return f"{sid}:{host}:{port}:{username}"
diff --git a/navi/tools/todo.py b/navi/tools/todo.py
index eeb1675..0c06cf9 100644
--- a/navi/tools/todo.py
+++ b/navi/tools/todo.py
@@ -153,3 +153,105 @@
validation_note = f" [verified: {t.validation}]" if t.validation else ""
lines.append(f" {icon} {i}. {t.text}{suffix}{validation_note}")
return "\n".join(lines)
+
+
+# ── Public API for agent.py (avoids tight coupling to internal _plans dict) ──
+
+def get_task_snapshot(session_id: str) -> frozenset[tuple[str, str]]:
+ """Return a frozenset of (task_text, status) for the session's todo list."""
+ try:
+ return frozenset((t.text, t.status) for t in _plans.get(session_id, []))
+ except Exception:
+ return frozenset()
+
+
+def get_failed_steps(session_id: str) -> frozenset[tuple[int, str]]:
+ """Return a frozenset of (1-based index, task_text) for failed steps."""
+ try:
+ return frozenset(
+ (i + 1, t.text)
+ for i, t in enumerate(_plans.get(session_id, []))
+ if t.status == "failed"
+ )
+ except Exception:
+ return frozenset()
+
+
+def get_progress_message(session_id: str, *, first_iteration: bool = False) -> "Message | None":
+ """Build a compact system reminder with current todo state."""
+ try:
+ tasks = _plans.get(session_id, [])
+ if not tasks:
+ return None
+ n = len(tasks)
+ done = sum(1 for t in tasks if t.status == "done")
+ failed = sum(1 for t in tasks if t.status == "failed")
+ in_progress = sum(1 for t in tasks if t.status == "in_progress")
+ pending = n - done - failed - in_progress
+
+ # Progress line
+ icon_done = _STATUS_ICON["done"]
+ icon_failed = _STATUS_ICON["failed"]
+ lines = [f"TODO progress: {done}/{n} {icon_done} {failed} {icon_failed} ({in_progress} in progress, {pending} pending)"]
+
+ # List remaining tasks
+ remaining = [t for t in tasks if t.status not in ("done", "skipped")]
+ if remaining:
+ lines.append("Remaining tasks:")
+ for i, t in enumerate(remaining, 1):
+ lines.append(f" {i}. {t.text}")
+ else:
+ lines.append("All tasks completed.")
+
+ # Adaptive discipline note
+ if failed > 0 and failed >= done:
+ lines.append(
+ "Discipline: you have more failures than completions. "
+ "Stop and diagnose the root cause before continuing."
+ )
+ elif failed > 0 and failed == 1 and done >= 2:
+ lines.append(
+ "Discipline: one failure so far — acceptable if the root cause is understood. "
+ "Document the fix in your response."
+ )
+ elif failed > 1:
+ lines.append(
+ "Discipline: multiple failures detected. Consider replanning or delegating."
+ )
+
+ if first_iteration:
+ lines.append(
+ "Discipline: this is the FIRST iteration. Prioritise the highest-impact "
+ "remaining task and complete it fully before moving to the next."
+ )
+ elif pending == 0 and failed == 0:
+ lines.append(
+ "Discipline: all tasks are done or in progress. Finalise the response now."
+ )
+ else:
+ lines.append(
+ "Discipline: focus on completing ONE task per iteration. "
+ "Do not start the next task until the current one is verified and marked done."
+ )
+
+ from navi.llm.base import Message
+ return Message(role="system", content="\n".join(lines))
+ except Exception:
+ return None
+
+
+def set_tasks(session_id: str, task_texts: list[str]) -> None:
+ """Auto-populate the todo list from plan steps."""
+ _plans[session_id] = [_Task(text=s) for s in task_texts]
+
+
+def render_todo_lines(session_id: str) -> list[str]:
+ """Return a list of formatted todo lines for goal anchoring."""
+ tasks = _plans.get(session_id, [])
+ if not tasks:
+ return []
+ lines = []
+ for i, t in enumerate(tasks):
+ icon = _STATUS_ICON.get(t.status, "?")
+ lines.append(f" {icon} [{i}] {t.text} ({t.status})")
+ return lines
diff --git a/old_webclient/debug.html b/old_webclient/debug.html
deleted file mode 100644
index 0e5e286..0000000
--- a/old_webclient/debug.html
+++ /dev/null
@@ -1,363 +0,0 @@
-
-
-
-
-
- Navi — Context Debug
-
-
-
-
-
-
-
- Enter a session ID to inspect what the model sees.
-
-
-
-
-
-
-
diff --git a/old_webclient/images/logo-icon.webp b/old_webclient/images/logo-icon.webp
deleted file mode 100644
index 2aecc46..0000000
--- a/old_webclient/images/logo-icon.webp
+++ /dev/null
Binary files differ
diff --git a/old_webclient/images/logo.svg b/old_webclient/images/logo.svg
deleted file mode 100644
index e242401..0000000
--- a/old_webclient/images/logo.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/old_webclient/images/navi-avatar.webp b/old_webclient/images/navi-avatar.webp
deleted file mode 100644
index 78545d5..0000000
--- a/old_webclient/images/navi-avatar.webp
+++ /dev/null
Binary files differ
diff --git a/old_webclient/images/navi.png b/old_webclient/images/navi.png
deleted file mode 100644
index 657dee0..0000000
--- a/old_webclient/images/navi.png
+++ /dev/null
Binary files differ
diff --git a/old_webclient/index.html b/old_webclient/index.html
deleted file mode 100644
index bbc008c..0000000
--- a/old_webclient/index.html
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
- Navi
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
💬
-
Start a new conversation
-
-
-
-
-
-
-
-
-
-
diff --git a/old_webclient/js/api.js b/old_webclient/js/api.js
deleted file mode 100644
index 7fc8770..0000000
--- a/old_webclient/js/api.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/** REST API calls. All functions return parsed JSON or throw on error. */
-
-async function request(method, path, body) {
- const res = await fetch(path, {
- method,
- headers: body ? { 'Content-Type': 'application/json' } : {},
- body: body ? JSON.stringify(body) : undefined,
- });
- if (!res.ok && res.status !== 204) {
- const err = await res.json().catch(() => ({ detail: res.statusText }));
- throw new Error(err.detail || res.statusText);
- }
- return res.status === 204 ? null : res.json();
-}
-
-export const api = {
- getProfiles: () => request('GET', '/agents/profiles'),
- getSessions: () => request('GET', '/sessions'),
- getSession: (id) => request('GET', `/sessions/${id}`),
- createSession: (profileId) => request('POST', '/sessions', { profile_id: profileId }),
- deleteSession: (id) => request('DELETE', `/sessions/${id}`),
- pinSession: (id, pinned) => request('PATCH', `/sessions/${id}/pin`, { pinned }),
-};
diff --git a/old_webclient/js/app.js b/old_webclient/js/app.js
deleted file mode 100644
index 4723f71..0000000
--- a/old_webclient/js/app.js
+++ /dev/null
@@ -1,677 +0,0 @@
-import { api } from './api.js';
-import { WsClient } from './ws.js';
-import { formatBytes } from './utils.js';
-import { appendMessage, appendStreamBubble, finalizeStreamBubble,
- appendToolCall, appendPendingToolCard, finalizeToolCard,
- appendSubagentStep, finalizeSubagentStep,
- appendTurnThinkingCard, appendSubagentThinking,
- appendThinkingCard, finalizeThinkingCard,
- appendPlanCard,
- appendTypingIndicator, removeTypingIndicator,
- appendError, showEmptyState, scrollToBottom,
- appendSummaryCard, appendCompressionNotice } from './chat.js';
-import { renderProfiles, renderSessions, updateChatHeader } from './sidebar.js';
-
-// ── DOM refs ─────────────────────────────────────────────────────────────────
-
-const profileSelect = document.getElementById('profile-select');
-const btnNew = document.getElementById('btn-new');
-const btnSidebarToggle = document.getElementById('btn-sidebar-toggle');
-const sidebarEl = document.querySelector('.sidebar');
-const sidebarBackdrop = document.getElementById('sidebar-backdrop');
-const sessionListEl = document.getElementById('session-list');
-const chatHeaderEl = document.getElementById('chat-header');
-const tokenCounterEl = document.getElementById('token-counter');
-const messagesEl = document.getElementById('messages');
-const textarea = document.getElementById('input');
-const btnSend = document.getElementById('btn-send');
-const btnAttach = document.getElementById('btn-attach');
-const fileInput = document.getElementById('file-input');
-const previewStrip = document.getElementById('image-preview-strip');
-const uploadProgressBar = document.getElementById('upload-progress-bar');
-const uploadProgressFill = document.getElementById('upload-progress-fill');
-const uploadProgressLabel = document.getElementById('upload-progress-label');
-
-// ── State ─────────────────────────────────────────────────────────────────────
-
-let profiles = [];
-let sessions = [];
-let currentId = null;
-let streaming = false;
-let inputAllowed = false; // true when a session is open and ready for input
-let currentBubble = null;
-let currentThinking = null;
-let pendingImages = []; // {dataUrl} — images to send as base64
-let pendingFiles = []; // {name, size, path, contentType} — uploaded non-image files
-let uploadCount = 0; // number of in-progress uploads
-let pendingToolCard = null; // current main-level tool card awaiting result
-let pendingSubStep = null; // current sub-agent step inside pendingToolCard
-
-const ws = new WsClient();
-
-// ── Boot ──────────────────────────────────────────────────────────────────────
-
-async function init() {
- textarea.disabled = false; // always enabled; send button guards actual sending
-
- textarea.addEventListener('keydown', onKey);
- textarea.addEventListener('input', onTextareaInput);
- textarea.addEventListener('paste', onPaste);
- btnSend.addEventListener('click', () => streaming ? stopGeneration() : sendMessage());
- btnNew.addEventListener('click', newChat);
- btnAttach.addEventListener('click', () => fileInput.click());
- fileInput.addEventListener('change', onFileChange);
- profileSelect.addEventListener('change', onProfileChange);
- btnSidebarToggle.addEventListener('click', toggleSidebar);
- sidebarBackdrop.addEventListener('click', closeSidebar);
-
- sessionListEl.innerHTML = '';
-
- [profiles, sessions] = await Promise.all([api.getProfiles(), api.getSessions()]);
- sessions = sessions.map(enrichSession);
-
- renderProfiles(profileSelect, profiles);
-
- // Open session from URL hash, or fall back to most recently active overall
- const hashId = location.hash.slice(1);
- const target = (hashId && sessions.find(s => s.session_id === hashId))
- ? hashId
- : sessions[0]?.session_id ?? null;
-
- if (target) {
- await openSession(target, false);
- } else {
- rerenderSidebar();
- showEmptyState(messagesEl);
- setInputEnabled(false);
- }
-}
-
-// ── Profile selector ──────────────────────────────────────────────────────────
-
-function onProfileChange() {
- rerenderSidebar();
- // Switch to the most recent session of the newly selected profile, or empty state
- const profileId = profileSelect.value;
- const first = sessions.find(s => s.profile_id === profileId)?.session_id ?? null;
- if (first) {
- openSession(first);
- } else {
- abandonStream();
- ws.disconnect();
- currentId = null;
- history.replaceState(null, '', location.pathname);
- showEmptyState(messagesEl);
- updateChatHeader(chatHeaderEl, null);
- setInputEnabled(false);
- }
-}
-
-// ── Sessions ──────────────────────────────────────────────────────────────────
-
-async function newChat() {
- const profileId = profileSelect.value;
- if (!profileId) return;
- btnNew.disabled = true;
- try {
- const session = await api.createSession(profileId);
- sessions.unshift({ ...session, preview: '', profile_name: profileName(profileId) });
- await openSession(session.session_id, false);
- } finally {
- btnNew.disabled = false;
- }
-}
-
-async function openSession(sessionId, skipLoad = false) {
- saveDraft(); // persist typed text for the session we're leaving
- abandonStream(); // reset stream state before WS disconnect to suppress finishStream on onClose
-
- ws.disconnect();
- currentId = sessionId;
- history.replaceState(null, '', '#' + sessionId);
- tokenCounterEl.hidden = true;
-
- // Sync profile selector to match the opened session
- const s = sessions.find(s => s.session_id === sessionId);
- if (s) profileSelect.value = s.profile_id;
-
- rerenderSidebar();
-
- const pId = s?.profile_id ?? '';
- const pName = s?.profile_name ?? profileName(pId);
- updateChatHeader(chatHeaderEl, pId, pName);
-
- if (!skipLoad) {
- await loadHistory(sessionId);
- }
-
- connectWs(sessionId);
- setInputEnabled(true);
- restoreDraft(); // restore any previously typed (unsent) text
-}
-
-async function loadHistory(sessionId) {
- messagesEl.innerHTML = '';
- try {
- const data = await api.getSession(sessionId);
- messagesEl.innerHTML = '';
-
- const toolCallMap = {};
- for (const msg of data.messages) {
- if (msg.role === 'assistant' && msg.tool_calls) {
- for (const tc of msg.tool_calls) {
- toolCallMap[tc.id] = { name: tc.name, args: tc.arguments ?? {} };
- }
- }
- }
-
- for (const msg of data.messages) {
- if (msg.role === 'system') continue;
-
- if (msg.is_summary) {
- appendSummaryCard(messagesEl, msg.content ?? '');
- continue;
- }
-
- if (msg.role === 'tool') {
- const tc = toolCallMap[msg.tool_call_id] ?? { name: msg.name ?? '?', args: {} };
- const success = !msg.content?.startsWith('Error:');
- appendToolCall(messagesEl, {
- tool: tc.name,
- args: tc.args,
- result: msg.content ?? '',
- success,
- });
- continue;
- }
-
- if (msg.role === 'user' || (msg.role === 'assistant' && msg.content && !msg.tool_calls)) {
- const imgs = msg.images?.map(b => b.startsWith('data:') ? b : `data:image/jpeg;base64,${b}`) ?? null;
- appendMessage(messagesEl, msg.role, msg.content, imgs, msg.created_at ?? null);
- }
- }
-
- scrollToBottom(messagesEl);
- } catch (e) {
- console.error('loadHistory', e);
- messagesEl.innerHTML = '';
- }
-}
-
-async function deleteSession(sessionId) {
- await api.deleteSession(sessionId).catch(console.error);
- localStorage.removeItem('navi_draft_' + sessionId);
- sessions = sessions.filter(s => s.session_id !== sessionId);
- if (currentId === sessionId) {
- abandonStream();
- ws.disconnect();
- currentId = null;
- history.replaceState(null, '', location.pathname);
- showEmptyState(messagesEl);
- updateChatHeader(chatHeaderEl, null);
- setInputEnabled(false);
- }
- rerenderSidebar();
-}
-
-async function pinSession(sessionId, pinned) {
- await api.pinSession(sessionId, pinned).catch(console.error);
- const s = sessions.find(s => s.session_id === sessionId);
- if (s) s.pinned = pinned;
- sessions.sort((a, b) => (b.pinned - a.pinned) || (b.last_active > a.last_active ? 1 : -1));
- rerenderSidebar();
-}
-
-// ── WebSocket ─────────────────────────────────────────────────────────────────
-
-function connectWs(sessionId) {
- ws.connect(sessionId, {
- onClose: () => { if (streaming) finishStream(); },
- onMessage: handleWsEvent,
- });
-}
-
-function handleWsEvent(event) {
- switch (event.type) {
- case 'stream_start':
- streaming = true;
- currentBubble = null;
- currentThinking = null;
- updateInputUI();
- appendTypingIndicator(messagesEl);
- scrollToBottom(messagesEl);
- break;
-
- case 'thinking_delta':
- if (!currentThinking) {
- removeTypingIndicator(messagesEl);
- currentThinking = appendThinkingCard(messagesEl);
- }
- currentThinking.pre.textContent += event.delta;
- scrollToBottom(messagesEl);
- break;
-
- case 'thinking_end':
- if (currentThinking) {
- finalizeThinkingCard(currentThinking.card);
- currentThinking = null;
- }
- break;
-
- case 'stream_delta':
- if (!currentBubble) {
- removeTypingIndicator(messagesEl);
- currentBubble = appendStreamBubble(messagesEl);
- }
- currentBubble.textContent += event.delta;
- scrollToBottom(messagesEl);
- break;
-
- case 'turn_thinking':
- removeTypingIndicator(messagesEl);
- if (event.is_subagent) {
- appendSubagentThinking(pendingToolCard, event.thinking);
- } else {
- appendTurnThinkingCard(messagesEl, event.thinking);
- }
- scrollToBottom(messagesEl);
- break;
-
- case 'tool_started':
- removeTypingIndicator(messagesEl);
- if (event.is_subagent) {
- // Sub-agent step — attach to current spawn_agent card
- pendingSubStep = appendSubagentStep(pendingToolCard, event);
- } else {
- // Main-level tool — create new pending card
- pendingToolCard = appendPendingToolCard(messagesEl, event);
- }
- scrollToBottom(messagesEl);
- break;
-
- case 'tool_call':
- if (event.is_subagent) {
- // Complete the current sub-agent step
- finalizeSubagentStep(pendingSubStep, event);
- pendingSubStep = null;
- } else {
- // Complete the main-level tool card
- finalizeToolCard(pendingToolCard, event);
- pendingToolCard = null;
- }
- scrollToBottom(messagesEl);
- break;
-
- case 'stream_end':
- finishStream(event.content);
- updateTokenCounter(event.context_tokens, event.max_context_tokens);
- setInputEnabled(true);
- break;
-
- case 'stream_stopped':
- finishStream();
- setInputEnabled(true);
- break;
-
- case 'profile_switched': {
- // Update local session record so sidebar and header stay in sync.
- const idx = sessions.findIndex(s => s.session_id === currentId);
- if (idx !== -1) {
- sessions[idx].profile_id = event.profile_id;
- sessions[idx].profile_name = event.profile_name;
- }
- profileSelect.value = event.profile_id;
- updateChatHeader(chatHeaderEl, event.profile_id, event.profile_name);
- rerenderSidebar();
- break;
- }
-
- case 'plan_ready':
- removeTypingIndicator(messagesEl);
- appendPlanCard(messagesEl, event.plan);
- appendTypingIndicator(messagesEl);
- scrollToBottom(messagesEl);
- break;
-
- case 'context_compressed':
- appendCompressionNotice(messagesEl, event.messages_before, event.messages_after, event.summary);
- scrollToBottom(messagesEl);
- break;
-
- case 'error':
- finishStream();
- appendError(messagesEl, event.message);
- setInputEnabled(true);
- break;
- }
-}
-
-function finishStream(finalContent) {
- streaming = false;
- removeTypingIndicator(messagesEl);
- if (currentThinking) {
- finalizeThinkingCard(currentThinking.card);
- currentThinking = null;
- }
- if (finalContent !== undefined) {
- if (!currentBubble) {
- currentBubble = appendStreamBubble(messagesEl);
- }
- finalizeStreamBubble(currentBubble, finalContent);
- updatePreview(currentId, finalContent);
- } else if (currentBubble) {
- currentBubble.classList.remove('cursor');
- }
- currentBubble = null;
- scrollToBottom(messagesEl);
-}
-
-/** Reset stream state without touching DOM — use when switching sessions mid-stream. */
-function abandonStream() {
- streaming = false;
- currentBubble = null;
- currentThinking = null;
- pendingToolCard = null;
- pendingSubStep = null;
- // Don't clear pendingImages/pendingFiles — those belong to the user's draft
-}
-
-// ── Sidebar (mobile) ──────────────────────────────────────────────────────────
-
-function toggleSidebar() {
- sidebarEl.classList.toggle('open');
- sidebarBackdrop.classList.toggle('visible');
-}
-
-function closeSidebar() {
- sidebarEl.classList.remove('open');
- sidebarBackdrop.classList.remove('visible');
-}
-
-// ── Sending ───────────────────────────────────────────────────────────────────
-
-async function sendMessage() {
- const text = textarea.value.trim();
- if ((!text && !pendingImages.length && !pendingFiles.length) || !ws.ready || streaming) return;
- if (uploadCount > 0) return; // wait for uploads to finish
-
- const imagesToSend = [...pendingImages];
- const filesToSend = [...pendingFiles];
- clearAttachments();
- textarea.value = '';
- autoResize();
- localStorage.removeItem('navi_draft_' + currentId);
- setInputEnabled(false);
-
- appendMessage(messagesEl, 'user', text || null, imagesToSend.length ? imagesToSend : null, null, filesToSend.length ? filesToSend : null);
- appendTypingIndicator(messagesEl);
- scrollToBottom(messagesEl);
-
- const b64List = imagesToSend.map(d => d.split(',', 2)[1]);
- ws.send(
- text || ' ',
- b64List.length ? b64List : null,
- filesToSend.length ? filesToSend : null,
- );
-}
-
-function onKey(e) {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
-}
-
-// ── Draft persistence ─────────────────────────────────────────────────────────
-
-function saveDraft() {
- if (!currentId) return;
- const text = textarea.value;
- if (text) {
- localStorage.setItem('navi_draft_' + currentId, text);
- } else {
- localStorage.removeItem('navi_draft_' + currentId);
- }
-}
-
-function restoreDraft() {
- textarea.value = (currentId && localStorage.getItem('navi_draft_' + currentId)) ?? '';
- autoResize();
-}
-
-// ── Helpers ───────────────────────────────────────────────────────────────────
-
-function rerenderSidebar() {
- const profileId = profileSelect.value;
- const visible = profileId
- ? sessions.filter(s => s.profile_id === profileId)
- : sessions;
- renderSessions(sessionListEl, visible, currentId, {
- onSelect: (id) => { if (id !== currentId) openSession(id); closeSidebar(); },
- onDelete: deleteSession,
- onPin: pinSession,
- });
-}
-
-function updatePreview(sessionId, text) {
- const s = sessions.find(s => s.session_id === sessionId);
- if (s) s.preview = text.slice(0, 60);
- rerenderSidebar();
-}
-
-function profileName(profileId) {
- return profiles.find(p => p.id === profileId)?.name ?? profileId;
-}
-
-function enrichSession(s) {
- return { ...s, profile_name: profileName(s.profile_id), preview: s.preview || '' };
-}
-
-/**
- * Single source of truth for input bar state.
- * Derives button appearance from: streaming, inputAllowed, uploadCount.
- */
-function updateInputUI() {
- if (streaming) {
- btnSend.disabled = false;
- btnSend.textContent = '■';
- btnSend.title = 'Stop generation';
- btnSend.classList.add('stop-mode');
- btnAttach.disabled = true;
- } else {
- btnSend.disabled = !inputAllowed || uploadCount > 0;
- btnSend.textContent = '↑';
- btnSend.title = 'Send (Enter)';
- btnSend.classList.remove('stop-mode');
- btnAttach.disabled = !inputAllowed;
- if (inputAllowed) textarea.focus();
- }
-}
-
-function setInputEnabled(on) {
- inputAllowed = on;
- updateInputUI();
-}
-
-function stopGeneration() {
- btnSend.disabled = true; // prevent double-click while waiting for stream_stopped
- fetch(`/sessions/${currentId}/stop`, { method: 'POST' }).catch(console.error);
-}
-
-function onTextareaInput() {
- autoResize();
- saveDraft();
-}
-
-function autoResize() {
- textarea.style.height = 'auto';
- textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px';
-}
-
-// ── File / Image handling ─────────────────────────────────────────────────────
-
-function addImageFile(file) {
- const reader = new FileReader();
- reader.onload = (e) => {
- pendingImages.push(e.target.result);
- renderPreviewStrip();
- };
- reader.readAsDataURL(file);
-}
-
-function addArbitraryFile(file) {
- if (!currentId) return;
- uploadCount++;
- updateInputUI();
-
- // Show upload progress
- uploadProgressBar.hidden = false;
- uploadProgressFill.style.width = '0%';
- uploadProgressLabel.textContent = `Uploading ${file.name}…`;
-
- const xhr = new XMLHttpRequest();
- const form = new FormData();
- form.append('file', file, file.name);
-
- xhr.upload.onprogress = (e) => {
- if (e.lengthComputable) {
- const pct = Math.round((e.loaded / e.total) * 100);
- uploadProgressFill.style.width = pct + '%';
- uploadProgressLabel.textContent = `${file.name} — ${pct}%`;
- }
- };
-
- xhr.onload = () => {
- uploadCount--;
- if (uploadCount === 0) uploadProgressBar.hidden = true;
- updateInputUI();
-
- if (xhr.status === 201) {
- const info = JSON.parse(xhr.responseText);
- pendingFiles.push(info);
- renderPreviewStrip();
- } else {
- let msg = `Upload failed (${xhr.status})`;
- try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (_) {}
- alert(msg);
- }
- };
-
- xhr.onerror = () => {
- uploadCount--;
- if (uploadCount === 0) uploadProgressBar.hidden = true;
- updateInputUI();
- alert(`Upload failed: network error`);
- };
-
- xhr.open('POST', `/sessions/${currentId}/files`);
- xhr.send(form);
-}
-
-function onFileChange(e) {
- for (const file of e.target.files) {
- if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
- addImageFile(file);
- } else {
- addArbitraryFile(file);
- }
- }
- fileInput.value = '';
-}
-
-function onPaste(e) {
- for (const item of e.clipboardData?.items ?? []) {
- if (item.kind === 'file' && item.type.startsWith('image/') && item.type !== 'image/svg+xml') {
- e.preventDefault();
- addImageFile(item.getAsFile());
- }
- }
-}
-
-function clearAttachments() {
- pendingImages = [];
- pendingFiles = [];
- previewStrip.innerHTML = '';
-}
-
-function renderPreviewStrip() {
- previewStrip.innerHTML = '';
-
- // Image thumbnails
- pendingImages.forEach((dataUrl) => {
- const wrap = document.createElement('div');
- wrap.className = 'img-thumb-wrap';
-
- const img = document.createElement('img');
- img.src = dataUrl;
- img.className = 'img-thumb';
-
- const btn = document.createElement('button');
- btn.className = 'img-thumb-remove';
- btn.textContent = '×';
- btn.addEventListener('click', () => {
- pendingImages.splice(pendingImages.indexOf(dataUrl), 1);
- renderPreviewStrip();
- });
-
- wrap.append(img, btn);
- previewStrip.appendChild(wrap);
- });
-
- // File badges
- pendingFiles.forEach((info) => {
- const badge = document.createElement('div');
- badge.className = 'file-badge';
-
- const icon = document.createElement('span');
- icon.className = 'file-badge-icon';
- icon.textContent = fileIcon(info.content_type || info.contentType || '');
-
- const infoEl = document.createElement('div');
- infoEl.className = 'file-badge-info';
-
- const nameEl = document.createElement('div');
- nameEl.className = 'file-badge-name';
- nameEl.title = info.name;
- nameEl.textContent = info.name;
-
- const sizeEl = document.createElement('div');
- sizeEl.className = 'file-badge-size';
- sizeEl.textContent = formatBytes(info.size);
-
- infoEl.append(nameEl, sizeEl);
-
- const btn = document.createElement('button');
- btn.className = 'file-badge-remove';
- btn.textContent = '×';
- btn.addEventListener('click', () => {
- pendingFiles.splice(pendingFiles.indexOf(info), 1);
- renderPreviewStrip();
- updateInputUI();
- });
-
- badge.append(icon, infoEl, btn);
- previewStrip.appendChild(badge);
- });
-}
-
-function fileIcon(contentType) {
- if (contentType === 'image/svg+xml') return '🎨';
- if (contentType.startsWith('image/')) return '🖼️';
- if (contentType.startsWith('video/')) return '🎬';
- if (contentType.startsWith('audio/')) return '🎵';
- if (contentType.includes('pdf')) return '📄';
- if (contentType.includes('zip') || contentType.includes('tar') || contentType.includes('gz')) return '🗜️';
- if (contentType.includes('json') || contentType.includes('xml') || contentType.includes('text')) return '📝';
- return '📎';
-}
-
-function updateTokenCounter(used, max) {
- if (!used || !max) { tokenCounterEl.hidden = true; return; }
- const pct = Math.round((used / max) * 100);
- tokenCounterEl.textContent = `${used.toLocaleString()}/${max.toLocaleString()} (${pct}%) tokens`;
- tokenCounterEl.classList.toggle('warn', pct >= 50 && pct < 80);
- tokenCounterEl.classList.toggle('danger', pct >= 80);
- tokenCounterEl.hidden = false;
-}
-
-// ── Start ─────────────────────────────────────────────────────────────────────
-
-init();
diff --git a/old_webclient/js/chat.js b/old_webclient/js/chat.js
deleted file mode 100644
index 59baf41..0000000
--- a/old_webclient/js/chat.js
+++ /dev/null
@@ -1,526 +0,0 @@
-/** Chat area DOM helpers. */
-
-import { marked } from 'https://esm.sh/marked@12';
-import hljs from 'https://esm.sh/highlight.js@11';
-import { esc, timeLabel, formatBytes } from './utils.js';
-
-// ── Markdown setup ────────────────────────────────────────────────────────────
-
-marked.use({
- gfm: true,
- breaks: true,
-});
-
-function renderMarkdown(text) {
- const div = document.createElement('div');
- div.className = 'prose';
- div.innerHTML = marked.parse(text);
- div.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
- return div;
-}
-
-// ── Tool icons ────────────────────────────────────────────────────────────────
-
-const TOOL_ICONS = {
- web_search: '🔍',
- web_view: '🌍',
- filesystem: '📁',
- http_request: '🌐',
- code_exec: '⚙️',
- terminal: '⚡',
- ssh_exec: '🔌',
- image_view: '🖼️',
- spawn_agent: '🤖',
- memory_search: '🧠',
- memory_forget: '🗑️',
- write_tool: '✏️',
- reload_tools: '🔄',
- list_tools: '📋',
- tool_manual: '📖',
-};
-
-// ── Public API ────────────────────────────────────────────────────────────────
-
-/** File icon by extension/name fallback. */
-function fileIconByName(name) {
- const ext = (name.split('.').pop() ?? '').toLowerCase();
- if (['jpg','jpeg','png','gif','webp','bmp'].includes(ext)) return '🖼️';
- if (ext === 'svg') return '🎨';
- if (['mp4','mkv','avi','mov','webm'].includes(ext)) return '🎬';
- if (['mp3','wav','ogg','flac','m4a'].includes(ext)) return '🎵';
- if (ext === 'pdf') return '📄';
- if (['zip','tar','gz','bz2','7z','rar'].includes(ext)) return '🗜️';
- if (['json','xml','yaml','yml','toml','ini','env'].includes(ext)) return '⚙️';
- if (['js','ts','py','rs','go','java','c','cpp','h','rb','php','sh'].includes(ext)) return '💻';
- if (['md','txt','log','csv'].includes(ext)) return '📝';
- return '📎';
-}
-
-/** Parse and strip the [Uploaded files on disk:] block injected by the server. */
-function extractFileBlock(content) {
- if (!content) return { text: content, files: null };
- const match = content.match(/\n\n\[Uploaded files on disk:\n([\s\S]*?)\]$/);
- if (!match) return { text: content, files: null };
- const text = content.slice(0, content.length - match[0].length) || null;
- const files = match[1]
- .split('\n')
- .filter(l => l.startsWith('- '))
- .map(l => {
- const m = l.match(/^- (.+?) → (.+)$/);
- return m ? { name: m[1], path: m[2] } : null;
- })
- .filter(Boolean);
- return { text, files: files.length ? files : null };
-}
-
-/**
- * Append a complete message bubble (used for history and user messages).
- * Assistant messages are rendered as markdown; user messages as plain text.
- * Pass images (array of base64 strings) to render them in the bubble.
- * Pass files (array of {name, size?, path?, content_type?}) to render file badges.
- * Returns the bubble element.
- */
-export function appendMessage(el, role, content, images = null, timestamp = null, files = null) {
- const wrap = document.createElement('div');
- wrap.className = `msg ${role}`;
-
- const bubble = document.createElement('div');
- bubble.className = 'bubble';
-
- // For user messages: extract file block from content if files not provided directly
- let displayContent = content;
- let displayFiles = files;
- if (role === 'user') {
- const extracted = extractFileBlock(content);
- displayContent = extracted.text;
- if (!displayFiles && extracted.files) displayFiles = extracted.files;
- }
-
- if (images?.length) {
- const imgStrip = document.createElement('div');
- imgStrip.className = 'bubble-images';
- for (const b64 of images) {
- const img = document.createElement('img');
- img.src = b64.startsWith('data:') ? b64 : `data:image/jpeg;base64,${b64}`;
- img.className = 'bubble-img';
- img.alt = 'attached image';
- imgStrip.appendChild(img);
- }
- bubble.appendChild(imgStrip);
- }
-
- if (role === 'assistant') {
- bubble.appendChild(renderMarkdown(content));
- } else if (displayContent) {
- const text = document.createElement('span');
- text.textContent = displayContent;
- bubble.appendChild(text);
- }
-
- if (displayFiles?.length) {
- const strip = document.createElement('div');
- strip.className = 'bubble-files';
- for (const f of displayFiles) {
- const badge = document.createElement('div');
- badge.className = 'file-badge';
- const icon = document.createElement('span');
- icon.className = 'file-badge-icon';
- icon.textContent = fileIconByName(f.name);
- const info = document.createElement('div');
- info.className = 'file-badge-info';
- const nameEl = document.createElement('div');
- nameEl.className = 'file-badge-name';
- nameEl.title = f.name;
- nameEl.textContent = f.name;
- info.appendChild(nameEl);
- if (f.size) {
- const sizeEl = document.createElement('div');
- sizeEl.className = 'file-badge-size';
- sizeEl.textContent = formatBytes(f.size);
- info.appendChild(sizeEl);
- }
- badge.append(icon, info);
- strip.appendChild(badge);
- }
- bubble.appendChild(strip);
- }
-
- const time = document.createElement('div');
- time.className = 'msg-time';
- time.textContent = timeLabel(timestamp ?? new Date().toISOString());
-
- wrap.append(bubble, time);
- el.appendChild(wrap);
- return bubble;
-}
-
-/**
- * Called during streaming: bubble shows raw text with a cursor.
- * Returns the bubble element — caller appends deltas via textContent.
- */
-export function appendStreamBubble(el) {
- const wrap = document.createElement('div');
- wrap.className = 'msg assistant';
-
- const bubble = document.createElement('div');
- bubble.className = 'bubble cursor';
-
- const time = document.createElement('div');
- time.className = 'msg-time';
- time.textContent = 'just now';
-
- wrap.append(bubble, time);
- el.appendChild(wrap);
- return bubble;
-}
-
-/**
- * Called on stream_end: replaces raw text with rendered markdown.
- */
-export function finalizeStreamBubble(bubble, content) {
- bubble.classList.remove('cursor');
- bubble.textContent = '';
- bubble.appendChild(renderMarkdown(content));
-}
-
-/** Build an args grid element from a {key:val} object. Returns null if empty. */
-function buildArgsEl(args) {
- const entries = Object.entries(args ?? {});
- if (!entries.length) return null;
- const div = document.createElement('div');
- div.className = 'tool-args';
- div.innerHTML = entries
- .map(([k, v]) => `${esc(k)}${esc(JSON.stringify(v))}`)
- .join('');
- return div;
-}
-
-/**
- * Create a pending tool card (spinner, no result yet).
- * Returns the card element — pass to finalizeToolCard() when done.
- */
-export function appendPendingToolCard(el, event) {
- const icon = TOOL_ICONS[event.tool] ?? '🔧';
- const card = document.createElement('div');
- card.className = 'tool-card pending';
-
- const header = document.createElement('div');
- header.className = 'tool-header';
- header.innerHTML = `
- ${icon}
- ${esc(event.tool)}
- `;
-
- // For spawn_agent: open body immediately to show sub-agent log as it streams
- const body = document.createElement('div');
- body.className = event.tool === 'spawn_agent' ? 'tool-body tool-body-open' : 'tool-body';
-
- const argsEl = buildArgsEl(event.args);
- if (argsEl) body.appendChild(argsEl);
-
- if (event.tool === 'spawn_agent') {
- const log = document.createElement('div');
- log.className = 'subagent-log';
- body.appendChild(log);
- card._subagentLog = log;
- }
-
- card.append(header, body);
- el.appendChild(card);
- return card;
-}
-
-/**
- * Fill in a pending card with the completed result.
- */
-export function finalizeToolCard(card, event) {
- const success = event.success;
- card.classList.remove('pending');
- if (!success) card.classList.add('error');
-
- const statusEl = card.querySelector('.tool-status');
- if (statusEl) statusEl.innerHTML = success ? '✓' : '✗';
-
- const body = card.querySelector('.tool-body');
- if (body) {
- body.classList.remove('tool-body-open');
- // Strip the "[Sub-agent result — ...]" reminder prefix before showing to user
- const result = event.result.replace(/^\[Sub-agent result[^\]]*\]\n\n/, '');
- const pre = document.createElement('pre');
- pre.className = 'tool-result-pre';
- pre.textContent = result;
- body.appendChild(pre);
- }
-
- const header = card.querySelector('.tool-header');
- if (header) header.addEventListener('click', () => card.classList.toggle('open'));
-}
-
-/**
- * Append a pending sub-agent step inside a spawn_agent card.
- * Returns the step element — pass to finalizeSubagentStep() when done.
- */
-export function appendSubagentStep(card, event) {
- const log = card._subagentLog;
- if (!log) return null;
- const icon = TOOL_ICONS[event.tool] ?? '🔧';
- const step = document.createElement('div');
- step.className = 'subagent-step pending';
- step.innerHTML = `
- ↳
- ${icon}
- ${esc(event.tool)}
- `;
- log.appendChild(step);
- return step;
-}
-
-/**
- * Mark a sub-agent step as complete.
- */
-export function finalizeSubagentStep(step, event) {
- if (!step) return;
- step.classList.remove('pending');
- if (!event.success) step.classList.add('error');
- const statusEl = step.querySelector('.step-status');
- if (statusEl) statusEl.innerHTML = event.success ? '✓' : '✗';
-}
-
-/**
- * Tool call card from history — complete, collapsed by default.
- */
-export function appendToolCall(el, event) {
- const icon = TOOL_ICONS[event.tool] ?? '🔧';
- const success = event.success;
-
- const card = document.createElement('div');
- card.className = `tool-card${success ? '' : ' error'}`;
-
- const header = document.createElement('div');
- header.className = 'tool-header';
- header.innerHTML = `
- ${icon}
- ${esc(event.tool)}
- ${success ? '✓' : '✗'}`;
-
- const body = document.createElement('div');
- body.className = 'tool-body';
- const argsEl = buildArgsEl(event.args);
- if (argsEl) body.appendChild(argsEl);
- const pre = document.createElement('pre');
- pre.className = 'tool-result-pre';
- pre.textContent = event.result;
- body.appendChild(pre);
-
- header.addEventListener('click', () => card.classList.toggle('open'));
- card.append(header, body);
- el.appendChild(card);
-}
-
-/**
- * Create a thinking block (open by default, collapses on finalizeThinkingCard).
- * Returns {card, pre} — caller appends thinking text to pre.textContent.
- */
-export function appendThinkingCard(el) {
- const card = document.createElement('div');
- card.className = 'thinking-card open';
-
- const header = document.createElement('div');
- header.className = 'thinking-header';
- header.innerHTML = '💭Thinking…';
-
- const body = document.createElement('div');
- body.className = 'thinking-body';
-
- const pre = document.createElement('pre');
- pre.className = 'thinking-pre';
- body.appendChild(pre);
-
- header.addEventListener('click', () => card.classList.toggle('open'));
- card.append(header, body);
- el.appendChild(card);
- return { card, pre };
-}
-
-/**
- * Called on thinking_end: update label and collapse the card.
- */
-export function finalizeThinkingCard(card) {
- const label = card.querySelector('.thinking-label');
- if (label) label.textContent = 'Thought';
- card.classList.remove('open');
-}
-
-export function appendTypingIndicator(el) {
- removeTypingIndicator(el);
- const div = document.createElement('div');
- div.className = 'typing';
- div.id = 'typing-indicator';
- div.innerHTML = '';
- el.appendChild(div);
-}
-
-export function removeTypingIndicator(el) {
- el.querySelector('#typing-indicator')?.remove();
-}
-
-export function appendError(el, message) {
- const div = document.createElement('div');
- div.className = 'msg-error';
- div.textContent = `Error: ${message}`;
- el.appendChild(div);
-}
-
-export function showEmptyState(el) {
- el.innerHTML = `
-
-
💬
-
Start a new conversation
-
`;
-}
-
-/**
- * Summary card — rendered for is_summary messages loaded from history.
- * Collapsed by default, click to expand.
- */
-export function appendSummaryCard(el, content) {
- const text = content.replace(/^\[Context Summary\]\n?/, '');
- const card = document.createElement('div');
- card.className = 'summary-card';
-
- const header = document.createElement('div');
- header.className = 'summary-header';
- header.innerHTML = '📋Context Summary';
-
- const body = document.createElement('div');
- body.className = 'summary-body';
- body.appendChild(renderMarkdown(text));
-
- header.addEventListener('click', () => card.classList.toggle('open'));
- card.append(header, body);
- el.appendChild(card);
-}
-
-/**
- * Inline notice that compression ran — appended to the message list.
- */
-export function appendCompressionNotice(el, before, after, summary) {
- const label = (before != null && after != null)
- ? `↑ Context compressed: ${before} → ${after} messages`
- : '↑ Older messages summarized to free context space';
-
- if (summary) {
- const details = document.createElement('details');
- details.className = 'compression-notice compression-notice--expandable';
- const sumEl = document.createElement('summary');
- sumEl.textContent = label;
- details.appendChild(sumEl);
- const body = document.createElement('div');
- body.className = 'compression-summary-body';
- body.innerHTML = marked.parse(summary);
- details.appendChild(body);
- el.appendChild(details);
- } else {
- const div = document.createElement('div');
- div.className = 'compression-notice';
- div.textContent = label;
- el.appendChild(div);
- }
-}
-
-/**
- * Intermediate text the model emitted alongside tool calls (not the final response).
- * Displayed as a subtle note between tool cards.
- */
-export function appendAgentNote(el, text) {
- const div = document.createElement('div');
- div.className = 'agent-note';
- div.textContent = text;
- el.appendChild(div);
- return div;
-}
-
-/**
- * Sub-agent note — text emitted alongside tool calls inside run_ephemeral.
- * Rendered as a text line inside the spawn_agent card's subagent log.
- */
-export function appendSubagentNote(card, text) {
- const log = card?._subagentLog;
- if (!log) return;
- const div = document.createElement('div');
- div.className = 'subagent-note';
- div.textContent = text;
- log.appendChild(div);
-}
-
-/**
- * Plan card — shown before tool calls when planning_enabled is set on the profile.
- * Collapsed by default (plan is complete when received, not streaming).
- */
-export function appendPlanCard(el, plan) {
- const card = document.createElement('div');
- card.className = 'plan-card';
-
- const header = document.createElement('div');
- header.className = 'plan-header';
- header.innerHTML = '🗺️Plan';
-
- const body = document.createElement('div');
- body.className = 'plan-body';
- body.appendChild(renderMarkdown(plan));
-
- header.addEventListener('click', () => card.classList.toggle('open'));
- card.append(header, body);
- el.appendChild(card);
- return card;
-}
-
-/**
- * Thinking block from a tool-calling turn (complete() — full text, not streaming).
- * Rendered collapsed — content is already complete when received.
- */
-export function appendTurnThinkingCard(el, thinking) {
- const card = document.createElement('div');
- card.className = 'thinking-card'; // collapsed by default (no 'open')
-
- const header = document.createElement('div');
- header.className = 'thinking-header';
- header.innerHTML = '💭Thought';
-
- const body = document.createElement('div');
- body.className = 'thinking-body';
- const pre = document.createElement('pre');
- pre.className = 'thinking-pre';
- pre.textContent = thinking;
- body.appendChild(pre);
-
- header.addEventListener('click', () => card.classList.toggle('open'));
- card.append(header, body);
- el.appendChild(card);
- return card;
-}
-
-/**
- * Sub-agent thinking block — collapsible block inside the spawn_agent subagent log.
- */
-export function appendSubagentThinking(card, thinking) {
- const log = card?._subagentLog;
- if (!log) return;
-
- const block = document.createElement('details');
- block.className = 'subagent-thinking';
-
- const summary = document.createElement('summary');
- summary.textContent = '💭 Thought';
-
- const pre = document.createElement('pre');
- pre.className = 'subagent-thinking-pre';
- pre.textContent = thinking;
-
- block.append(summary, pre);
- log.appendChild(block);
-}
-
-export function scrollToBottom(el) {
- el.scrollTop = el.scrollHeight;
-}
diff --git a/old_webclient/js/sidebar.js b/old_webclient/js/sidebar.js
deleted file mode 100644
index f5a75d0..0000000
--- a/old_webclient/js/sidebar.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/** Sidebar DOM helpers. */
-
-import { esc, timeLabel } from './utils.js';
-
-export function renderProfiles(selectEl, profiles) {
- selectEl.innerHTML = profiles
- .map(p => ``)
- .join('');
-}
-
-export function renderSessions(listEl, sessions, currentId, { onSelect, onDelete, onPin }) {
- if (!sessions.length) {
- listEl.innerHTML = 'No conversations yet
';
- return;
- }
-
- listEl.innerHTML = sessions.map(s => {
- const active = s.session_id === currentId ? ' active' : '';
- const pinned = s.pinned ? ' pinned' : '';
- const preview = esc(s.preview || 'No messages yet');
- const time = timeLabel(s.last_active);
- const pinIcon = s.pinned ? '📌' : '📍';
- const pinTitle = s.pinned ? 'Unpin' : 'Pin';
- return `
-
-
-
${s.pinned ? '📌 ' : ''}${preview}
-
${time}
-
-
-
-
-
-
`;
- }).join('');
-
- listEl.querySelectorAll('.session-item').forEach(el =>
- el.addEventListener('click', () => onSelect(el.dataset.id))
- );
- listEl.querySelectorAll('.btn-pin').forEach(btn =>
- btn.addEventListener('click', e => {
- e.stopPropagation();
- onPin(btn.dataset.id, btn.dataset.pinned !== 'true');
- })
- );
- listEl.querySelectorAll('.btn-delete').forEach(btn =>
- btn.addEventListener('click', e => { e.stopPropagation(); onDelete(btn.dataset.id); })
- );
-}
-
-export function updateChatHeader(headerEl, profileId, profileName) {
- const titleEl = headerEl.querySelector('#chat-header-title');
- if (!titleEl) return;
- titleEl.innerHTML = profileId
- ? `${esc(profileId)} ${esc(profileName || profileId)}`
- : 'Select a profile and start a new chat';
-}
diff --git a/old_webclient/js/utils.js b/old_webclient/js/utils.js
deleted file mode 100644
index bb331bf..0000000
--- a/old_webclient/js/utils.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/** Shared utilities. */
-
-export function formatBytes(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
-}
-
-export function esc(str) {
- return String(str ?? '')
- .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-}
-
-export function timeLabel(iso) {
- if (!iso) return '';
- const d = new Date(iso);
- if (isNaN(d)) return '';
- const diff = Date.now() - d;
- if (diff < 60_000) return 'just now';
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
- if (diff < 86_400_000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- return d.toLocaleDateString();
-}
diff --git a/old_webclient/js/ws.js b/old_webclient/js/ws.js
deleted file mode 100644
index 7c5f72c..0000000
--- a/old_webclient/js/ws.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/** WebSocket client wrapper. */
-
-export class WsClient {
- #ws = null;
- #handlers = {};
-
- connect(sessionId, handlers) {
- this.disconnect();
- this.#handlers = handlers;
-
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
- this.#ws = new WebSocket(`${proto}://${location.host}/ws/sessions/${sessionId}`);
-
- this.#ws.onopen = () => handlers.onOpen?.();
- this.#ws.onclose = (e) => handlers.onClose?.(e);
- this.#ws.onerror = (e) => handlers.onError?.(e);
- this.#ws.onmessage = (e) => {
- const msg = JSON.parse(e.data);
- if (!['stream_delta', 'thinking_delta'].includes(msg.type)) {
- console.log('[ws]', msg.type, msg);
- }
- handlers.onMessage?.(msg);
- };
- }
-
- send(content, images = null, files = null) {
- if (this.#ws?.readyState === WebSocket.OPEN) {
- const payload = { type: 'message', content };
- if (images?.length) payload.images = images;
- if (files?.length) payload.files = files;
- this.#ws.send(JSON.stringify(payload));
- return true;
- }
- return false;
- }
-
- disconnect() {
- this.#ws?.close();
- this.#ws = null;
- }
-
- get ready() {
- return this.#ws?.readyState === WebSocket.OPEN;
- }
-}
diff --git a/old_webclient/style.css b/old_webclient/style.css
deleted file mode 100644
index 28b3499..0000000
--- a/old_webclient/style.css
+++ /dev/null
@@ -1,967 +0,0 @@
-*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
-
-:root {
- --sidebar-w: 300px;
- --bg: #111111;
- --sidebar-bg: #1a1a1a;
- --border: #2a2a2a;
- --text: #e0e0e0;
- --text-muted: #666;
- --accent: #3b82f6;
- --accent-hover: #2563eb;
- --user-bubble: #2563eb;
- --user-text: #ffffff;
- --bot-bubble: #212121;
- --bot-text: #e0e0e0;
- --tool-bg: #1f1a0e;
- --tool-border: #4a3800;
- --tool-text: #c9a227;
- --error-bg: #1f0e0e;
- --error-border: #5c1a1a;
- --error-text: #f87171;
- --thinking-bg: #111820;
- --thinking-border: #1e3a5f;
- --thinking-text: #6b9fd4;
- --thinking-pre-text: #5a8ab0;
- --plan-bg: #111a12;
- --plan-border: #1e4a20;
- --plan-text: #6db86f;
- --input-bg: #1e1e1e;
- --radius: 12px;
- --shadow: 0 1px 4px rgba(0,0,0,0.4);
-}
-
-html, body {
- height: 100%;
- overflow: hidden; /* prevent page-level scroll — only inner containers scroll */
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- font-size: 14px;
- color: var(--text);
- background: var(--bg);
-}
-
-/* ── Layout ─────────────────────────────────────────── */
-
-.app {
- display: flex;
- height: 100vh;
- height: 100dvh; /* dynamic viewport height: accounts for mobile browser chrome */
- overflow: hidden;
-}
-
-/* ── Sidebar ─────────────────────────────────────────── */
-
-.sidebar {
- width: var(--sidebar-w);
- min-width: var(--sidebar-w);
- background: var(--sidebar-bg);
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.sidebar-header {
- padding: 16px;
- border-bottom: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.sidebar-header h1 {
- font-size: 18px;
- font-weight: 700;
- letter-spacing: -0.3px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.sidebar-logo {
- height: 24px;
- width: auto;
- display: block;
-}
-
-.sidebar-header select {
- width: 100%;
- padding: 7px 10px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--bg);
- font-size: 13px;
- color: var(--text);
- cursor: pointer;
- outline: none;
-}
-.sidebar-header select:focus { border-color: var(--accent); }
-
-.btn-new {
- width: 100%;
- padding: 8px;
- background: var(--accent);
- color: #fff;
- border: none;
- border-radius: 8px;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- transition: background 0.15s;
-}
-.btn-new:hover { background: var(--accent-hover); }
-.btn-new:disabled { opacity: 0.5; cursor: not-allowed; }
-
-.session-list {
- flex: 1;
- overflow-y: auto;
- padding: 8px;
-}
-
-.session-item {
- padding: 10px 12px;
- border-radius: 8px;
- cursor: pointer;
- transition: background 0.1s;
- margin-bottom: 2px;
-}
-.session-item:hover { background: #222; }
-.session-item.active { background: #1a2540; }
-.session-item .s-profile { font-size: 11px; color: var(--accent); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 2px; }
-.session-item .s-preview {
- font-size: 13px;
- color: var(--text);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
- line-height: 1.4;
-}
-.session-item .s-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
-
-.session-item .s-body { flex: 1; min-width: 0; }
-.session-item { display: flex; align-items: flex-start; gap: 6px; }
-.session-item.pinned { border-left: 2px solid var(--accent); padding-left: 10px; }
-
-.s-actions {
- display: flex;
- gap: 2px;
- flex-shrink: 0;
- opacity: 0;
- transition: opacity 0.15s;
- padding-top: 2px; /* align buttons with first line of preview */
-}
-.session-item:hover .s-actions { opacity: 1; }
-
-.btn-pin, .btn-delete {
- width: 22px;
- height: 22px;
- background: none;
- border: none;
- border-radius: 5px;
- font-size: 13px;
- line-height: 1;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: background 0.15s;
- color: var(--text-muted);
-}
-.btn-pin:hover { background: #1a2540; }
-.btn-delete:hover { background: #3d1a1a; color: var(--error-text); }
-/* always show pin icon for pinned sessions */
-.session-item.pinned .s-actions { opacity: 1; }
-
-.empty-sessions { padding: 20px 12px; color: var(--text-muted); font-size: 13px; text-align: center; }
-
-/* ── Spinners ────────────────────────────────────────── */
-
-@keyframes spin { to { transform: rotate(360deg); } }
-
-.spinner {
- width: 20px;
- height: 20px;
- border: 2px solid var(--border);
- border-top-color: var(--accent);
- border-radius: 50%;
- animation: spin 0.7s linear infinite;
- flex-shrink: 0;
-}
-
-/* Small inline spinner — used inside tool card headers */
-.spinner-inline {
- display: inline-block;
- width: 11px;
- height: 11px;
- border: 2px solid rgba(255,255,255,0.2);
- border-top-color: var(--tool-text);
- border-radius: 50%;
- animation: spin 0.7s linear infinite;
- vertical-align: middle;
-}
-.tool-card.error .spinner-inline { border-top-color: var(--error-text); }
-
-/* Centred in the session list column */
-.sidebar-spinner {
- display: flex;
- justify-content: center;
- padding: 24px 0;
-}
-
-/* Centred in the chat messages area */
-.chat-spinner {
- display: flex;
- flex: 1;
- align-items: center;
- justify-content: center;
-}
-.chat-spinner .spinner { width: 28px; height: 28px; border-width: 3px; }
-
-/* ── Sub-agent step log (inside spawn_agent card) ────── */
-
-.subagent-log {
- display: flex;
- flex-direction: column;
- gap: 3px;
- border-top: 1px solid var(--tool-border);
- padding-top: 6px;
- margin-top: 2px;
-}
-
-.subagent-step {
- display: flex;
- align-items: center;
- gap: 5px;
- font-size: 11px;
- color: var(--tool-text);
- opacity: 0.85;
- padding: 2px 4px;
- border-radius: 4px;
-}
-.subagent-step.pending { opacity: 1; }
-.subagent-step.error { color: var(--error-text); }
-.subagent-step.done { opacity: 0.6; }
-
-.step-arrow { color: var(--text-muted); font-size: 10px; }
-.step-icon { font-size: 12px; }
-.step-name { flex: 1; }
-.step-status { font-size: 11px; }
-
-/* ── Main chat area ──────────────────────────────────── */
-
-.chat {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background: var(--bg);
-}
-
-.chat-header {
- padding: 14px 20px;
- background: var(--sidebar-bg);
- border-bottom: 1px solid var(--border);
- font-size: 14px;
- color: var(--text-muted);
- min-height: 49px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.token-counter {
- margin-left: auto;
- font-size: 11px;
- font-variant-numeric: tabular-nums;
- color: var(--text-muted);
- white-space: nowrap;
- transition: color 0.3s;
-}
-.token-counter.warn { color: #b8860b; }
-.token-counter.danger { color: #c0392b; }
-
-.chat-header .profile-badge {
- background: var(--accent);
- color: #fff;
- font-size: 11px;
- font-weight: 700;
- padding: 2px 8px;
- border-radius: 99px;
- text-transform: uppercase;
- letter-spacing: 0.4px;
-}
-
-.messages {
- flex: 1;
- overflow-y: auto;
- padding: 24px 20px;
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-/* ── Message bubbles ─────────────────────────────────── */
-
-.msg {
- display: flex;
- flex-direction: column;
- max-width: 72%;
-}
-.msg.user { align-self: flex-end; align-items: flex-end; }
-.msg.assistant { align-self: flex-start; align-items: flex-start; }
-
-.bubble {
- padding: 10px 14px;
- border-radius: var(--radius);
- line-height: 1.55;
- box-shadow: var(--shadow);
- word-break: break-word;
- /* no white-space: pre-wrap — markdown handles this */
-}
-.msg.user .bubble { background: var(--user-bubble); color: var(--user-text); border-bottom-right-radius: 3px; white-space: pre-wrap; }
-.msg.assistant .bubble { background: var(--bot-bubble); color: var(--bot-text); border-bottom-left-radius: 3px; }
-
-/* Images inside chat bubbles */
-.bubble-images {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 8px;
-}
-.bubble-img {
- max-width: 320px;
- max-height: 240px;
- border-radius: 6px;
- object-fit: contain;
- cursor: pointer;
-}
-.bubble-img:only-child { max-width: 100%; }
-
-.msg-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; padding: 0 2px; }
-
-/* ── Markdown prose ───────────────────────────────── */
-
-.prose { line-height: 1.6; }
-.prose p { margin: 0 0 0.6em; }
-.prose p:last-child { margin-bottom: 0; }
-.prose h1,.prose h2,.prose h3,.prose h4 { font-weight: 700; margin: 0.8em 0 0.3em; line-height: 1.25; }
-.prose h1 { font-size: 1.4em; } .prose h2 { font-size: 1.2em; } .prose h3 { font-size: 1.05em; }
-.prose ul,.prose ol { padding-left: 1.4em; margin: 0.4em 0; }
-.prose li { margin: 0.15em 0; }
-.prose code { font-family: "Fira Code", "Cascadia Code", ui-monospace, monospace; font-size: 0.85em;
- background: #2a2a2a; color: #e2b97e; padding: 1px 5px; border-radius: 4px; }
-.prose pre { margin: 0.6em 0; border-radius: 8px; overflow: hidden; }
-.prose pre code { background: none; color: inherit; padding: 0; border-radius: 0; font-size: 1em; }
-.prose pre .hljs { padding: 12px 16px; border-radius: 8px; font-size: 1em; }
-.prose blockquote { border-left: 3px solid #444; margin: 0.5em 0; padding: 0.2em 0 0.2em 0.8em; color: var(--text-muted); }
-.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.9em; }
-.prose th,.prose td { border: 1px solid #333; padding: 5px 10px; text-align: left; }
-.prose th { background: #222; }
-.prose a { color: #60a5fa; text-decoration: none; }
-.prose a:hover { text-decoration: underline; }
-.prose hr { border: none; border-top: 1px solid #333; margin: 0.8em 0; }
-.prose strong { font-weight: 700; }
-.prose em { font-style: italic; }
-
-/* ── Tool call card (accordion) ──────────────────── */
-
-.tool-card {
- align-self: flex-start;
- max-width: 84%;
- background: var(--tool-bg);
- border: 1px solid var(--tool-border);
- border-radius: var(--radius);
- font-size: 12px;
- color: var(--tool-text);
-}
-.tool-card.error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
-
-.tool-header {
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 8px 12px;
- cursor: pointer;
- user-select: none;
- font-weight: 600;
- border-radius: var(--radius);
-}
-.tool-header:hover { background: rgba(255,255,255,0.04); }
-.tool-icon { font-size: 14px; }
-.tool-name { flex: 1; }
-.tool-status { font-size: 13px; opacity: 0.8; }
-.tool-card:not(.open) .tool-header::after { content: '›'; font-size: 16px; opacity: 0.5; }
-.tool-card.open .tool-header::after { content: '‹'; font-size: 16px; opacity: 0.5; }
-
-.tool-body {
- border-top: 1px solid var(--tool-border);
- padding: 8px 12px;
- display: none;
- flex-direction: column;
- gap: 6px;
-}
-.tool-card.open .tool-body,
-.tool-card.pending .tool-body-open {
- display: flex;
- animation: fadeSlide 0.18s ease;
-}
-/* pending: no chevron toggle while running */
-.tool-card.pending .tool-header::after { content: ''; }
-@keyframes fadeSlide { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
-
-.tool-args {
- display: grid;
- grid-template-columns: max-content 1fr;
- gap: 2px 10px;
- font-size: 11px;
- opacity: 0.85;
-}
-.arg-key { color: var(--text-muted); font-style: italic; }
-.arg-val { word-break: break-all; }
-
-.tool-result-pre {
- margin: 0;
- padding: 8px 10px;
- background: rgba(0,0,0,0.25);
- border-radius: 6px;
- font-family: ui-monospace, monospace;
- font-size: 11px;
- line-height: 1.5;
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 260px;
- overflow-y: auto;
- color: var(--text);
-}
-
-/* ── Context summary card (compressed history) ───────── */
-
-.summary-card {
- align-self: flex-start;
- max-width: 84%;
- background: #161b22;
- border: 1px solid #30363d;
- border-radius: var(--radius);
- font-size: 12px;
- color: var(--text-muted);
-}
-
-.summary-header {
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 8px 12px;
- cursor: pointer;
- user-select: none;
- font-weight: 600;
- border-radius: var(--radius);
-}
-.summary-header:hover { background: rgba(255,255,255,0.03); }
-.summary-icon { font-size: 13px; }
-.summary-card:not(.open) .summary-header::after { content: '›'; font-size: 16px; opacity: 0.5; }
-.summary-card.open .summary-header::after { content: '‹'; font-size: 16px; opacity: 0.5; }
-
-.summary-body {
- border-top: 1px solid #30363d;
- padding: 8px 12px;
- display: none;
- color: var(--text-muted);
-}
-.summary-card.open .summary-body {
- display: block;
- animation: fadeSlide 0.18s ease;
-}
-.summary-body .prose { font-size: 12px; line-height: 1.55; }
-
-/* Compression notice — inline divider */
-.compression-notice {
- align-self: center;
- font-size: 11px;
- color: var(--text-muted);
- padding: 2px 12px;
- border-radius: 99px;
- border: 1px solid #30363d;
- background: #161b22;
- opacity: 0.7;
-}
-
-.compression-notice--expandable {
- border-radius: 8px;
- padding: 6px 12px;
- cursor: pointer;
- opacity: 0.85;
- max-width: 680px;
-}
-
-.compression-notice--expandable > summary {
- cursor: pointer;
- list-style: none;
- user-select: none;
-}
-
-.compression-notice--expandable > summary::before {
- content: '▶ ';
- font-size: 9px;
-}
-
-.compression-notice--expandable[open] > summary::before {
- content: '▼ ';
-}
-
-.compression-summary-body {
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px solid #30363d;
- font-size: 12px;
- line-height: 1.5;
- color: var(--text-secondary);
- white-space: normal;
-}
-
-/* ── Agent note — model text between tool calls ──────── */
-
-.agent-note {
- align-self: flex-start;
- max-width: 72%;
- font-size: 13px;
- font-style: italic;
- color: var(--text-muted);
- padding: 4px 10px;
- border-left: 2px solid var(--border);
- margin: 2px 0;
- white-space: pre-wrap;
- word-break: break-word;
- line-height: 1.5;
-}
-
-.subagent-note {
- font-size: 11px;
- font-style: italic;
- color: var(--text-muted);
- padding: 2px 4px 2px 16px;
- white-space: pre-wrap;
- word-break: break-word;
- line-height: 1.4;
-}
-
-.subagent-thinking {
- margin: 4px 0 4px 8px;
-}
-.subagent-thinking summary {
- font-size: 11px;
- color: var(--text-muted);
- cursor: pointer;
- user-select: none;
- list-style: none;
- padding: 2px 0;
-}
-.subagent-thinking summary::-webkit-details-marker { display: none; }
-.subagent-thinking-pre {
- font-size: 11px;
- color: var(--text-muted);
- font-style: italic;
- white-space: pre-wrap;
- word-break: break-word;
- margin: 4px 0 0 0;
- padding-left: 12px;
- border-left: 2px solid var(--border);
- line-height: 1.4;
-}
-
-/* ── Thinking card ───────────────────────────────────── */
-
-.thinking-card {
- align-self: flex-start;
- max-width: 84%;
- background: var(--thinking-bg);
- border: 1px solid var(--thinking-border);
- border-radius: var(--radius);
- font-size: 12px;
- color: var(--thinking-text);
-}
-
-.thinking-header {
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 8px 12px;
- cursor: pointer;
- user-select: none;
- font-weight: 600;
- border-radius: var(--radius);
-}
-.thinking-header:hover { background: rgba(255,255,255,0.03); }
-.thinking-icon { font-size: 14px; }
-.thinking-label { flex: 1; }
-.thinking-card:not(.open) .thinking-header::after { content: '›'; font-size: 16px; opacity: 0.5; }
-.thinking-card.open .thinking-header::after { content: '‹'; font-size: 16px; opacity: 0.5; }
-
-.thinking-body {
- border-top: 1px solid var(--thinking-border);
- padding: 8px 12px;
- display: none;
-}
-.thinking-card.open .thinking-body {
- display: block;
- animation: fadeSlide 0.18s ease;
-}
-
-.thinking-pre {
- margin: 0;
- font-family: ui-monospace, monospace;
- font-size: 11px;
- line-height: 1.55;
- white-space: pre-wrap;
- word-break: break-word;
- max-height: 320px;
- overflow-y: auto;
- color: var(--thinking-pre-text);
-}
-
-/* ── Plan card ───────────────────────────────────────── */
-
-.plan-card {
- align-self: flex-start;
- max-width: 84%;
- background: var(--plan-bg);
- border: 1px solid var(--plan-border);
- border-radius: var(--radius);
- font-size: 12px;
- color: var(--plan-text);
-}
-
-.plan-header {
- display: flex;
- align-items: center;
- gap: 7px;
- padding: 8px 12px;
- cursor: pointer;
- user-select: none;
- font-weight: 600;
- border-radius: var(--radius);
-}
-.plan-header:hover { background: rgba(255,255,255,0.03); }
-.plan-icon { font-size: 14px; }
-.plan-label { flex: 1; }
-.plan-card:not(.open) .plan-header::after { content: '›'; font-size: 16px; opacity: 0.5; }
-.plan-card.open .plan-header::after { content: '‹'; font-size: 16px; opacity: 0.5; }
-
-.plan-body {
- border-top: 1px solid var(--plan-border);
- padding: 10px 14px;
- display: none;
-}
-.plan-card.open .plan-body {
- display: block;
- animation: fadeSlide 0.18s ease;
-}
-.plan-body .prose { color: var(--plan-text); font-size: 12px; }
-.plan-body .prose ol,
-.plan-body .prose ul { padding-left: 1.4em; }
-.plan-body .prose li { margin: 3px 0; }
-.plan-body .prose p { margin: 4px 0; }
-
-/* Typing indicator */
-.typing {
- align-self: flex-start;
- display: flex;
- align-items: center;
- gap: 5px;
- padding: 10px 14px;
- background: var(--bot-bubble);
- border-radius: var(--radius);
- border-bottom-left-radius: 3px;
- box-shadow: var(--shadow);
-}
-.typing span { width: 7px; height: 7px; background: var(--text-muted); border-radius: 50%; animation: blink 1.2s infinite; }
-.typing span:nth-child(2) { animation-delay: 0.2s; }
-.typing span:nth-child(3) { animation-delay: 0.4s; }
-@keyframes blink { 0%,80%,100% { opacity: 0.2; } 40% { opacity: 1; } }
-
-/* Cursor while streaming */
-.cursor::after { content: "▋"; animation: cursor-blink 0.7s step-start infinite; font-size: 0.9em; margin-left: 1px; }
-@keyframes cursor-blink { 50% { opacity: 0; } }
-
-/* Error message */
-.msg-error {
- align-self: center;
- background: var(--error-bg);
- border: 1px solid var(--error-border);
- color: var(--error-text);
- padding: 8px 16px;
- border-radius: 8px;
- font-size: 13px;
-}
-
-/* Empty state */
-.empty-chat {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- color: var(--text-muted);
- gap: 8px;
-}
-.empty-chat .icon { font-size: 40px; }
-.empty-chat p { font-size: 15px; }
-
-/* ── Input bar ───────────────────────────────────────── */
-
-.input-bar {
- padding: 12px 20px 16px;
- background: var(--sidebar-bg);
- border-top: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-/* Image preview strip */
-.image-preview-strip {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-.image-preview-strip:empty { display: none; }
-
-.img-thumb-wrap {
- position: relative;
- width: 72px;
- height: 72px;
-}
-.img-thumb {
- width: 72px;
- height: 72px;
- object-fit: cover;
- border-radius: 6px;
- border: 1px solid var(--border);
-}
-.img-thumb-remove {
- position: absolute;
- top: -6px;
- right: -6px;
- width: 18px;
- height: 18px;
- border-radius: 50%;
- border: none;
- background: var(--error-text);
- color: #fff;
- font-size: 12px;
- line-height: 1;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
-}
-
-/* File badges (non-image uploads) */
-.file-badge {
- display: flex;
- align-items: center;
- gap: 6px;
- background: var(--bot-bubble);
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 6px 10px;
- max-width: 180px;
- cursor: default;
-}
-.file-badge-icon { font-size: 18px; flex-shrink: 0; }
-.file-badge-info { overflow: hidden; }
-.file-badge-name {
- font-size: 12px;
- font-weight: 500;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- color: var(--text);
-}
-.file-badge-size { font-size: 11px; color: var(--text-muted); }
-.file-badge-remove {
- margin-left: auto;
- flex-shrink: 0;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- border: none;
- background: transparent;
- color: var(--text-muted);
- font-size: 14px;
- line-height: 1;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
-}
-.file-badge-remove:hover { color: var(--error-text); }
-
-/* Upload progress bar */
-.upload-progress-bar {
- position: relative;
- height: 4px;
- background: var(--border);
- border-radius: 2px;
- overflow: hidden;
-}
-.upload-progress-fill {
- height: 100%;
- background: var(--accent);
- border-radius: 2px;
- transition: width 0.1s linear;
- width: 0%;
-}
-.upload-progress-label {
- position: absolute;
- right: 0;
- top: 6px;
- font-size: 10px;
- color: var(--text-muted);
-}
-
-/* Input row: attach + textarea + send */
-.input-row {
- display: flex;
- gap: 10px;
- align-items: flex-end;
-}
-
-.btn-attach {
- width: 44px;
- height: 44px;
- flex-shrink: 0;
- background: var(--input-bg);
- color: var(--text-muted);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font-size: 18px;
- cursor: pointer;
- transition: border-color 0.15s, color 0.15s;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.btn-attach:hover { border-color: var(--accent); color: var(--accent); }
-.btn-attach:disabled { opacity: 0.5; cursor: not-allowed; }
-
-.input-bar textarea {
- flex: 1;
- padding: 10px 14px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- background: var(--input-bg);
- font-family: inherit;
- font-size: 14px;
- color: var(--text);
- resize: none;
- min-height: 44px;
- max-height: 180px;
- overflow-y: auto;
- outline: none;
- transition: border-color 0.15s;
- line-height: 1.5;
-}
-.input-bar textarea:focus { border-color: var(--accent); }
-.input-bar textarea:disabled { opacity: 0.5; }
-
-.btn-send {
- width: 44px;
- height: 44px;
- flex-shrink: 0;
- background: var(--accent);
- color: #fff;
- border: none;
- border-radius: var(--radius);
- font-size: 18px;
- cursor: pointer;
- transition: background 0.15s;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.btn-send:hover { background: var(--accent-hover); }
-.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
-.btn-send.stop-mode { background: #c0392b; font-size: 14px; }
-.btn-send.stop-mode:hover { background: #a93226; }
-.btn-send.stop-mode:disabled { background: #c0392b; opacity: 0.5; }
-
-/* ── Scrollbar ───────────────────────────────────────── */
-::-webkit-scrollbar { width: 5px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb { background: #333; border-radius: 99px; }
-
-/* ── Sidebar toggle button (hidden on desktop) ───────── */
-
-.btn-sidebar-toggle {
- display: none;
- width: 36px;
- height: 36px;
- flex-shrink: 0;
- background: none;
- border: 1px solid var(--border);
- border-radius: 8px;
- color: var(--text-muted);
- font-size: 18px;
- cursor: pointer;
- align-items: center;
- justify-content: center;
- transition: background 0.15s, color 0.15s;
-}
-.btn-sidebar-toggle:hover { background: #222; color: var(--text); }
-
-/* ── Sidebar backdrop (mobile overlay) ───────────────── */
-
-.sidebar-backdrop {
- display: none;
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.55);
- z-index: 99;
- opacity: 0;
- transition: opacity 0.25s ease;
-}
-.sidebar-backdrop.visible {
- display: block;
- opacity: 1;
-}
-
-/* ── Responsive breakpoint ───────────────────────────── */
-
-@media (max-width: 768px) {
- /* Sidebar becomes a fixed drawer */
- .sidebar {
- position: fixed;
- left: 0;
- top: 0;
- height: 100vh;
- height: 100dvh;
- z-index: 100;
- transform: translateX(-100%);
- transition: transform 0.25s ease;
- box-shadow: 4px 0 24px rgba(0, 0, 0, 0.5);
- }
- .sidebar.open { transform: translateX(0); }
-
- /* Hamburger button visible */
- .btn-sidebar-toggle { display: flex; }
-
- /* Chat fills the full width */
- .chat { width: 100%; }
-
- /* Wider bubbles on small screens */
- .msg { max-width: 90%; }
- .tool-card,
- .thinking-card,
- .plan-card,
- .summary-card { max-width: 96%; }
-
- /* Tighter message padding */
- .messages { padding: 16px 12px; gap: 12px; }
-
- /* Input bar tighter */
- .input-bar { padding: 10px 12px 12px; }
-
- /* Chat header smaller */
- .chat-header { padding: 10px 14px; min-height: 44px; }
-}