diff --git a/server/inbox/accounts.py b/server/inbox/accounts.py index c729fa0..9b6cbb5 100644 --- a/server/inbox/accounts.py +++ b/server/inbox/accounts.py @@ -28,13 +28,13 @@ async def list_accounts(user_id: str | None = None) -> list[dict]: pool = await get_pool() if user_id is None: rows = await pool.fetch( - "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" + "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " ORDER BY ea.created_at" ) else: rows = await pool.fetch( - "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" + "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " WHERE ea.user_id = $1 ORDER BY ea.created_at", user_id, @@ -46,7 +46,7 @@ async def list_accounts_enabled() -> list[dict]: """Return all enabled accounts (used by listener on startup).""" pool = await get_pool() rows = await pool.fetch( - "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" + "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " WHERE ea.enabled = TRUE ORDER BY ea.created_at" ) @@ -56,7 +56,7 @@ async def list_accounts_enabled() -> list[dict]: async def get_account(account_id: str) -> dict | None: pool = await get_pool() row = await pool.fetchrow( - "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" + "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea" " LEFT JOIN agents a ON a.id = ea.agent_id" " WHERE ea.id = $1", account_id, diff --git a/server/inbox/listener.py b/server/inbox/listener.py index 1262e3a..12b9bbd 100644 --- a/server/inbox/listener.py +++ b/server/inbox/listener.py @@ -496,8 +496,10 @@ class EmailAccountListener: f"- Memory file: {mem_path}\n" f"- Reasoning log: {log_path}\n" f"Read the memory file before acting. " - f"Append a reasoning entry to the reasoning log for each email you act on. " - f"If either file doesn't exist yet, create it with an appropriate template." + f"Append a brief reasoning entry to the reasoning log for each email you act on. " + f"If either file doesn't exist yet, create it with an appropriate template. " + f"If the memory file exceeds 120 lines, summarise it in-place (condense key facts, " + f"drop old entries) before appending — keep it concise to reduce token costs." ) await agent_runner.run_agent_and_wait( diff --git a/server/main.py b/server/main.py index 3668fac..e01295f 100644 --- a/server/main.py +++ b/server/main.py @@ -55,7 +55,7 @@ logging.basicConfig( logging.getLogger("server.tools.caldav_tool").setLevel(logging.DEBUG) from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -412,6 +412,13 @@ app.add_middleware(_AuthMiddleware) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static") app.include_router(api_router, prefix="/api") +_STATIC_DIR = BASE_DIR / "web" / "static" + + +@app.get("/service-worker.js", include_in_schema=False) +async def _service_worker(): + return FileResponse(_STATIC_DIR / "service-worker.js", media_type="application/javascript") + # 2nd Brain MCP server — mounted at /brain-mcp (SSE transport) app.mount("/brain-mcp", create_mcp_app()) diff --git a/server/providers/anthropic_provider.py b/server/providers/anthropic_provider.py index 09e5575..6cfeb59 100644 --- a/server/providers/anthropic_provider.py +++ b/server/providers/anthropic_provider.py @@ -83,7 +83,12 @@ class AnthropicProvider(AIProvider): "max_tokens": max_tokens, } if system: - params["system"] = system + # Wrap in a content block with cache_control so the system prompt is + # cached across repeated calls (e.g. email handling runs). Cached tokens + # cost ~10% of normal input price after the first write. + params["system"] = [ + {"type": "text", "text": system, "cache_control": {"type": "ephemeral"}} + ] if tools: # aide tool schemas ARE Anthropic format — pass through directly params["tools"] = tools @@ -163,10 +168,17 @@ class AnthropicProvider(AIProvider): arguments=block.input, )) - usage = UsageStats( - input_tokens=response.usage.input_tokens, - output_tokens=response.usage.output_tokens, - ) if response.usage else UsageStats() + if response.usage: + cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0 + cache_write = getattr(response.usage, "cache_creation_input_tokens", 0) or 0 + usage = UsageStats( + input_tokens=response.usage.input_tokens, + output_tokens=response.usage.output_tokens, + cache_read_tokens=cache_read, + cache_write_tokens=cache_write, + ) + else: + usage = UsageStats() finish_reason = response.stop_reason or "stop" if tool_calls: diff --git a/server/providers/base.py b/server/providers/base.py index 10c4875..31e498c 100644 --- a/server/providers/base.py +++ b/server/providers/base.py @@ -24,6 +24,8 @@ class ToolCallResult: class UsageStats: input_tokens: int = 0 output_tokens: int = 0 + cache_read_tokens: int = 0 # Anthropic: tokens served from cache (billed at ~10%) + cache_write_tokens: int = 0 # Anthropic: tokens written to cache (billed at ~125%) @property def total_tokens(self) -> int: diff --git a/server/web/routes.py b/server/web/routes.py index 3b5e0e5..7f935df 100644 --- a/server/web/routes.py +++ b/server/web/routes.py @@ -2585,6 +2585,14 @@ async def create_my_email_account(request: Request): agent_model = body.get("agent_model", "").strip() agent_prompt = body.get("agent_prompt", "").strip() + agent_max_tool_calls = body.get("agent_max_tool_calls") + if agent_max_tool_calls is not None: + try: + agent_max_tool_calls = int(agent_max_tool_calls) + except (TypeError, ValueError): + agent_max_tool_calls = None + agent_prompt_mode = body.get("agent_prompt_mode") or "combined" + # Auto-create a dedicated agent for this account agent = await create_agent( name=f"Email Handler: {label}", @@ -2593,6 +2601,8 @@ async def create_my_email_account(request: Request): allowed_tools=["email"], created_by="user", owner_user_id=user.id, + max_tool_calls=agent_max_tool_calls, + prompt_mode=agent_prompt_mode, ) agent_id = agent["id"] @@ -2629,7 +2639,7 @@ async def update_my_email_account(request: Request, account_id: str): raise HTTPException(status_code=404, detail="Account not found") body = await request.json() - # Update the linked agent's model and prompt if provided + # Update the linked agent's model, prompt, and cost-control fields if provided if acct.get("agent_id"): agent_fields = {} if "agent_model" in body: @@ -2638,6 +2648,11 @@ async def update_my_email_account(request: Request, account_id: str): agent_fields["prompt"] = body["agent_prompt"] if "label" in body: agent_fields["name"] = f"Email Handler: {body['label']}" + if "agent_max_tool_calls" in body: + v = body["agent_max_tool_calls"] + agent_fields["max_tool_calls"] = int(v) if v is not None else None + if "agent_prompt_mode" in body: + agent_fields["prompt_mode"] = body["agent_prompt_mode"] or "combined" if agent_fields: await update_agent(acct["agent_id"], **agent_fields) diff --git a/server/web/static/app.js b/server/web/static/app.js index ae97bf4..a424c8a 100644 --- a/server/web/static/app.js +++ b/server/web/static/app.js @@ -140,6 +140,53 @@ let _skipNextRestore = false; // true when snapshot was just restored — skip let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } } +/* ── PWA install ─────────────────────────────────────────────────────────── */ +let _deferredInstallPrompt = null; + +window.addEventListener("beforeinstallprompt", e => { + e.preventDefault(); + _deferredInstallPrompt = e; + const btn = document.getElementById("pwa-install-btn"); + if (btn) btn.style.display = "flex"; +}); + +function handleInstall() { + const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent); + const isStandalone = window.navigator.standalone === true; + if (isStandalone) return; + + if (_deferredInstallPrompt) { + _deferredInstallPrompt.prompt(); + _deferredInstallPrompt.userChoice.then(() => { _deferredInstallPrompt = null; }); + } else if (isIOS) { + alert("To add to your Home Screen:\n1. Tap the Share button (\u25a1\u2191) in Safari's toolbar\n2. Tap \"Add to Home Screen\""); + } +} + +(function _initPWA() { + const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent); + const isStandalone = window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches; + if (isIOS && !isStandalone) { + const btn = document.getElementById("pwa-install-btn"); + if (btn) btn.style.display = "flex"; + } +})(); + +function toggleSidebar() { + const sidebar = document.getElementById("sidebar"); + const overlay = document.getElementById("sidebar-overlay"); + if (!sidebar) return; + const isOpen = sidebar.classList.toggle("open"); + overlay && overlay.classList.toggle("visible", isOpen); +} + +function closeSidebar() { + const sidebar = document.getElementById("sidebar"); + const overlay = document.getElementById("sidebar-overlay"); + sidebar && sidebar.classList.remove("open"); + overlay && overlay.classList.remove("visible"); +} + function initNav() { _setActiveNav(location.pathname); @@ -149,6 +196,7 @@ function initNav() { const href = el.getAttribute("href"); if (!href || href.startsWith("http")) return; // external links: normal e.preventDefault(); + closeSidebar(); navigateTo(href); }); }); @@ -344,6 +392,10 @@ async function fileBrowserNavigate(path) { nameTd.style.cssText = "cursor:pointer;color:var(--accent)"; nameTd.textContent = entry.name; nameTd.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(entry.path); + } else if (_fbIsTextFile(entry.name)) { + nameTd.style.cssText = "cursor:pointer;color:var(--accent)"; + nameTd.textContent = entry.name; + nameTd.onclick = (function(p, n) { return function() { fileBrowserViewFile(p, n); }; })(entry.path, entry.name); } else { nameTd.textContent = entry.name; } @@ -359,16 +411,6 @@ async function fileBrowserNavigate(path) { const actionTd = document.createElement("td"); actionTd.style.cssText = "text-align:right;display:flex;justify-content:flex-end;gap:6px;align-items:center"; - // View button — text files only - if (!entry.is_dir && _fbIsTextFile(entry.name)) { - const viewBtn = document.createElement("button"); - viewBtn.className = "btn btn-ghost btn-small"; - viewBtn.title = "View file"; - viewBtn.textContent = "View"; - viewBtn.onclick = (function(p, n) { return function(e) { e.stopPropagation(); fileBrowserViewFile(p, n); }; })(entry.path, entry.name); - actionTd.appendChild(viewBtn); - } - const btn = document.createElement("button"); btn.className = "btn btn-ghost btn-small"; if (entry.is_dir) { @@ -785,7 +827,10 @@ function openModelPicker() { _mpFocusIdx = -1; modal.classList.remove("hidden"); const s = document.getElementById("model-picker-search"); - if (s) { s.value = ""; s.focus(); } + if (s) { + s.value = ""; + if (window.innerWidth > 768) s.focus(); + } _renderModelPicker(""); } @@ -1548,6 +1593,45 @@ function closeAuditDetail() { SETTINGS PAGE ══════════════════════════════════════════════════════════════════════════ */ +/* ── Mobile settings tab select ──────────────────────────────────────────── */ +function _initMobileTabSelect(switchFn) { + if (window.innerWidth > 768) return; + const bar = document.querySelector(".tab-bar"); + if (!bar || bar.dataset.selectBuilt) return; + bar.dataset.selectBuilt = "1"; + + const buttons = [...bar.querySelectorAll(".tab-btn")]; + if (!buttons.length) return; + + const wrap = document.createElement("div"); + wrap.className = "tab-select-wrap"; + + const sel = document.createElement("select"); + sel.className = "tab-select"; + buttons.forEach(btn => { + const opt = document.createElement("option"); + opt.value = btn.id.replace(/^[us]tab-|^stab-/, ""); + opt.textContent = btn.textContent.trim(); + if (btn.classList.contains("active")) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener("change", () => switchFn(sel.value)); + + const arrow = document.createElement("span"); + arrow.className = "tab-select-arrow"; + arrow.textContent = "▾"; + + wrap.appendChild(sel); + wrap.appendChild(arrow); + bar.parentNode.insertBefore(wrap, bar); + bar.style.display = "none"; +} + +function _syncTabSelect(name) { + const sel = document.querySelector(".tab-select"); + if (sel) sel.value = name; +} + function switchSettingsTab(name) { ["general", "whitelists", "credentials", "caldav", "pushover", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "webhooks", "mfa"].forEach(t => { const pane = document.getElementById(`spane-${t}`); @@ -1555,6 +1639,7 @@ function switchSettingsTab(name) { if (pane) pane.style.display = t === name ? "" : "none"; if (btn) btn.classList.toggle("active", t === name); }); + _syncTabSelect(name); if (name === "inbox") { loadInboxStatus(); } if (name === "emailaccounts") { loadEmailAccounts(); } if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); } @@ -1698,6 +1783,7 @@ function switchUserTab(name) { const pane = document.getElementById("uspane-" + name); if (tab) tab.classList.add("active"); if (pane) pane.style.display = ""; + _syncTabSelect(name); if (name === "mcp") loadMyMcpServers(); if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); } if (name === "emailaccounts") { loadEmailAccounts(); } @@ -2187,6 +2273,10 @@ function openEmailAccountModal(id) { document.getElementById("eam-folders-display").textContent = (acct?.monitored_folders || ["INBOX"]).join(", "); document.getElementById("eam-folders-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]); document.getElementById("eam-agent-prompt").value = acct?.agent_prompt || ""; + const mtc = document.getElementById("eam-max-tool-calls"); + if (mtc) mtc.value = acct?.agent_max_tool_calls != null ? acct.agent_max_tool_calls : ""; + const pm = document.getElementById("eam-prompt-mode"); + if (pm) pm.value = acct?.agent_prompt_mode || "combined"; _populateEamModelSelect(acct?.agent_model); _loadEamExtraTools(acct?.extra_tools || [], acct?.telegram_chat_id || "", acct?.telegram_keyword || ""); @@ -2347,6 +2437,9 @@ async function saveEmailAccount() { const imap_password = document.getElementById("eam-imap-password").value; const agent_model = document.getElementById("eam-agent-model").value; const agent_prompt = document.getElementById("eam-agent-prompt").value.trim(); + const _mtcRaw = document.getElementById("eam-max-tool-calls")?.value; + const agent_max_tool_calls = _mtcRaw ? parseInt(_mtcRaw) : null; + const agent_prompt_mode = document.getElementById("eam-prompt-mode")?.value || "combined"; const initial_load_limit = parseInt(document.getElementById("eam-initial-load-limit").value || "200"); const monitored_folders = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]'); const extra_tools = Array.from(document.querySelectorAll("#eam-extra-tools-area input[type=checkbox]:checked")).map(c => c.value); @@ -2364,7 +2457,8 @@ async function saveEmailAccount() { if (extra_tools.includes("telegram") && !telegram_keyword) { showFlash("Please enter a reply keyword for Telegram (e.g. 'work')."); return; } const payload = { label, imap_host, imap_port, imap_username, imap_password, - agent_model, agent_prompt, initial_load_limit, monitored_folders, extra_tools, + agent_model, agent_prompt, agent_max_tool_calls, agent_prompt_mode, + initial_load_limit, monitored_folders, extra_tools, telegram_chat_id, telegram_keyword }; const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts"; @@ -2668,6 +2762,7 @@ function togglePasswordVisibility(inputId, btn) { } function initUserSettings() { + _initMobileTabSelect(switchUserTab); const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys"; switchUserTab(tab); loadMyProviderKeys(); @@ -2688,6 +2783,7 @@ function initSettings() { } if (!document.getElementById("limits-form")) return; + _initMobileTabSelect(switchSettingsTab); loadApiKeyStatus(); loadProviderKeys(); loadUsersBaseFolder(); @@ -4848,7 +4944,46 @@ async function editCurrentAgent() { if (!agentId) return; const r = await fetch(`/api/agents/${agentId}`); const agent = await r.json(); - showAgentModal(agent); + openPromptEditor(agent.name, agent.prompt); +} + +function openPromptEditor(name, prompt) { + const overlay = document.getElementById("prompt-editor-overlay"); + if (!overlay) return; + document.getElementById("pe-agent-name").textContent = name || ""; + const ta = document.getElementById("pe-textarea"); + ta.value = prompt || ""; + overlay.style.display = "flex"; + ta.focus(); + // Place cursor at start + ta.setSelectionRange(0, 0); + ta.scrollTop = 0; +} + +function closePromptEditor() { + const overlay = document.getElementById("prompt-editor-overlay"); + if (overlay) overlay.style.display = "none"; +} + +async function savePromptEditor() { + const agentId = window.AGENT_ID; + if (!agentId) return; + const prompt = document.getElementById("pe-textarea").value; + const btn = document.getElementById("pe-save-btn"); + if (btn) { btn.disabled = true; btn.textContent = "Saving…"; } + const r = await fetch(`/api/agents/${agentId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + if (btn) { btn.disabled = false; btn.textContent = "Save"; } + if (r.ok) { + closePromptEditor(); + loadAgentDetail(); + } else { + const d = await r.json().catch(() => ({})); + alert("Save failed: " + (d.detail || r.statusText)); + } } async function runCurrentAgent() { @@ -4876,6 +5011,13 @@ async function saveAgentAndReload() { function initAgentDetail() { if (!document.getElementById("agent-detail-container")) return; _loadAvailableModels().then(() => loadAgentDetail()); + // Keyboard shortcuts for the prompt editor + document.addEventListener("keydown", function _detailKeys(e) { + const overlay = document.getElementById("prompt-editor-overlay"); + if (!overlay || overlay.style.display === "none") return; + if (e.key === "Escape") { e.preventDefault(); closePromptEditor(); } + if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); savePromptEditor(); } + }); } async function loadAgentDetail() { @@ -5257,6 +5399,23 @@ function initHelp() { // Disconnect any previous observer (SPA re-navigation) if (_helpObserver) { _helpObserver.disconnect(); _helpObserver = null; } + // Mobile: add collapsible TOC toggle + if (window.innerWidth <= 768) { + const toc = document.querySelector(".help-toc"); + if (toc && !toc.querySelector(".toc-toggle")) { + toc.classList.add("toc-collapsed"); + const btn = document.createElement("button"); + btn.className = "toc-toggle"; + btn.innerHTML = "Contents ▾"; + btn.addEventListener("click", () => { + toc.classList.toggle("toc-collapsed"); + btn.querySelector(".toc-arrow").textContent = + toc.classList.contains("toc-collapsed") ? "▾" : "▴"; + }); + toc.insertBefore(btn, toc.firstChild); + } + } + const tocLinks = document.querySelectorAll(".toc-list a[href^='#']"); const sections = document.querySelectorAll("section[data-section]"); @@ -5279,6 +5438,15 @@ function initHelp() { e.preventDefault(); const target = document.querySelector(a.getAttribute("href")); if (target) target.scrollIntoView({ behavior: "smooth" }); + // On mobile: collapse TOC after selecting a section + if (window.innerWidth <= 768) { + const toc = document.querySelector(".help-toc"); + if (toc) { + toc.classList.add("toc-collapsed"); + const arrow = toc.querySelector(".toc-arrow"); + if (arrow) arrow.textContent = "▾"; + } + } }); }); } diff --git a/server/web/static/manifest.json b/server/web/static/manifest.json new file mode 100644 index 0000000..26e8da4 --- /dev/null +++ b/server/web/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "oAI-Web", + "short_name": "Jarvis", + "description": "Personal AI Agent", + "start_url": "/", + "display": "standalone", + "background_color": "#0f1117", + "theme_color": "#0f1117", + "icons": [ + { + "src": "/static/icon.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icon.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/server/web/static/service-worker.js b/server/web/static/service-worker.js new file mode 100644 index 0000000..db51726 --- /dev/null +++ b/server/web/static/service-worker.js @@ -0,0 +1,36 @@ +const CACHE = 'jarvis-v1'; +const SHELL = [ + '/static/style.css', + '/static/app.js', + '/static/icon.png', + '/static/logo.png', +]; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys() + .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', e => { + const url = new URL(e.request.url); + if (url.pathname.startsWith('/static/')) { + // Cache-first for static assets + e.respondWith( + caches.match(e.request).then(r => r || fetch(e.request)) + ); + } else { + // Network-first for everything else (pages, API) + e.respondWith( + fetch(e.request).catch(() => caches.match(e.request)) + ); + } +}); diff --git a/server/web/static/style.css b/server/web/static/style.css index ae6c885..5d74633 100644 --- a/server/web/static/style.css +++ b/server/web/static/style.css @@ -669,6 +669,25 @@ tr:hover td { background: var(--bg2); } .http-put { color: var(--yellow); font-weight: 700; font-family: var(--mono); font-size: 11px; } .http-del { color: var(--red); font-weight: 700; font-family: var(--mono); font-size: 11px; } +/* ── Help TOC collapse toggle (mobile) ───────────────────────────────────── */ +.toc-toggle { + display: none; + width: 100%; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font); + font-size: 14px; + font-weight: 500; + padding: 10px 14px; + text-align: left; + cursor: pointer; +} +@media (max-width: 768px) { + .toc-toggle { display: block; } +} + /* ── Prompt mode toggle ───────────────────────────────────────────────────── */ .prompt-mode-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .pm-btn { flex: 1; padding: 6px 8px; font-size: 12px; border: none; border-right: 1px solid var(--border); background: var(--bg2); color: var(--text-dim); cursor: pointer; transition: background 0.15s, color 0.15s; } @@ -680,3 +699,213 @@ tr:hover td { background: var(--bg2); } .stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; } .stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; } .stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); } + +/* ── Responsive layout ────────────────────────────────────────────────────── */ +.app-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 150; +} + +.mobile-header { + display: none; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg2); + flex-shrink: 0; +} + +.hamburger-btn { + background: none; + border: none; + color: var(--text); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + border-radius: 4px; + transition: background 0.15s; +} +.hamburger-btn:hover { background: var(--bg3); } +.hamburger-btn svg { width: 20px; height: 20px; } + +.mobile-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + flex: 1; +} + +.mobile-install-btn { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + border-radius: 4px; + transition: background 0.15s, color 0.15s; + margin-left: auto; +} +.mobile-install-btn:hover { background: var(--bg3); color: var(--accent); } + +/* ── Mobile tab select (replaces tab bar on small screens) ───────────────── */ +.tab-select-wrap { + display: none; + position: relative; + margin-bottom: 24px; +} + +.tab-select { + width: 100%; + appearance: none; + -webkit-appearance: none; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font); + font-size: 14px; + font-weight: 500; + padding: 10px 36px 10px 14px; + cursor: pointer; + outline: none; +} +.tab-select:focus { border-color: var(--accent-dim); } + +.tab-select-arrow { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-dim); + pointer-events: none; + font-size: 13px; +} + +@media (max-width: 768px) { + .tab-select-wrap { display: block; } +} + +/* ── Tab bar (settings + others) ─────────────────────────────────────────── */ +.tab-bar { + display: flex; + gap: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; +} +.tab-bar::-webkit-scrollbar { display: none; } +.tab-bar .tab-btn { flex-shrink: 0; } + +/* ── Usage page header ────────────────────────────────────────────────────── */ +.usage-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + gap: 12px; + flex-wrap: wrap; +} +.usage-header-actions { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +@media (max-width: 768px) { + html, body { overflow: hidden; } + + .sidebar { + position: fixed; + left: -220px; + top: 0; + height: 100%; + z-index: 200; + transition: left 0.25s ease; + overflow-y: auto; + width: 200px; + } + + .sidebar.open { left: 0; } + + .sidebar-overlay.visible { display: block; } + + .mobile-header { display: flex; } + + .app-body { width: 100%; } + + /* Page padding */ + .page { padding: 16px; } + .page h1 { margin-bottom: 16px; } + + /* Chat */ + .chat-messages { padding: 12px 16px; } + .status-bar { padding: 6px 12px; flex-wrap: wrap; gap: 6px; font-size: 10px; } + + /* Prevent iOS auto-zoom on input focus (triggered when font-size < 16px) */ + input, textarea, select, .form-input, .chat-input, .tab-select { + font-size: 16px !important; + } + + /* Chat input — textarea full-width row, buttons on second row */ + .chat-input-wrap { + flex-wrap: wrap; + padding: 8px 12px; + gap: 6px; + } + .chat-input-wrap .chat-input { + flex: 1 1 100%; + min-height: 80px; + max-height: 160px; + order: 1; + } + #attach-btn { order: 2; } + #clear-btn { order: 3; } + #send-btn { order: 4; margin-left: auto; } + + /* Modals slide up from bottom */ + .modal-overlay { align-items: flex-end; } + .modal { + width: 100% !important; + max-width: 100% !important; + border-radius: var(--radius) var(--radius) 0 0 !important; + margin: 0; + } + + /* Help page stacks with collapsible TOC */ + .help-layout { flex-direction: column; overflow: auto; } + .help-toc { + width: 100%; min-width: unset; + border-right: none; border-bottom: 1px solid var(--border); + overflow: hidden; + padding: 12px 16px; + } + .help-toc .toc-list, .help-toc .form-input { display: none; } + .help-toc.toc-collapsed .toc-list, + .help-toc.toc-collapsed .form-input { display: none; } + .help-toc:not(.toc-collapsed) .toc-list, + .help-toc:not(.toc-collapsed) .form-input { display: block; margin-top: 10px; } + .help-content { padding: 20px 16px; } + + /* Tab bar scrollable */ + .tabs { overflow-x: auto; flex-wrap: nowrap; } + + /* Filter bar */ + .filter-bar { gap: 6px; } + .filter-bar .form-input { min-width: 0; } +} diff --git a/server/web/templates/agent_detail.html b/server/web/templates/agent_detail.html index 4c9f696..9ec2133 100644 --- a/server/web/templates/agent_detail.html +++ b/server/web/templates/agent_detail.html @@ -114,57 +114,33 @@ - -