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

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 - - - - - - -
- - - - - -
-
- - Select a profile and start a new chat - -
- -
-
-
💬
-

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; } -}