diff --git a/src/ursa_dashboard/app.py b/src/ursa_dashboard/app.py index 020bd462..8621bc9a 100644 --- a/src/ursa_dashboard/app.py +++ b/src/ursa_dashboard/app.py @@ -1606,6 +1606,8 @@ async def ui_workspace_all( settings: null, _renderTimers: { stdout: null, stderr: null }, _logToken: 0, + _followLogs: { stdout: true, stderr: true }, + _pendingWhilePaused: { stdout: false, stderr: false }, }; function escHtml(s) { @@ -2041,12 +2043,18 @@ async def ui_workspace_all( const key = (which === 'stderr') ? 'stderr' : 'stdout'; if (state._renderTimers[key]) return; state._renderTimers[key] = setTimeout(() => { - state._renderTimers[key] = null; - const el = (key === 'stderr') ? $('#stderrLog') : $('#stdoutLog'); - if (!el) return; - const raw = (key === 'stderr') ? state.logs.stderr : state.logs.stdout; - el.innerHTML = ansiToHtml(raw); - el.scrollTop = el.scrollHeight; + state._renderTimers[key] = null; + const el = (key === 'stderr') ? $('#stderrLog') : $('#stdoutLog'); + if (!el) return; + + if (!state._followLogs[key]) { + state._pendingWhilePaused[key] = true; + return; + } + + const raw = (key === 'stderr') ? state.logs.stderr : state.logs.stdout; + el.innerHTML = ansiToHtml(raw); + el.scrollTop = el.scrollHeight; }, 60); } @@ -2065,23 +2073,55 @@ async def ui_workspace_all( return out.join(''); } + // this helps w/ Rich console animations + function applyCarriageReturns(existing, incoming) { + let out = existing; + let lineStart = out.lastIndexOf('\n') + 1; + + for (let i = 0; i < incoming.length; i++) { + const ch = incoming[i]; + + if (ch === '\r') { + // Move cursor to start of current line. + lineStart = out.lastIndexOf('\n') + 1; + out = out.slice(0, lineStart); + continue; + } + + out += ch; + + if (ch === '\n') { + lineStart = out.length; + } + } + + return out; + } + + function trimToLastLines(text, maxLines) { + text = String(text ?? ''); + maxLines = Number(maxLines); + + if (!Number.isFinite(maxLines) || maxLines <= 0) return ''; + + const lines = text.split('\n'); + if (lines.length <= maxLines) return text; + return lines.slice(-maxLines).join('\n'); + } + function appendLog(stream, text) { - const cap = 250000; + const capLines = state.settings?.ui?.stdout_buffer_lines ?? 5000; const key = (stream === 'stderr') ? 'stderr' : 'stdout'; text = String(text ?? ''); - // Some console UIs emit backspaces for spinners. text = stripBackspaces(text); - // Make progress-style output readable even without a real terminal. - text = text.replace(/\r(?!\n)/g, '\n'); - if (key === 'stderr') { - state.logs.stderr = (state.logs.stderr + text); - if (state.logs.stderr.length > cap) state.logs.stderr = state.logs.stderr.slice(-cap); + state.logs.stderr = applyCarriageReturns(state.logs.stderr, text); + state.logs.stderr = trimToLastLines(state.logs.stderr, capLines); } else { - state.logs.stdout = (state.logs.stdout + text); - if (state.logs.stdout.length > cap) state.logs.stdout = state.logs.stdout.slice(-cap); + state.logs.stdout = applyCarriageReturns(state.logs.stdout, text); + state.logs.stdout = trimToLastLines(state.logs.stdout, capLines); } scheduleLogRender(key); @@ -2098,8 +2138,9 @@ async def ui_workspace_all( let after = 0; let pages = 0; const limit = 5000; + const maxPages = 50; - while (pages < 50) { + while (pages < maxPages) { if (token !== state._logToken) return; const res = await api('GET', `/runs/${encodeURIComponent(runId)}/events?after_seq=${after}&limit=${limit}`); const events = res.events || []; @@ -2269,66 +2310,80 @@ async def ui_workspace_all( } } + function renderSessions() { const list = $('#sessionList'); if (!list) return; list.innerHTML = ''; for (const s of state.sessions) { - const row = document.createElement('div'); - row.className = 'sessionRow'; - - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'histItem' + (state.activeSessionId === s.session_id ? ' selected' : ''); - btn.onclick = () => loadSession(s.session_id); - - const title = document.createElement('div'); - title.textContent = s.title || s.session_id; - - const meta = document.createElement('div'); - meta.className = 'muted small'; - const agentName = state.agentsById.get(s.agent_id)?.display_name || s.agent_id; - const active = s.active_run_id ? ' (running)' : ''; - meta.textContent = `${agentName} \u00b7 ${fmtTime(s.updated_at)}${active}`; - - btn.appendChild(title); - btn.appendChild(meta); - - const renameBtn = document.createElement('button'); - renameBtn.type = 'button'; - renameBtn.className = 'sessActBtn'; - renameBtn.textContent = 'Rename'; - renameBtn.title = 'Rename session'; - renameBtn.onclick = async (e) => { + const row = document.createElement('div'); + row.className = 'sessionRow'; + + const btn = document.createElement('button'); + btn.type = 'button'; + const isRunning = !!s.active_run_id; + btn.className = 'histItem' + (state.activeSessionId === s.session_id ? ' selected' : '') + (isRunning ? ' running' : ' idle'); + btn.onclick = () => loadSession(s.session_id); + + const title = document.createElement('div'); + title.className = 'sessionTitle'; + title.textContent = s.title || s.session_id; + + const meta = document.createElement('div'); + meta.className = 'muted small sessionMeta'; + + const agentName = state.agentsById.get(s.agent_id)?.display_name || s.agent_id; + const stateClass = isRunning ? 'running' : 'idle'; + const stateText = isRunning ? 'Running' : 'Idle'; + + meta.innerHTML = ` + + ${stateText} + + · + ${escHtml(agentName)} + · + ${escHtml(fmtTime(s.updated_at))} + `; + + btn.appendChild(title); + btn.appendChild(meta); + + const renameBtn = document.createElement('button'); + renameBtn.type = 'button'; + renameBtn.className = 'sessActBtn'; + renameBtn.textContent = 'Rename'; + renameBtn.title = 'Rename session'; + renameBtn.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); const cur = s.title || ''; const next = prompt('Rename session', cur); if (next === null) return; await renameSession(s.session_id, next); - }; - - const delBtn = document.createElement('button'); - delBtn.type = 'button'; - delBtn.className = 'sessActBtn danger'; - delBtn.textContent = 'Delete'; - delBtn.title = s.active_run_id ? 'Cannot delete while a run is active' : 'Delete session'; - delBtn.disabled = !!s.active_run_id; - delBtn.onclick = async (e) => { + }; + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'sessActBtn danger'; + delBtn.textContent = 'Delete'; + delBtn.title = s.active_run_id ? 'Cannot delete while a run is active' : 'Delete session'; + delBtn.disabled = !!s.active_run_id; + delBtn.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); await deleteSessionUI(s.session_id); - }; + }; - row.appendChild(btn); - row.appendChild(renameBtn); - row.appendChild(delBtn); - list.appendChild(row); + row.appendChild(btn); + row.appendChild(renameBtn); + row.appendChild(delBtn); + list.appendChild(row); } if (!state.sessions.length) { - list.innerHTML = '
No sessions yet. Start one from the Agents list.
'; + list.innerHTML = '
No sessions yet. Start one from the Agents list.
'; } } @@ -2337,15 +2392,23 @@ async def ui_workspace_all( const meta = $('#activeSessionMeta'); const msgs = $('#sessionMessages'); const wsTitle = $('#workspaceTitle'); + const badge = $('#activeSessionStateBadge'); if (!state.activeSession) { - if (title) title.textContent = 'No session selected'; - if (meta) meta.textContent = ''; - if (msgs) msgs.innerHTML = '
Pick an agent to start a new session, or select a session on the left.
'; - if (wsTitle) wsTitle.textContent = 'Session artifacts'; - setStatus(''); - clearRunView(); - return; + if (title) title.textContent = 'No session selected'; + if (meta) meta.textContent = ''; + if (msgs) msgs.innerHTML = '
Pick an agent to start a new session, or select a session on the left.
'; + if (wsTitle) wsTitle.textContent = 'Session artifacts'; + + if (badge) { + badge.style.display = 'none'; + badge.className = 'sessionStatus idle'; + badge.innerHTML = 'Idle'; + } + + setStatus(''); + clearRunView(); + return; } const s = state.activeSession.session; @@ -2358,7 +2421,20 @@ async def ui_workspace_all( const activeRunId = s.active_run_id || null; const lastRunId = s.last_run_id || null; - const viewRunId = activeRunId || lastRunId || null; + + if (badge) { + badge.style.display = ''; + if (activeRunId) { + badge.className = 'sessionStatus running'; + badge.innerHTML = 'Running'; + } else if (lastRunId) { + badge.className = 'sessionStatus idle'; + badge.innerHTML = 'Complete'; + } else { + badge.className = 'sessionStatus idle'; + badge.innerHTML = 'Idle'; + } + } if (activeRunId) setStatus(`run ${activeRunId} \u00b7 running`); else if (lastRunId) setStatus(`run ${lastRunId} \u00b7 last run`); @@ -2368,54 +2444,50 @@ async def ui_workspace_all( msgs.innerHTML = ''; for (const m of (state.activeSession.messages || [])) { - const row = document.createElement('div'); - row.className = 'msgRow ' + (m.role || ''); + const row = document.createElement('div'); + row.className = 'msgRow ' + (m.role || ''); - const head = document.createElement('div'); - head.className = 'msgHead'; + const head = document.createElement('div'); + head.className = 'msgHead'; - const who = document.createElement('span'); - who.className = 'who'; - who.textContent = (m.role === 'assistant') ? 'Assistant' : (m.role === 'system' ? 'System' : 'You'); - head.appendChild(who); + const who = document.createElement('span'); + who.className = 'who'; + who.textContent = (m.role === 'assistant') ? 'Assistant' : (m.role === 'system' ? 'System' : 'You'); + head.appendChild(who); - const t = document.createElement('span'); - t.className = 'muted small'; - t.textContent = ' \u00b7 ' + fmtTime(m.ts); - head.appendChild(t); + const t = document.createElement('span'); + t.className = 'muted small'; + t.textContent = ' \u00b7 ' + fmtTime(m.ts); + head.appendChild(t); - if (m.run_id) { + if (m.run_id) { const r = document.createElement('span'); r.className = 'muted small mono'; r.textContent = ' \u00b7 ' + m.run_id; head.appendChild(r); - } + } - const body = document.createElement('div'); - body.className = 'bubble ' + (m.role || ''); - if (m.role === 'assistant' || m.role === 'system') { + const body = document.createElement('div'); + body.className = 'bubble ' + (m.role || ''); + if (m.role === 'assistant' || m.role === 'system') { body.innerHTML = mdToHtml(m.text || ''); - } else { + } else { body.textContent = m.text || ''; - } + } - row.appendChild(head); - row.appendChild(body); - msgs.appendChild(row); + row.appendChild(head); + row.appendChild(body); + msgs.appendChild(row); } - // Scroll to bottom msgs.scrollTop = msgs.scrollHeight; - // Show run logs affiliated with this session: - // - If a run is currently active, stream it. - // - Otherwise, show the last completed run logs. if (activeRunId) { - showRunStream(activeRunId); + showRunStream(activeRunId); } else if (lastRunId) { - showRunStatic(lastRunId).catch(err => console.error(err)); + showRunStatic(lastRunId).catch(err => console.error(err)); } else { - clearRunView(); + clearRunView(); } } @@ -2715,13 +2787,30 @@ async def ui_workspace_all( renderMcpServers(); } + function applyTheme(theme) { + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + let resolved = 'light'; + + if (theme === 'dark') resolved = 'dark'; + else if (theme === 'light') resolved = 'light'; + else resolved = prefersDark ? 'dark' : 'light'; + + document.documentElement.setAttribute('data-theme', resolved); + } + async function loadSettings() { const res = await api('GET', '/settings'); state.settings = res.settings || {}; + applyTheme(state.settings?.ui?.theme || 'system'); const llm = state.settings.llm || {}; const runner = state.settings.runner || {}; const mcp = state.settings.mcp || {}; + // Settings-related entries + const ui = state.settings.ui || {}; + $('#set_stdout_buffer_lines').value = ui.stdout_buffer_lines ?? 20000; + $('#set_theme').value = ui.theme || 'system'; + $('#set_base_url').value = llm.base_url || ''; $('#set_model').value = llm.model || ''; $('#set_api_key_env_var').value = llm.api_key_env_var || ''; @@ -2741,6 +2830,10 @@ async def ui_workspace_all( async function saveSettings() { const patch = { + ui: { + theme: ($('#set_theme').value || 'system'), + stdout_buffer_lines: ($('#set_stdout_buffer_lines').value === '' ? null : Number($('#set_stdout_buffer_lines').value)), + }, llm: { base_url: ($('#set_base_url').value || '').trim() || null, model: ($('#set_model').value || '').trim() || null, @@ -2776,6 +2869,7 @@ async def ui_workspace_all( const payload = { patch: compact(patch) }; const res = await api('PATCH', '/settings', payload); state.settings = res.settings || {}; + applyTheme(state.settings?.ui?.theme || 'system'); const saved = $('#settingsSaved'); if (saved) { @@ -2784,6 +2878,40 @@ async def ui_workspace_all( } } + function setSettingsSection(section) { + $$('.settingsNavBtn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.settingsSection === section); + }); + $$('.settingsPane').forEach(pane => { + pane.classList.toggle('hidden', pane.dataset.settingsPane !== section); + }); + } + + function setupSettingsNav() { + $$('.settingsNavBtn').forEach(btn => { + btn.onclick = () => setSettingsSection(btn.dataset.settingsSection); + }); + } + + function setupLogFollowState() { + ['stdout', 'stderr'].forEach((key) => { + const el = (key === 'stderr') ? $('#stderrLog') : $('#stdoutLog'); + if (!el) return; + + el.addEventListener('scroll', () => { + const thresholdPx = 24; + const nearBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) <= thresholdPx; + state._followLogs[key] = nearBottom; + + if (nearBottom && state._pendingWhilePaused[key]) { + state._pendingWhilePaused[key] = false; + el.innerHTML = ansiToHtml(state.logs[key]); + el.scrollTop = el.scrollHeight; + } + }); + }); + } + function setupUi() { // New panel toggles (sidebar is always visible) // Migrate old prefs if present. @@ -2795,6 +2923,7 @@ async def ui_workspace_all( state.showArtifacts = !!loadPref('ursa.ui.showArtifacts', oldHideRight === null ? true : !oldHideRight); applyPanelVisibility(); + setupLogFollowState(); const tChat = $('#toggleChatBtn'); if (tChat) tChat.onclick = () => { state.showChat = !state.showChat; applyPanelVisibility(); }; @@ -2879,6 +3008,7 @@ async def ui_workspace_all( // Settings modal const modal = $('#settingsModal'); + setupSettingsNav(); $('#openSettingsBtn').onclick = async () => { modal.classList.add('open'); await loadSettings(); @@ -2897,6 +3027,11 @@ async def ui_workspace_all( } async function init() { + // have to grab settings for light/dark theme first + const res = await api('GET', '/settings'); + state.settings = res.settings || {}; + applyTheme(state.settings?.ui?.theme || 'system'); + setupUi(); await refreshAgents(); await refreshSessions(); @@ -2921,6 +3056,35 @@ async def ui_workspace_all( """ DASHBOARD_CSS = r""" +:root[data-theme="dark"] { + --bg: #111418; + --panel: rgba(28, 32, 38, 0.92); + --panelSolid: #1c2026; + --border: #3a404a; + --text: #eceff4; + --muted: #aab3bf; +} +:root[data-theme="dark"] body { background: var(--bg); color: var(--text); } +:root[data-theme="dark"] .section { background: rgba(24, 28, 34, 0.92); } +:root[data-theme="dark"] .logDetails { background: rgba(24, 28, 34, 0.92); } +:root[data-theme="dark"] .btn { background: #1f242b; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] .histItem { background: #1f242b; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] .sessActBtn { background: #1f242b; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] .agentBtn { background: #1f242b; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] .fileItem { background: #1f242b; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] .fileDl { background: #1f242b; color: #8ab4ff; border-color: var(--border); } +:root[data-theme="dark"] .messages { background: #171b20; border-color: var(--border); } +:root[data-theme="dark"] .bubble { background: #252b33; color: var(--text); } +:root[data-theme="dark"] .bubble.user { background: #1d3557; color: #eef4ff; } +:root[data-theme="dark"] textarea { background: #171b20; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] input, :root[data-theme="dark"] select { background: #171b20; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] .artifactText { background: #171b20; color: var(--text); border-color: var(--border); } +:root[data-theme="dark"] iframe { background: #171b20; border-color: var(--border); } +:root[data-theme="dark"] .modalCard { background: #1a1f26; color: var(--text); } +:root[data-theme="dark"] .settingsNavBtn:hover { background: #252b33; } +:root[data-theme="dark"] .settingsNavBtn.active { background: #233247; border-color: #355070; } +:root[data-theme="dark"] .settingsNavBtn { color: #b7bda6; } +:root[data-theme="dark"] .settingsNavBtn.active { color: #eef4ff; } :root { --bg: #ffffff; --panel: rgba(250, 250, 250, 0.92); @@ -3059,6 +3223,24 @@ async def ui_workspace_all( .pill { font-size: 11px; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--border); background: #fff; color: var(--muted); } .pill.action { color: #0b57d0; border-color: rgba(11,87,208,0.35); } +.sessionTitle { font-weight: 650; line-height: 1.2; overflow-wrap: anywhere; margin-bottom: 6px; } +.sessionMeta { line-height: 1.25; overflow-wrap: anywhere; } +.sessionMetaState { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; } +.sessionMetaState.running { color: #0f7a2f; } +.sessionMetaState.idle { color: #666; } +.sessionMetaDot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; background: currentColor; transform: translateY(0.5px); } +.sessionMetaState.running .sessionMetaDot { animation: sessionPulse 1.4s ease-in-out infinite; } +.sessionMetaSep { color: #999; margin: 0 4px; } +.sessionStatus { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 11px; line-height: 1; border: 1px solid var(--border); background: #fff; white-space: nowrap; } +.sessionStatus.running { color: #0f7a2f; border-color: rgba(15, 122, 47, 0.28); background: rgba(15, 122, 47, 0.06); } +.sessionStatus.idle { color: #666; border-color: rgba(0, 0, 0, 0.10); background: rgba(0, 0, 0, 0.03); } +.sessionDot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; background: currentColor; transform: translateY(0.5px); } +.sessionStatus.running .sessionDot { animation: sessionPulse 1.4s ease-in-out infinite; } +.histItem.running { border-left: 4px solid rgba(15, 122, 47, 0.55); padding-left: 8px; } +.histItem.selected.running { box-shadow: 0 0 0 2px rgba(15, 122, 47, 0.12); } +.histItem.selected.idle { box-shadow: 0 0 0 2px rgba(11,87,208,0.10); } +@keyframes sessionPulse { 0%, 100% { transform: translateY(0.5px) scale(1); opacity: 1; } 50% { transform: translateY(0.5px) scale(1.35); opacity: 0.65; } } + .sessionRow { display:flex; gap: 8px; align-items: stretch; margin-bottom: 10px; } .sessionRow .histItem { margin-bottom: 0; flex: 1 1 auto; } .sessionRow .sessActBtn { flex: 0 0 auto; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--border); background: #fff; cursor: pointer; font-size: 12px; } @@ -3134,8 +3316,65 @@ async def ui_workspace_all( .modal { position: fixed; inset: 0; display:none; z-index: 30; } .modal.open { display:block; } .backdrop { position:absolute; inset:0; background: rgba(0,0,0,0.25); } -.modalCard { position:absolute; top: 8vh; left: 50%; transform: translateX(-50%); width: min(720px, 94vw); background:#fff; border-radius: 14px; border: 1px solid var(--border); padding: 14px; } +.modalCard { + position: absolute; + top: 4vh; + left: 50%; + transform: translateX(-50%); + width: min(1100px, 96vw); + height: 90vh; + background: #fff; + border-radius: 14px; + border: 1px solid var(--border); + padding: 14px; + box-sizing: border-box; + display: flex; + flex-direction: column; +} + +.settingsShell { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 220px 1fr; + gap: 16px; +} + +.settingsNav { + border-right: 1px solid var(--border); + padding-right: 12px; + overflow: auto; +} + +.settingsContent { + min-width: 0; + overflow: auto; + padding-right: 4px; +} + +.settingsNavBtn { + display: block; + width: 100%; + text-align: left; + border: 1px solid transparent; + background: transparent; + padding: 10px 12px; + border-radius: 10px; + cursor: pointer; + margin-bottom: 6px; +} + +.settingsNavBtn:hover { + background: #f6f6f6; +} + +.settingsNavBtn.active { + background: #eef5ff; + border-color: #c9daf8; +} .fieldRow { display:grid; grid-template-columns: 170px 1fr; gap: 8px; align-items: center; margin-bottom: 8px; } +.fieldHelp { display: grid; grid-template-columns: 170px 1fr; gap: 8px; margin: -2px 0 12px; } +.fieldHelpText { color: var(--muted); font-size: 12px; line-height: 1.35; } .label { color: var(--muted); font-size: 12px; } .input { padding: 8px 10px; border-radius: 10px; border: 1px solid var(--border); } textarea.input { width: 100%; box-sizing: border-box; resize: vertical; } @@ -3251,7 +3490,13 @@ async def ui_dashboard() -> HTMLResponse:
-
No session selected
+
+
No session selected
+ +
@@ -3324,6 +3569,8 @@ async def ui_dashboard() -> HTMLResponse:
+ +