diff --git a/.claude/commands/add-sse-event.md b/.claude/commands/add-sse-event.md index 23f47a08eb..79b7ff1a83 100644 --- a/.claude/commands/add-sse-event.md +++ b/.claude/commands/add-sse-event.md @@ -45,18 +45,18 @@ If the event carries structured data beyond a simple string, add a serializable ## Step 4: Add frontend handler -**File**: `src/channels/web/static/app.js` +**File**: `crates/ironclaw_gateway/static/js/core/sse.js` In the `connectSSE()` function, add a new `eventSource.addEventListener()` for the snake_case event name. Parse the JSON data and call a handler function. -Create the handler function that updates the DOM. Follow existing patterns: +Create the handler function that updates the DOM. Put it in the split file that matches its surface — e.g. `js/core/onboarding.js` for auth/onboarding handlers, `js/surfaces/chat.js` for chat message handlers, `js/surfaces/jobs.js` for sandbox job events. Follow existing patterns: - `showApproval(data)` for complex card-style UI - `addMessage(role, content)` for simple text - `setStatus(text, spinning)` for status bar updates ## Step 5: Add CSS if needed -**File**: `src/channels/web/static/style.css` +**File**: pick the matching surface under `crates/ironclaw_gateway/static/styles/surfaces/` (e.g. `chat.css` for chat UI, `jobs.css` for sandbox job cards) or `styles/components/` for cross-surface reusable pieces. If the event needs custom UI (cards, badges, etc.), add styles. Follow the existing naming conventions (`.approval-card`, `.log-entry`, etc.). diff --git a/.claude/commands/trace.md b/.claude/commands/trace.md index 8651bd59f2..97dee9286c 100644 --- a/.claude/commands/trace.md +++ b/.claude/commands/trace.md @@ -35,9 +35,9 @@ StatusUpdate variant (channel.rs) → WebChannel::send_status() (web/mod.rs) maps to SseEvent → broadcast via tokio::broadcast channel → SSE endpoint streams events (web/server.rs) - → Browser EventSource listener (app.js) - → DOM update function - → CSS styling (style.css) + → Browser EventSource listener (js/core/sse.js) + → DOM update function (js/surfaces/.js or js/core/.js) + → CSS styling (styles/surfaces/.css or styles/components/*.css) ``` ### Tool Flow (tool definition to execution) @@ -72,7 +72,7 @@ Tool trait impl (tools/builtin/*.rs or tools/mcp/client.rs or tools/wasm/wrapper | Channel trait | `src/channels/channel.rs` | `Channel`, `StatusUpdate`, `IncomingMessage` | | Web gateway | `src/channels/web/mod.rs` | `send_status`, `send_response` | | Web server | `src/channels/web/server.rs` | Route handlers, SSE endpoints | -| Web frontend | `src/channels/web/static/app.js` | SSE listeners, DOM builders | +| Web frontend | `crates/ironclaw_gateway/static/js/` (core/ + surfaces/) | SSE listeners in `core/sse.js`; DOM builders per surface | | Tool registry | `src/tools/registry.rs` | `tool_definitions`, `get`, `register` | | MCP tools | `src/tools/mcp/client.rs` | `McpToolWrapper`, `list_tools`, `call_tool` | | MCP protocol | `src/tools/mcp/protocol.rs` | `McpTool`, `inputSchema` | diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 58bb903e24..caf8025446 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -118,8 +118,14 @@ jobs: uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 with: node-version: "22" - - name: Check gateway app.js syntax - run: node --check crates/ironclaw_gateway/static/app.js + - name: Check gateway JS syntax + run: | + # app.js was split into per-surface/per-concern modules under + # static/js/ that are concatenated at compile time into APP_JS. + # Cuts land on top-level symbol boundaries, so each file is + # self-parseable — a per-file node --check is sufficient. + find crates/ironclaw_gateway/static/js -type f -name '*.js' \ + -exec node --check {} + deny-check: name: cargo-deny diff --git a/CLAUDE.md b/CLAUDE.md index 18240b0014..f3257cc19d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ Current ownership: - `src/bridge/auth_manager.rs`: canonical auth-flow extension-name resolver - `src/bridge/router.rs`: auth gate display + submit routing - `src/channels/web/server.rs`: pending-gate/history rehydration -- `crates/ironclaw_gateway/static/app.js`: unified onboarding controller and configure-modal routing +- `crates/ironclaw_gateway/static/js/core/onboarding.js`: unified onboarding controller and configure-modal routing (previously in the monolithic `app.js`, now split — see `crates/ironclaw_gateway/src/assets.rs` for the concat order) Temporary compatibility boundary: diff --git a/crates/ironclaw_gateway/src/assets.rs b/crates/ironclaw_gateway/src/assets.rs index 76a5434fe4..8c5eb38306 100644 --- a/crates/ironclaw_gateway/src/assets.rs +++ b/crates/ironclaw_gateway/src/assets.rs @@ -10,11 +10,116 @@ /// Main HTML page (SPA shell). pub const INDEX_HTML: &str = include_str!("../static/index.html"); -/// Main application JavaScript. -pub const APP_JS: &str = include_str!("../static/app.js"); +/// Main application JavaScript — compile-time concatenation of the split +/// modules under `static/js/`. Each file owns one surface, one slice of +/// lifecycle, or one shared helper; merge-conflict churn used to +/// dominate `app.js` when it was a single 11 k-line file. +/// +/// Execution order MUST match the original top-to-bottom order of the +/// pre-split monolith — code has forward references (closures, event +/// bindings) that are order-dependent. A newline between each module +/// prevents the last token of one file running into the first token of +/// the next when a file ends without a trailing newline. +pub const APP_JS: &str = concat!( + include_str!("../static/js/core/bootstrap.js"), + "\n", + include_str!("../static/js/core/activity-store.js"), + "\n", + include_str!("../static/js/core/routing.js"), + "\n", + include_str!("../static/js/core/init-auth.js"), + "\n", + include_str!("../static/js/core/sse.js"), + "\n", + include_str!("../static/js/surfaces/chat.js"), + "\n", + include_str!("../static/js/core/render.js"), + "\n", + include_str!("../static/js/core/tool-activity.js"), + "\n", + include_str!("../static/js/core/onboarding.js"), + "\n", + include_str!("../static/js/core/history.js"), + "\n", + include_str!("../static/js/surfaces/memory.js"), + "\n", + include_str!("../static/js/surfaces/logs.js"), + "\n", + include_str!("../static/js/surfaces/extensions.js"), + "\n", + include_str!("../static/js/surfaces/jobs.js"), + "\n", + include_str!("../static/js/surfaces/routines.js"), + "\n", + include_str!("../static/js/surfaces/projects.js"), + "\n", + include_str!("../static/js/surfaces/users.js"), + "\n", + include_str!("../static/js/core/gateway-tee.js"), + "\n", + include_str!("../static/js/surfaces/skills.js"), + "\n", + include_str!("../static/js/surfaces/tool-permissions.js"), + "\n", + include_str!("../static/js/surfaces/settings.js"), + "\n", + include_str!("../static/js/core/ui-helpers.js"), + "\n", + include_str!("../static/js/surfaces/config.js"), + "\n", + include_str!("../static/js/core/widgets.js"), +); -/// Base stylesheet. -pub const STYLE_CSS: &str = include_str!("../static/style.css"); +/// Base stylesheet — compile-time concatenation of the split modules +/// under `static/styles/`. Authoring happens in one module per surface, +/// component, or primitive (see `static/styles/`); the served blob is +/// the same text a monolithic `style.css` would be. Load order is +/// base → layout → components → primitives → surfaces. +/// +/// A newline between each module guards against the last rule of one +/// file running into the first selector of the next when a file ends +/// without a trailing newline. +pub const STYLE_CSS: &str = concat!( + include_str!("../static/styles/base.css"), + "\n", + include_str!("../static/styles/layout.css"), + "\n", + include_str!("../static/styles/components/topbar.css"), + "\n", + include_str!("../static/styles/components/markdown.css"), + "\n", + include_str!("../static/styles/primitives/toast.css"), + "\n", + include_str!("../static/styles/surfaces/auth.css"), + "\n", + include_str!("../static/styles/surfaces/chat.css"), + "\n", + include_str!("../static/styles/surfaces/memory.css"), + "\n", + include_str!("../static/styles/surfaces/jobs.css"), + "\n", + include_str!("../static/styles/surfaces/missions.css"), + "\n", + include_str!("../static/styles/surfaces/routines.css"), + "\n", + include_str!("../static/styles/surfaces/logs.css"), + "\n", + include_str!("../static/styles/surfaces/extensions.css"), + "\n", + include_str!("../static/styles/surfaces/activity.css"), + "\n", + include_str!("../static/styles/surfaces/skills.css"), + "\n", + include_str!("../static/styles/surfaces/settings.css"), + "\n", + include_str!("../static/styles/surfaces/config.css"), + "\n", + include_str!("../static/styles/surfaces/users.css"), + "\n", + include_str!("../static/styles/surfaces/tool-permissions.css"), + "\n", + include_str!("../static/styles/surfaces/projects.css"), +); /// Theme initialization script (runs synchronously in `` to prevent FOUC). pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js"); @@ -48,7 +153,7 @@ pub const THEME_CSS: &str = include_str!("../static/theme.css"); pub const ADMIN_HTML: &str = include_str!("../static/admin.html"); /// Admin panel stylesheet. -pub const ADMIN_CSS: &str = include_str!("../static/admin.css"); +pub const ADMIN_CSS: &str = include_str!("../static/admin/admin.css"); /// Admin panel JavaScript. -pub const ADMIN_JS: &str = include_str!("../static/admin.js"); +pub const ADMIN_JS: &str = include_str!("../static/admin/admin.js"); diff --git a/crates/ironclaw_gateway/static/admin.css b/crates/ironclaw_gateway/static/admin/admin.css similarity index 100% rename from crates/ironclaw_gateway/static/admin.css rename to crates/ironclaw_gateway/static/admin/admin.css diff --git a/crates/ironclaw_gateway/static/admin.js b/crates/ironclaw_gateway/static/admin/admin.js similarity index 100% rename from crates/ironclaw_gateway/static/admin.js rename to crates/ironclaw_gateway/static/admin/admin.js diff --git a/crates/ironclaw_gateway/static/app.js b/crates/ironclaw_gateway/static/app.js deleted file mode 100644 index fb031718ea..0000000000 --- a/crates/ironclaw_gateway/static/app.js +++ /dev/null @@ -1,11189 +0,0 @@ -// IronClaw Web Gateway - Client - -// --- Theme Management (dark / light / system) --- -// Icon switching is handled by pure CSS via data-theme-mode on . - -function getSystemTheme() { - return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; -} - -const VALID_THEME_MODES = { dark: true, light: true, system: true }; - -function getThemeMode() { - const stored = localStorage.getItem('ironclaw-theme'); - return (stored && VALID_THEME_MODES[stored]) ? stored : 'system'; -} - -function resolveTheme(mode) { - return mode === 'system' ? getSystemTheme() : mode; -} - -function applyTheme(mode) { - const resolved = resolveTheme(mode); - document.documentElement.setAttribute('data-theme', resolved); - document.documentElement.setAttribute('data-theme-mode', mode); - const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' }; - const btn = document.getElementById('theme-toggle'); - if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode); - const announce = document.getElementById('theme-announce'); - if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode); -} - -function toggleTheme() { - const cycle = { dark: 'light', light: 'system', system: 'dark' }; - const current = getThemeMode(); - const next = cycle[current] || 'dark'; - localStorage.setItem('ironclaw-theme', next); - applyTheme(next); -} - -// Apply theme immediately (FOUC prevention is done via inline script in , -// but we call again here to ensure tooltip is set after DOM is ready). -applyTheme(getThemeMode()); - -// Delay enabling theme transition to avoid flash on initial load. -requestAnimationFrame(function() { - requestAnimationFrame(function() { - document.body.classList.add('theme-transition'); - }); -}); - -// Listen for OS theme changes — only re-apply when in 'system' mode. -const mql = window.matchMedia('(prefers-color-scheme: light)'); -const onSchemeChange = function() { - if (getThemeMode() === 'system') { - applyTheme('system'); - } -}; -if (mql.addEventListener) { - mql.addEventListener('change', onSchemeChange); -} else if (mql.addListener) { - mql.addListener(onSchemeChange); -} - -// Bind theme toggle buttons (CSP-compliant — no inline onclick). -document.getElementById('theme-toggle').addEventListener('click', toggleTheme); -document.getElementById('settings-theme-toggle')?.addEventListener('click', () => { - toggleTheme(); - const btn = document.getElementById('settings-theme-toggle'); - if (btn) { - const mode = localStorage.getItem('ironclaw-theme') || 'system'; - btn.textContent = I18n.t('theme.label', { mode: mode.charAt(0).toUpperCase() + mode.slice(1) }); - } -}); - -let token = ''; -let oidcProxyAuth = false; -let eventSource = null; -let logEventSource = null; -let currentTab = 'chat'; -let currentThreadId = null; -let currentThreadIsReadOnly = false; -let assistantThreadId = null; -let hasMore = false; -let oldestTimestamp = null; -let loadingOlder = false; -let sseHasConnectedBefore = false; -let jobEvents = new Map(); // job_id -> Array of events -let jobListRefreshTimer = null; -let pairingPollInterval = null; -let unreadThreads = new Map(); // thread_id -> unread count -let processingThreads = new Set(); // thread IDs with active agent work -let _loadThreadsTimer = null; -const JOB_EVENTS_CAP = 500; -const JOB_EVENTS_MAX_JOBS = 50; -const MAX_DOM_MESSAGES = 200; -const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; -let stagedImages = []; -let authFlowPending = false; -// Tracks user messages sent but not yet persisted to DB (#2409). -// When loadHistory() clears the DOM, pending messages are re-injected -// so they don't vanish during the safety-pipeline processing window. -const _pendingUserMessages = new Map(); // threadId -> [{id, content, images, timestamp}] -const PENDING_MSG_TTL_MS = 60000; // discard after 60s -let _nextPendingId = 0; -let _ghostSuggestion = ''; -let currentSettingsSubtab = 'inference'; -let generatedImagesByThread = new Map(); -const GENERATED_IMAGE_THREAD_CACHE_CAP = 20; -const GENERATED_IMAGES_PER_THREAD_CAP = 8; -let engineV2Enabled = false; -let engineModeApplied = false; -let currentMissionData = null; -let currentEngineThreadDetail = null; -let currentMissionList = []; -const missionDetailCache = new Map(); -const missionDetailFetchInFlight = new Set(); -const ACTIVE_MISSION_MAPPING_REFRESH_MS = 5000; -const MAX_ACTIVITY_BAR_ITEMS = 6; -let missionProgressRefreshScheduled = false; -let missionMappingRefreshTimer = null; -let missionMappingsLastRefreshedAt = 0; -let activityBarSnapshotInFlight = false; - -function shortDisplayId(id) { - return typeof id === 'string' && id.length > 8 ? id.substring(0, 8) : (id || ''); -} - -class ActivityEntry { - static parseTimestampMs(value) { - if (!value) return 0; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : 0; - } - - static t(key, fallback, params) { - if (typeof I18n === 'undefined') return fallback; - const translated = I18n.t(key, params); - return translated && translated !== key ? translated : fallback; - } -} - -class JobActivityEntry extends ActivityEntry { - constructor({ id, title, state, statusText, updatedAt }) { - super(); - this.id = id; - this.title = title; - this.state = state; - this.statusText = statusText; - this.updatedAt = updatedAt; - } - - static isActiveState(state) { - return state === 'pending' || state === 'in_progress' || state === 'running'; - } - - static normalizeState(state) { - if (state === 'failed' || state === 'error' || state === 'stuck') return 'failed'; - if (state === 'completed' || state === 'done' || state === 'succeeded') return 'done'; - if (JobActivityEntry.isActiveState(state)) return 'running'; - return state || 'done'; - } - - static formatStatus(state, fallback) { - if (fallback) return fallback; - if (state === 'pending') return ActivityEntry.t('jobs.statusPending', 'Pending'); - if (state === 'in_progress' || state === 'running') return ActivityEntry.t('jobs.statusRunning', 'Running'); - if (state === 'completed' || state === 'done' || state === 'succeeded') return ActivityEntry.t('jobs.statusCompleted', 'Completed'); - if (state === 'failed' || state === 'error') return ActivityEntry.t('jobs.statusFailed', 'Failed'); - if (state === 'stuck') return ActivityEntry.t('jobs.summary.stuck', 'Stuck'); - return state ? state.replace(/_/g, ' ') : ActivityEntry.t('jobs.statusCompleted', 'Completed'); - } - - static shouldPreserveActiveStatus(existing) { - if (!existing?.isActive() || !existing.statusText) return false; - const genericStates = ['pending', 'in_progress', 'running']; - return !genericStates.some((candidate) => existing.statusText === JobActivityEntry.formatStatus(candidate)); - } - - static fromApi(job, existing) { - const normalizedState = JobActivityEntry.normalizeState(job.state); - const nextUpdatedAt = JobActivityEntry.parseTimestampMs(job.started_at || job.created_at) - || existing?.updatedAt - || Date.now(); - const shouldPreserveStatus = normalizedState === 'running' - && JobActivityEntry.shouldPreserveActiveStatus(existing); - return new JobActivityEntry({ - id: job.id, - title: job.title || existing?.title || ('Job ' + shortDisplayId(job.id)), - state: normalizedState, - statusText: normalizedState === 'running' - ? JobActivityEntry.formatStatus(job.state, shouldPreserveStatus ? existing.statusText : '') - : JobActivityEntry.formatStatus(job.state), - updatedAt: nextUpdatedAt, - }); - } - - applyPatch(patch) { - if (patch.title) this.title = patch.title; - if (patch.state) this.state = patch.state; - if (patch.statusText) this.statusText = patch.statusText; - if (patch.active === false && !patch.state) this.state = 'done'; - this.updatedAt = Date.now(); - } - - isActive() { - return this.state === 'running'; - } - - toBarItem() { - return { - kind: 'job', - id: this.id, - title: this.title, - statusText: this.statusText || JobActivityEntry.formatStatus('running'), - updatedAt: this.updatedAt || 0, - state: this.state || 'done', - }; - } -} - -class MissionActivityEntry extends ActivityEntry { - constructor({ id, title, status, state, statusText, updatedAt }) { - super(); - this.id = id; - this.title = title; - this.status = status; - this.state = state; - this.statusText = statusText; - this.updatedAt = updatedAt; - } - - static normalizeState(status) { - if (status === 'Active') return 'running'; - if (status === 'Completed') return 'done'; - if (status === 'Failed') return 'failed'; - return 'idle'; - } - - static formatStatus(status, fallback) { - if (fallback) return fallback; - if (status === 'Active') return ActivityEntry.t('status.active', 'Active'); - if (status === 'Completed') return ActivityEntry.t('missions.summary.completed', 'Completed'); - if (status === 'Failed') return ActivityEntry.t('missions.summary.failed', 'Failed'); - if (status === 'Paused') return ActivityEntry.t('missions.summary.paused', 'Paused'); - return status || ActivityEntry.t('status.idle', 'Idle'); - } - - static shouldPreserveActiveStatus(existing) { - if (!existing?.isActive() || !existing.statusText) return false; - const genericStatuses = ['Active', 'Completed', 'Failed', 'Paused']; - return !genericStatuses.some((candidate) => existing.statusText === MissionActivityEntry.formatStatus(candidate)); - } - - static fromApi(mission, existing) { - const normalizedState = MissionActivityEntry.normalizeState(mission.status); - const nextUpdatedAt = MissionActivityEntry.parseTimestampMs(mission.updated_at || mission.created_at) - || existing?.updatedAt - || Date.now(); - const shouldPreserveStatus = normalizedState === 'running' - && MissionActivityEntry.shouldPreserveActiveStatus(existing); - return new MissionActivityEntry({ - id: mission.id, - title: mission.name || existing?.title || ('Mission ' + shortDisplayId(mission.id)), - status: mission.status || existing?.status || '', - state: normalizedState, - statusText: normalizedState === 'running' - ? MissionActivityEntry.formatStatus( - mission.status, - shouldPreserveStatus ? existing.statusText : '', - ) - : MissionActivityEntry.formatStatus(mission.status), - updatedAt: nextUpdatedAt, - }); - } - - applyThreadPatch(meta, patch) { - this.title = meta.mission_name || this.title || ('Mission ' + shortDisplayId(meta.mission_id)); - this.status = this.status || 'Active'; - this.state = 'running'; - this.statusText = patch.statusText || this.statusText || MissionActivityEntry.formatStatus('Active'); - this.updatedAt = Date.now(); - } - - isActive() { - return this.state === 'running'; - } - - isVisibleInBar() { - return this.state !== 'idle'; - } - - toBarItem(liveSnapshot) { - const liveUpdatedAt = liveSnapshot?.updatedAt || 0; - const statusText = this.state === 'running' - ? (liveSnapshot?.progress || MissionActivityEntry.formatStatus(this.status)) - : MissionActivityEntry.formatStatus(this.status, this.statusText); - return { - kind: 'mission', - id: this.id, - missionId: this.id, - title: this.title || ('Mission ' + shortDisplayId(this.id)), - statusText: statusText, - updatedAt: Math.max(this.updatedAt || 0, liveUpdatedAt), - state: this.state || 'done', - }; - } -} - -class ActiveWorkStore { - constructor() { - this.threads = new Map(); - this.jobs = new Map(); - this.missions = new Map(); - this.threadMeta = new Map(); - } - - setEngineV2Enabled(enabled) { - engineV2Enabled = !!enabled; - this.render(); - } - - rememberThreads(entries) { - if (!Array.isArray(entries) || entries.length === 0) return; - let changed = false; - entries.forEach(({ threadId, meta }) => { - if (!threadId) return; - const prev = this.threadMeta.get(threadId) || {}; - const next = { ...prev, ...meta }; - const nextKeys = Object.keys(next); - const isSame = nextKeys.length === Object.keys(prev).length - && nextKeys.every((key) => prev[key] === next[key]); - if (isSame) return; - this.threadMeta.set(threadId, next); - changed = true; - }); - if (changed) this.render(); - } - - rememberMissionThreads(mission) { - if (!mission || !Array.isArray(mission.threads)) return; - this.rememberThreads(mission.threads.map((thread) => ({ - threadId: thread.id, - meta: { - label: thread.goal || ('Thread ' + shortDisplayId(thread.id)), - mission_id: mission.id, - mission_name: mission.name, - }, - }))); - } - - rememberJobs(jobs) { - if (!Array.isArray(jobs)) return; - jobs.forEach((job) => { - if (!job || !job.id) return; - this.jobs.set(job.id, JobActivityEntry.fromApi(job, this.jobs.get(job.id))); - }); - this.render(); - } - - rememberMissions(missions) { - if (!Array.isArray(missions)) return; - missions.forEach((mission) => { - if (!mission || !mission.id) return; - this.missions.set(mission.id, MissionActivityEntry.fromApi(mission, this.missions.get(mission.id))); - }); - this.render(); - } - - updateThread(threadId, patch) { - if (!threadId) return; - const prev = this.threads.get(threadId) || {}; - this.threads.set(threadId, { - ...prev, - ...patch, - active: patch.active !== undefined ? patch.active : true, - updatedAt: Date.now(), - }); - const meta = this.threadMeta.get(threadId) || {}; - if (meta.mission_id) { - const missionEntry = this.missions.get(meta.mission_id) - || new MissionActivityEntry({ - id: meta.mission_id, - title: meta.mission_name || ('Mission ' + shortDisplayId(meta.mission_id)), - status: 'Active', - state: 'running', - statusText: MissionActivityEntry.formatStatus('Active'), - updatedAt: Date.now(), - }); - missionEntry.applyThreadPatch(meta, patch); - this.missions.set(meta.mission_id, missionEntry); - } - this.render(); - } - - clearThread(threadId) { - if (!threadId) return; - this.threads.delete(threadId); - this.render(); - } - - updateJob(jobId, patch) { - if (!jobId) return; - const prev = this.jobs.get(jobId) - || new JobActivityEntry({ - id: jobId, - title: patch.title || ('Job ' + shortDisplayId(jobId)), - state: 'running', - statusText: JobActivityEntry.formatStatus('running'), - updatedAt: Date.now(), - }); - prev.applyPatch(patch); - this.jobs.set(jobId, prev); - this.render(); - } - - getThreadProgress(threadId) { - const entry = threadId ? this.threads.get(threadId) : null; - return entry && entry.active ? entry.statusText : ''; - } - - isThreadBlocked(threadId) { - const entry = threadId ? this.threads.get(threadId) : null; - return !!(entry && entry.blockedReason); - } - - getMissionProgress(missionId) { - let newest = null; - for (const [threadId, meta] of this.threadMeta.entries()) { - if (meta.mission_id !== missionId) continue; - const thread = this.threads.get(threadId); - if (!thread || !thread.active) continue; - if (!newest || thread.updatedAt > newest.updatedAt) { - newest = thread; - } - } - return newest ? newest.statusText : ''; - } - - getTabCounts() { - let jobs = 0; - - for (const entry of this.jobs.values()) { - if (entry && entry.isActive()) jobs += 1; - } - - let missions = 0; - for (const entry of this.missions.values()) { - if (entry && entry.isActive()) missions += 1; - } - - return { - jobs: jobs, - missions: missions, - }; - } - - renderTabCounts() { - const counts = this.getTabCounts(); - const chatButton = document.querySelector('.tab-bar button[data-tab="chat"]'); - if (chatButton) { - chatButton.removeAttribute('data-active-count'); - } - ['jobs', 'missions'].forEach((tabName) => { - const button = document.querySelector('.tab-bar button[data-tab="' + tabName + '"]'); - if (!button) return; - const count = counts[tabName] || 0; - if (count > 0) { - button.setAttribute('data-active-count', String(count)); - } else { - button.removeAttribute('data-active-count'); - } - }); - } - - getMissionLiveSnapshot(missionId) { - let newestUpdatedAt = 0; - let progress = ''; - for (const [threadId, meta] of this.threadMeta.entries()) { - if (meta.mission_id !== missionId) continue; - const thread = this.threads.get(threadId); - if (!thread || !thread.active) continue; - if ((thread.updatedAt || 0) >= newestUpdatedAt) { - newestUpdatedAt = thread.updatedAt || 0; - progress = thread.statusText || ''; - } - } - return { updatedAt: newestUpdatedAt, progress: progress }; - } - - getActiveMissionIds() { - const ids = []; - for (const [missionId, entry] of this.missions.entries()) { - if (entry && entry.isActive()) ids.push(missionId); - } - return ids; - } - - getActivityBarItems() { - const items = []; - for (const [missionId, entry] of this.missions.entries()) { - if (!entry || !entry.isVisibleInBar()) continue; - items.push(entry.toBarItem(this.getMissionLiveSnapshot(missionId))); - } - for (const [jobId, entry] of this.jobs.entries()) { - if (!entry) continue; - items.push(entry.toBarItem()); - } - items.sort((a, b) => b.updatedAt - a.updatedAt); - return items.slice(0, MAX_ACTIVITY_BAR_ITEMS); - } - - render() { - this.renderTabCounts(); - const strip = document.getElementById('active-work-strip'); - if (!strip) return; - if (!engineV2Enabled) { - strip.hidden = true; - strip.innerHTML = ''; - scheduleMissionProgressViewsRefresh(); - return; - } - const items = this.getActivityBarItems(); - strip.hidden = false; - strip.innerHTML = items.length === 0 - ? '
' + escapeHtml(ActivityEntry.t('activity.empty', 'No recent jobs or missions')) + '
' - : items.map((item) => { - const kindLabel = item.kind === 'job' - ? ActivityEntry.t('activity.kind.job', 'Job') - : ActivityEntry.t('activity.kind.mission', 'Mission'); - return ''; - }).join(''); - - scheduleMissionProgressViewsRefresh(); - } -} - -const activeWorkStore = new ActiveWorkStore(); - -// --- Hash-based URL Navigation --- -// -// Encodes navigation state in window.location.hash so refreshing -// the page restores the current tab, thread, memory file, job detail, etc. -// -// Hash format: #/{tab}[/{detail}[/{subtab}]] -// #/chat → chat tab, assistant thread -// #/chat/{threadId} → chat tab, specific thread -// #/memory → memory tab, tree root -// #/memory/{path/to/file} → memory tab, specific file -// #/jobs → jobs list -// #/jobs/{jobId} → job detail -// #/routines → routines list -// #/routines/{id} → routine detail -// #/settings/{subtab} → settings tab with specific sub-tab -// #/logs → logs tab - -/** Suppress hash-change handling while we're programmatically updating. */ -let _suppressHashChange = false; - -/** Update the URL hash to reflect current navigation state. */ -function updateHash() { - if (_suppressHashChange) return; - var parts = [currentTab]; - - switch (currentTab) { - case 'chat': - if (currentThreadId && currentThreadId !== assistantThreadId) { - parts.push(currentThreadId); - } - break; - case 'memory': - if (typeof currentMemoryPath === 'string' && currentMemoryPath) { - parts.push(currentMemoryPath); - } - break; - case 'jobs': - if (typeof currentJobId !== 'undefined' && currentJobId) { - parts.push(currentJobId); - } - break; - case 'routines': - if (typeof currentRoutineId !== 'undefined' && currentRoutineId) { - parts.push(currentRoutineId); - } - break; - case 'settings': - if (currentSettingsSubtab && currentSettingsSubtab !== 'inference') { - parts.push(currentSettingsSubtab); - } - break; - } - - var hash = '#/' + parts.join('/'); - if (window.location.hash !== hash) { - window.history.replaceState(null, '', hash); - } -} - -/** Parse the current URL hash into navigation state. */ -function parseHash() { - var hash = window.location.hash || ''; - if (!hash.startsWith('#/')) return null; - var parts = hash.substring(2).split('/'); - return { - tab: parts[0] || 'chat', - detail: parts.slice(1).join('/') || null, - }; -} - -function normalizeTabForEngineMode(tab) { - if (engineV2Enabled && tab === 'routines') { - return 'missions'; - } - return tab; -} - -function applyEngineModeUi() { - var routinesTab = document.querySelector('.tab-bar [data-tab-role="routines"]'); - var routinesPanel = document.getElementById('tab-routines'); - if (routinesTab) { - routinesTab.style.display = engineV2Enabled ? 'none' : ''; - } - if (routinesPanel && engineV2Enabled && currentTab !== 'routines') { - routinesPanel.classList.remove('active'); - } - if (engineV2Enabled && currentTab === 'routines') { - switchTab('missions'); - } -} - -/** - * Restore navigation state from the URL hash. - * Called once after authentication and on hashchange events. - */ -function restoreFromHash() { - var state = parseHash(); - if (!state) return; - - // Suppress hash updates while restoring — switchTab/readMemoryFile/etc. - // each call updateHash(), which would overwrite the full hash before - // the detail part is restored. - _suppressHashChange = true; - - // Switch tab - if (state.tab && state.tab !== currentTab) { - switchTab(normalizeTabForEngineMode(state.tab)); - } - - // Restore detail state within the tab - if (state.detail) { - switch (state.tab) { - case 'chat': - // Defer thread switch until threads are loaded - window._pendingThreadRestore = state.detail; - break; - case 'memory': - readMemoryFile(state.detail); - break; - case 'jobs': - openJobDetail(state.detail); - break; - case 'routines': - if (engineV2Enabled) { - switchTab('missions'); - } else { - openRoutineDetail(state.detail); - } - break; - case 'settings': - switchSettingsSubtab(state.detail); - break; - } - } - - _suppressHashChange = false; -} - -window.addEventListener('hashchange', function() { - if (_suppressHashChange) return; - restoreFromHash(); -}); - -// --- Streaming Debounce State --- -let _streamBuffer = ''; -let _streamDebounceTimer = null; -const STREAM_DEBOUNCE_MS = 50; - -// --- Connection Status Banner State --- -let _connectionLostTimer = null; -let _reconnectAttempts = 0; -let _lastSseEventId = null; -// Timestamp of the most recent SSE disconnect (tab hide or onerror). Cleared -// on successful reconnect. Used to decide whether to reload chat history on -// reconnect — brief disconnects ( for one job' }, - { cmd: '/list', desc: 'List all jobs' }, - { cmd: '/cancel', desc: '/cancel — cancel a running job' }, - { cmd: '/undo', desc: 'Revert the last turn' }, - { cmd: '/redo', desc: 'Re-apply an undone turn' }, - { cmd: '/compact', desc: 'Compress the context window' }, - { cmd: '/clear', desc: 'Clear thread and start fresh' }, - { cmd: '/interrupt', desc: 'Stop the current turn' }, - { cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' }, - { cmd: '/summarize', desc: 'Summarize the current thread' }, - { cmd: '/suggest', desc: 'Suggest next steps' }, - { cmd: '/help', desc: 'Show help' }, - { cmd: '/version', desc: 'Show version info' }, - { cmd: '/tools', desc: 'List available tools' }, - { cmd: '/skills', desc: 'List installed skills' }, - { cmd: '/model', desc: 'Show or switch the LLM model' }, - { cmd: '/thread new', desc: 'Create a new conversation thread' }, -]; - -let _slashSelected = -1; -let _slashMatches = []; - -// --- Tool Activity State --- -// Chat uses a reusable controller so the same entry and rendering helpers can -// be shared with history, jobs, and future activity surfaces. -let _chatToolActivity = createToolActivityController({ containerId: 'chat-messages' }); - -// --- Auth --- - -// Common post-auth initialization shared by token auth and OIDC auto-auth. -function initApp() { - var authScreen = document.getElementById('auth-screen'); - var app = document.getElementById('app'); - // Cross-fade: fade out auth screen, then show app - if (authScreen) authScreen.style.opacity = '0'; - // Show app container (invisible — opacity:0 in CSS) so layout computes - app.style.display = 'flex'; - // Position tab indicator instantly (no transition) before fade-in - var indicator = document.getElementById('tab-indicator'); - if (indicator) indicator.style.transition = 'none'; - updateTabIndicator(); - // Force layout so the instant position is applied, then restore transition - if (indicator) { - void indicator.offsetLeft; - indicator.style.transition = ''; - } - // Now fade in - app.classList.add('visible'); - // Hide auth screen after fade-out transition completes - setTimeout(function() { if (authScreen) authScreen.style.display = 'none'; }, 300); - // Strip token and log_level from URL so they're not visible in the address bar - var cleaned = new URL(window.location); - var urlLogLevel = cleaned.searchParams.get('log_level'); - cleaned.searchParams.delete('token'); - cleaned.searchParams.delete('log_level'); - window.history.replaceState({}, '', cleaned.pathname + cleaned.search + cleaned.hash); - connectSSE(); - connectLogSSE(); - startGatewayStatusPolling(); - // Fetch user profile and render avatar + account menu. - apiFetch('/api/profile').then(function(profile) { - if (!profile) return; - window._currentUser = profile; - // Hide admin tabs for non-admin users. - if (profile.role !== 'admin') { - var usersTab = document.querySelector('[data-settings-subtab="users"]'); - if (usersTab) usersTab.style.display = 'none'; - } - // Render avatar. - var avatarImg = document.getElementById('user-avatar-img'); - var avatarInitials = document.getElementById('user-avatar-initials'); - var displayName = profile.display_name || profile.email || profile.id || '?'; - if (avatarInitials) { - avatarInitials.textContent = displayName.charAt(0).toUpperCase(); - } - if (profile.avatar_url && avatarImg) { - avatarImg.referrerPolicy = 'no-referrer'; - avatarImg.onload = function() { - if (avatarInitials) avatarInitials.style.display = 'none'; - }; - avatarImg.src = profile.avatar_url; - avatarImg.removeAttribute('hidden'); - } - // Populate dropdown. - var nameEl = document.getElementById('user-dropdown-name'); - var emailEl = document.getElementById('user-dropdown-email'); - var roleEl = document.getElementById('user-dropdown-role'); - if (nameEl) nameEl.textContent = profile.display_name || profile.id; - if (emailEl) emailEl.textContent = profile.email || ''; - if (roleEl) roleEl.textContent = profile.role; - }).catch(function() {}); - checkTeeStatus(); - loadThreads(); - loadMemoryTree(); - loadJobs(); - // Restore navigation state from URL hash (tab, thread, memory file, etc.) - restoreFromHash(); - // Apply URL log_level param if present, otherwise just sync the dropdown - if (urlLogLevel) { - setServerLogLevel(urlLogLevel); - } else { - loadServerLogLevel(); - } -} - -function authenticate() { - token = document.getElementById('token-input').value.trim(); - if (!token) { - document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired'); - return; - } - - // Loading state for Connect button - const connectBtn = document.getElementById('auth-connect-btn'); - if (connectBtn) { - connectBtn.disabled = true; - connectBtn.textContent = I18n.t('auth.connecting'); - } - - // Test the token against the health-ish endpoint (chat/threads requires auth) - apiFetch('/api/chat/threads') - .then(() => { - sessionStorage.setItem('ironclaw_token', token); - initApp(); - }) - .catch(() => { - sessionStorage.removeItem('ironclaw_token'); - document.getElementById('auth-screen').style.display = ''; - document.getElementById('auth-screen').style.opacity = ''; - document.getElementById('app').style.display = 'none'; - document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid'); - // Reset Connect button on error - if (connectBtn) { - connectBtn.disabled = false; - connectBtn.textContent = I18n.t('auth.connect'); - } - }); -} - -document.getElementById('token-input').addEventListener('keydown', (e) => { - if (e.key === 'Enter') authenticate(); -}); - -// Close SSE connections on page unload to free the browser's connection pool. -// Without this, stale SSE connections from prior page loads linger and exhaust -// the HTTP/1.1 per-origin connection limit (6), blocking API fetch calls. -window.addEventListener('beforeunload', () => { - cleanupConnectionState(); - if (eventSource) { eventSource.close(); eventSource = null; } - if (logEventSource) { logEventSource.close(); logEventSource = null; } -}); - -// Pause SSE when the browser tab is hidden (another tab is focused) and resume -// when it becomes visible again. This frees connection slots for other tabs -// running the gateway — without this, each tab holds 1-2 SSE connections and -// the 3rd tab exhausts the browser's per-origin limit. -document.addEventListener('visibilitychange', () => { - if (document.hidden) { - _sseDisconnectedAt = _sseDisconnectedAt || Date.now(); - cleanupConnectionState(); - if (eventSource) { eventSource.close(); eventSource = null; } - if (logEventSource) { logEventSource.close(); logEventSource = null; } - } else if (token) { - connectSSE(); - startGatewayStatusPolling(); - if (currentTab === 'logs') connectLogSSE(); - } -}); - -// --- Social login (OAuth + NEAR wallet) --- - -// Show the token form (used as fallback when no OAuth providers are available). -function showTokenForm() { - var tokenForm = document.getElementById('auth-token-form'); - if (tokenForm) { - tokenForm.style.display = ''; - var input = document.getElementById('token-input'); - if (input) input.focus(); - } -} - -// Discover enabled providers and show corresponding buttons. -fetch('/auth/providers', { credentials: 'include' }) - .then(function(r) { return r.ok ? r.json() : { providers: [] }; }) - .then(function(data) { - var providers = data.providers || []; - if (providers.length === 0) { showTokenForm(); return; } - // Store NEAR network for the wallet connector. - if (data.near_network) window._nearNetwork = data.near_network; - var social = document.getElementById('auth-social'); - if (social) social.style.display = ''; - providers.forEach(function(p) { - var btn = document.getElementById('auth-' + p + '-btn'); - if (!btn) return; - btn.style.display = ''; - if (p === 'near') { - btn.addEventListener('click', authenticateWithNear); - } else { - btn.addEventListener('click', function() { window.location = '/auth/login/' + p; }); - } - }); - // When social providers are available, collapse the token form - // and show the "or use a token" divider instead. - var tokenForm = document.getElementById('auth-token-form'); - var tokenDivider = document.getElementById('auth-token-divider'); - if (tokenForm && tokenDivider) { - tokenForm.style.display = 'none'; - tokenDivider.style.display = ''; - tokenDivider.style.cursor = 'pointer'; - tokenDivider.addEventListener('click', function() { - tokenForm.style.display = ''; - tokenDivider.style.display = 'none'; - var input = document.getElementById('token-input'); - if (input) input.focus(); - }); - } - }) - .catch(function() { showTokenForm(); }); - -// NEAR wallet authentication via near-connect. -async function authenticateWithNear() { - var nearBtn = document.getElementById('auth-near-btn'); - var errEl = document.getElementById('auth-error'); - if (nearBtn) { nearBtn.disabled = true; nearBtn.textContent = I18n.t('auth.connectingWallet'); } - if (errEl) errEl.textContent = ''; - - try { - // 1. Get challenge nonce from the server. - var challengeResp = await fetch('/auth/near/challenge', { credentials: 'include' }); - if (!challengeResp.ok) throw new Error('Failed to get challenge'); - var challenge = await challengeResp.json(); - - // 2. Load near-connect dynamically if not already loaded. - if (!window._nearConnector) { - var mod = await import('https://esm.sh/@hot-labs/near-connect@0.11'); - var network = window._nearNetwork || 'mainnet'; - window._nearConnector = new mod.NearConnector({ network: network }); - } - var connector = window._nearConnector; - - // 3. Connect wallet and request signature. - if (nearBtn) nearBtn.textContent = I18n.t('auth.signWithWallet'); - var wallet = await connector.connect(); - var accounts = await wallet.getAccounts(); - if (!accounts || accounts.length === 0) throw new Error('No NEAR account found'); - - var accountId = accounts[0].accountId; - - // Convert hex nonce to Uint8Array for signMessage. - var nonceBytes = new Uint8Array(challenge.nonce.match(/.{2}/g).map(function(b) { return parseInt(b, 16); })); - - var signed = await wallet.signMessage({ - message: challenge.message, - recipient: challenge.recipient || 'ironclaw', - nonce: nonceBytes, - }); - - // 4. Send signature to server for verification. - if (nearBtn) nearBtn.textContent = I18n.t('auth.verifying'); - var verifyResp = await fetch('/auth/near/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - account_id: accountId, - public_key: signed.publicKey, - signature: signed.signature, - nonce: challenge.nonce, - }), - }); - - if (!verifyResp.ok) { - var errText = await verifyResp.text(); - throw new Error(errText || 'Verification failed'); - } - - await verifyResp.json(); - - // 5. Rely on the HttpOnly session cookie created by the backend. - token = ''; - sessionStorage.removeItem('ironclaw_token'); - initApp(); - } catch (err) { - if (errEl) errEl.textContent = err.message || 'NEAR wallet login failed'; - if (nearBtn) { nearBtn.disabled = false; nearBtn.textContent = I18n.t('auth.social.near'); } - } -} - -// Note: main event listener registration is at the bottom of this file (search -// "Event Listener Registration"). Do NOT add duplicate listeners here. - -// Auto-authenticate from URL param, saved session, or OIDC proxy header. -// -// When behind a reverse proxy that injects auth (e.g., AWS ALB with OIDC), -// the proxy already authenticates every request. We probe /api/gateway/status -// without a token — if the proxy's header lets us through, skip the login -// screen entirely. -(function autoAuth() { - const params = new URLSearchParams(window.location.search); - const urlToken = params.get('token'); - if (urlToken) { - document.getElementById('token-input').value = urlToken; - authenticate(); - return; - } - // Restore OIDC proxy mode from session. - if (sessionStorage.getItem('ironclaw_oidc') === '1') { - oidcProxyAuth = true; - } - const saved = sessionStorage.getItem('ironclaw_token'); - if (saved) { - document.getElementById('token-input').value = saved; - document.getElementById('auth-screen').style.display = 'none'; - document.getElementById('app').style.display = 'flex'; - authenticate(); - return; - } - // Probe for proxy-injected OIDC auth (no token needed from the client). - fetch('/api/gateway/status', { credentials: 'include' }).then(function(r) { - if (r.ok) { - oidcProxyAuth = true; - sessionStorage.setItem('ironclaw_oidc', '1'); - document.getElementById('auth-screen').style.display = 'none'; - document.getElementById('app').style.display = 'flex'; - initApp(); - } - }).catch(function() { /* proxy auth not available, show login */ }); -})(); - -// --- API helper --- - -function apiFetch(path, options) { - const opts = options || {}; - opts.headers = opts.headers || {}; - // In OIDC mode the reverse proxy provides auth; skip the Authorization header. - if (token && !oidcProxyAuth) { - opts.headers['Authorization'] = 'Bearer ' + token; - } - if (opts.body && typeof opts.body === 'object') { - opts.headers['Content-Type'] = 'application/json'; - opts.body = JSON.stringify(opts.body); - } - return fetch(path, opts).then((res) => { - if (!res.ok) { - return res.text().then(function(body) { - const err = new Error(body || (res.status + ' ' + res.statusText)); - err.status = res.status; - throw err; - }); - } - if (res.status === 204) return null; - return res.json(); - }); -} - -// --- Restart Feature --- - -let isRestarting = false; // Track if we're currently restarting -let restartEnabled = false; // Track if restart is available in this deployment - -function triggerRestart() { - if (!currentThreadId) { - alert(I18n.t('error.startConversation')); - return; - } - - // Show the confirmation modal - const confirmModal = document.getElementById('restart-confirm-modal'); - confirmModal.style.display = 'flex'; -} - -function confirmRestart() { - if (!currentThreadId) { - alert(I18n.t('error.startConversation')); - return; - } - - // Hide confirmation modal - const confirmModal = document.getElementById('restart-confirm-modal'); - confirmModal.style.display = 'none'; - - const restartBtn = document.getElementById('restart-btn'); - const restartIcon = document.getElementById('restart-icon'); - - // Mark as restarting - isRestarting = true; - restartBtn.disabled = true; - if (restartIcon) restartIcon.classList.add('spinning'); - - // Show progress modal - const loaderEl = document.getElementById('restart-loader'); - loaderEl.style.display = 'flex'; - - // Send restart command via chat - console.log('[confirmRestart] Sending /restart command to server'); - apiFetch('/api/chat/send', { - method: 'POST', - body: { - content: '/restart', - thread_id: currentThreadId, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - }) - .then((response) => { - console.log('[confirmRestart] API call succeeded, response:', response); - }) - .catch((err) => { - console.error('[confirmRestart] Restart request failed:', err); - addMessage('system', I18n.t('error.restartFailed', { message: err.message })); - isRestarting = false; - restartBtn.disabled = false; - if (restartIcon) restartIcon.classList.remove('spinning'); - loaderEl.style.display = 'none'; - }); -} - -function cancelRestart() { - const confirmModal = document.getElementById('restart-confirm-modal'); - confirmModal.style.display = 'none'; -} - -function tryShowRestartModal() { - // Defensive callback for when restart is detected in messages. - if (!isRestarting) { - isRestarting = true; - const restartBtn = document.getElementById('restart-btn'); - const restartIcon = document.getElementById('restart-icon'); - restartBtn.disabled = true; - if (restartIcon) restartIcon.classList.add('spinning'); - - // Show progress modal - const loaderEl = document.getElementById('restart-loader'); - loaderEl.style.display = 'flex'; - } -} - -function updateRestartButtonVisibility() { - const restartBtn = document.getElementById('restart-btn'); - if (restartBtn) { - restartBtn.style.display = restartEnabled ? 'block' : 'none'; - } -} - -// --- SSE --- - -function rememberSseEventId(event) { - if (!event || !event.lastEventId) return; - _lastSseEventId = event.lastEventId; - window.__e2e = window.__e2e || {}; - window.__e2e.lastSseEventId = event.lastEventId; -} - -function connectSSE(lastEventIdOverride) { - if (eventSource) eventSource.close(); - cleanupConnectionState(); - - // In OIDC mode the reverse proxy provides auth; no query token needed. - let chatSseUrl = (token && !oidcProxyAuth) - ? '/api/chat/events?token=' + encodeURIComponent(token) - : '/api/chat/events'; - const lastEventId = lastEventIdOverride || _lastSseEventId; - if (lastEventId) { - chatSseUrl += (chatSseUrl.includes('?') ? '&' : '?') - + 'last_event_id=' + encodeURIComponent(lastEventId); - } - eventSource = new EventSource(chatSseUrl); - - const addTrackedEventListener = (eventType, handler) => { - eventSource.addEventListener(eventType, (event) => { - rememberSseEventId(event); - handler(event); - }); - }; - - eventSource.onopen = () => { - document.getElementById('sse-dot').classList.remove('disconnected'); - var statusEl = document.getElementById('sse-status'); - if (statusEl) statusEl.textContent = I18n.t('status.connected'); - _reconnectAttempts = 0; - // Clear stale turn-tracking state from before the disconnect - _turnResponseReceived = false; - if (_doneWithoutResponseTimer) { - clearTimeout(_doneWithoutResponseTimer); - _doneWithoutResponseTimer = null; - } - - // Dismiss connection-lost banner and show reconnected flash - if (_connectionLostTimer) { - clearTimeout(_connectionLostTimer); - _connectionLostTimer = null; - } - const lostBanner = document.getElementById('connection-banner'); - if (lostBanner) { - lostBanner.textContent = I18n.t('connection.reconnected'); - lostBanner.className = 'connection-banner connection-banner-success'; - setTimeout(() => { lostBanner.remove(); }, 2000); - } - - // If we were restarting, close the modal and reset button now that server is back - if (isRestarting) { - const loaderEl = document.getElementById('restart-loader'); - if (loaderEl) loaderEl.style.display = 'none'; - const restartBtn = document.getElementById('restart-btn'); - const restartIcon = document.getElementById('restart-icon'); - if (restartBtn) restartBtn.disabled = false; - if (restartIcon) restartIcon.classList.remove('spinning'); - isRestarting = false; - } - - if (sseHasConnectedBefore && currentThreadId) { - finalizeActivityGroup(); - // Only reload full history if disconnected beyond the threshold. Brief - // reconnects (tab visibility change, transient network blip) rely on - // SSE catch-up and the "Done without response" safety net (#2079). - // Full re-render loses scroll position and disrupts the user. - const disconnectMs = _sseDisconnectedAt ? Date.now() - _sseDisconnectedAt : 0; - if (disconnectMs > SSE_RELOAD_THRESHOLD_MS) { - loadHistory(); - } - } - _sseDisconnectedAt = null; - // Clear stale processing state — agents may have finished during disconnect. - // Refresh sidebar so stale spinners are removed immediately. - processingThreads.clear(); - debouncedLoadThreads(); - sseHasConnectedBefore = true; - }; - - eventSource.onerror = () => { - _sseDisconnectedAt = _sseDisconnectedAt || Date.now(); - _reconnectAttempts++; - document.getElementById('sse-dot').classList.add('disconnected'); - var statusEl2 = document.getElementById('sse-status'); - if (statusEl2) statusEl2.textContent = I18n.t('status.reconnecting'); - - // Update existing banner with attempt count - const existingBanner = document.getElementById('connection-banner'); - if (existingBanner && existingBanner.classList.contains('connection-banner-warning')) { - existingBanner.textContent = I18n.t('connection.reconnecting', { count: _reconnectAttempts }); - } - - // Start connection-lost banner timer (3s delay) - if (!_connectionLostTimer && !existingBanner) { - _connectionLostTimer = setTimeout(() => { - _connectionLostTimer = null; - // Only show if still disconnected - const dot = document.getElementById('sse-dot'); - if (dot?.classList.contains('disconnected')) { - showConnectionBanner(I18n.t('connection.reconnecting', { count: _reconnectAttempts }), 'warning'); - } - }, 3000); - } - }; - - // Forward all SSE events to registered widget handlers. - // Wraps addEventListener to intercept every named event and dispatch - // to widget subscribers before the built-in handler runs. - // Must run before any addTrackedEventListener calls so the wrapper is in place. - // - // NOTE: Only NAMED events (those dispatched via `addEventListener('foo', …)` - // by the gateway, see `SseEvent` in `src/channels/web/types.rs`) are - // forwarded. The generic `eventSource.onmessage` handler is intentionally - // NOT wrapped because the IronClaw gateway never emits SSE frames without - // an `event:` field — every frame carries a typed name (`response`, - // `tool_started`, `gate_required`, etc.). Widget authors should subscribe - // to those typed events via `IronClaw.api.on('', handler)` - // rather than relying on the generic message channel; if a widget needs - // an untyped stream it must open its own `EventSource`. - var _origAddEventListener = eventSource.addEventListener.bind(eventSource); - eventSource.addEventListener = function(type, listener, opts) { - _origAddEventListener(type, function(e) { - // Dispatch to widget handlers - if (IronClaw.api && e.data) { - try { - var parsed = JSON.parse(e.data); - IronClaw.api._dispatch(type, parsed); - } catch (parseErr) { - console.warn('[IronClaw] SSE parse error for event', type, parseErr); - } - } - // Call original handler - listener(e); - }, opts); - }; - - addTrackedEventListener('response', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) activeWorkStore.clearThread(data.thread_id); - if (!isCurrentThread(data.thread_id)) { - if (data.thread_id) { - unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1); - debouncedLoadThreads(); - } - return; - } - // Flush any remaining streaming buffer - if (_streamDebounceTimer) { - clearInterval(_streamDebounceTimer); - _streamDebounceTimer = null; - } - if (_streamBuffer) { - appendToLastAssistant(_streamBuffer); - _streamBuffer = ''; - } - // Remove streaming attribute from active assistant message - const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]'); - if (streamingMsg) streamingMsg.removeAttribute('data-streaming'); - - _turnResponseReceived = true; - if (_doneWithoutResponseTimer) { - clearTimeout(_doneWithoutResponseTimer); - _doneWithoutResponseTimer = null; - } - finalizeActivityGroup(); - addMessage('assistant', data.content); - pruneOldMessages(); - enableChatInput(); - // Refresh thread list so new titles appear after first message - loadThreads(); - - // Turn complete — remove oldest pending entry for this thread (#2409). - // FIFO is safe here because the agent loop processes one turn at a time - // per thread, so the oldest pending entry is the one that just completed. - const pending = _pendingUserMessages.get(data.thread_id); - if (pending) { - pending.shift(); - if (pending.length === 0) _pendingUserMessages.delete(data.thread_id); - } - - // Show restart modal if the response indicates restart was initiated - if (data.content && data.content.toLowerCase().includes('restart initiated')) { - setTimeout(() => tryShowRestartModal(), 500); - } - }); - - addTrackedEventListener('thinking', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - activeWorkStore.updateThread(data.thread_id, { - statusText: data.message || ActivityEntry.t('activity.thinking', 'Thinking'), - }); - } - if (!isCurrentThread(data.thread_id)) { - if (data.thread_id) { - processingThreads.add(data.thread_id); - debouncedLoadThreads(); - } - return; - } - clearSuggestionChips(); - showActivityThinking(data.message); - }); - - addTrackedEventListener('suggestions', (e) => { - const data = JSON.parse(e.data); - if (!isCurrentThread(data.thread_id)) return; - if (data.suggestions && data.suggestions.length > 0) { - showSuggestionChips(data.suggestions); - } - }); - - addTrackedEventListener('skill_activated', (e) => { - const data = JSON.parse(e.data); - if (!isCurrentThread(data.thread_id)) return; - const names = Array.isArray(data.skill_names) ? data.skill_names : []; - const feedback = Array.isArray(data.feedback) ? data.feedback : []; - if (names.length === 0 && feedback.length === 0) return; - addSkillActivationCard(names, feedback); - }); - - addTrackedEventListener('tool_started', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - activeWorkStore.updateThread(data.thread_id, { - statusText: ActivityEntry.t('activity.usingTool', 'Using {name}', { - name: data.name, - }), - }); - } - if (!isCurrentThread(data.thread_id)) { - if (data.thread_id) { - processingThreads.add(data.thread_id); - debouncedLoadThreads(); - } - return; - } - addToolCard(data); - }); - - addTrackedEventListener('tool_completed', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - activeWorkStore.updateThread(data.thread_id, { - statusText: data.success - ? ActivityEntry.t('activity.finishedTool', 'Finished {name}', { name: data.name }) - : ActivityEntry.t('activity.failedTool', 'Failed {name}', { name: data.name }), - }); - } - if (!isCurrentThread(data.thread_id)) return; - completeToolCard(data); - - // Show restart modal only when the restart tool succeeds - if (data.name.toLowerCase() === 'restart' && data.success) { - setTimeout(() => tryShowRestartModal(), 500); - } - }); - - addTrackedEventListener('tool_result', (e) => { - const data = JSON.parse(e.data); - if (!isCurrentThread(data.thread_id)) return; - setToolCardOutput(data); - }); - - addTrackedEventListener('stream_chunk', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - activeWorkStore.updateThread(data.thread_id, { - statusText: ActivityEntry.t('activity.streamingResponse', 'Streaming response'), - }); - } - if (!isCurrentThread(data.thread_id)) { - if (data.thread_id) { - processingThreads.add(data.thread_id); - debouncedLoadThreads(); - } - return; - } - finalizeActivityGroup(); - - // Mark the active assistant message as streaming - const container = document.getElementById('chat-messages'); - let lastAssistant = container.querySelector('.message.assistant:last-of-type'); - if (!lastAssistant) { - addMessage('assistant', ''); - lastAssistant = container.querySelector('.message.assistant:last-of-type'); - } - if (lastAssistant) lastAssistant.setAttribute('data-streaming', 'true'); - - // Mark turn as having received content so the Done safety net - // does not trigger a spurious loadHistory() for streaming responses. - _turnResponseReceived = true; - - // Accumulate chunks and debounce rendering at 50ms intervals - _streamBuffer += data.content; - // Force flush when buffer exceeds 10K chars to prevent memory buildup - if (_streamBuffer.length > 10000) { - appendToLastAssistant(_streamBuffer); - _streamBuffer = ''; - } - if (!_streamDebounceTimer) { - _streamDebounceTimer = setInterval(() => { - if (_streamBuffer) { - appendToLastAssistant(_streamBuffer); - _streamBuffer = ''; - } - }, STREAM_DEBOUNCE_MS); - } - }); - - addTrackedEventListener('status', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - const isBlockedStatus = activeWorkStore.isThreadBlocked(data.thread_id); - if (data.message === 'Done' || data.message === 'Interrupted' - || data.message === 'Rejected' || data.message === 'Tool call denied.') { - activeWorkStore.clearThread(data.thread_id); - } else if (data.message === 'Awaiting approval') { - activeWorkStore.updateThread(data.thread_id, { - statusText: ActivityEntry.t('activity.waitingApproval', 'Waiting for approval'), - blockedReason: 'approval', - }); - } else if (isBlockedStatus) { - // Keep the user-visible blocked state until the gate resolves. Generic - // step/status updates from the runner are less informative here. - } else if (data.message) { - activeWorkStore.updateThread(data.thread_id, { - statusText: data.message, - blockedReason: null, - }); - } - } - if (!isCurrentThread(data.thread_id)) { - if (data.thread_id) { - if (data.message === 'Done' || data.message === 'Awaiting approval' - || data.message === 'Interrupted' || data.message === 'Rejected' - || data.message === 'Tool call denied.') { - processingThreads.delete(data.thread_id); - } - debouncedLoadThreads(); - } - return; - } - // "Done" and "Awaiting approval" are terminal signals from the agent: - // the agentic loop finished, so re-enable input as a safety net in case - // the response SSE event is empty or lost. - // Status text is not displayed — inline activity cards handle visual feedback. - if (data.message === 'Done' || data.message === 'Awaiting approval') { - finalizeActivityGroup(); - enableChatInput(); - // Safety net (#2079): if "Done" arrives but we never received a - // `response` event for this turn, the message may have been lost - // (broadcast lag, proxy buffering, brief SSE disconnect). Reload - // history after a short delay so the user sees the answer. - if (!_turnResponseReceived && data.message === 'Done') { - if (!_doneWithoutResponseTimer) { - _doneWithoutResponseTimer = setTimeout(() => { - _doneWithoutResponseTimer = null; - if (currentThreadId) loadHistory(); - }, DONE_WITHOUT_RESPONSE_TIMEOUT_MS); - } - } - _turnResponseReceived = false; - } - }); - - addTrackedEventListener('job_started', (e) => { - const data = JSON.parse(e.data); - activeWorkStore.updateJob(data.job_id, { - title: data.title, - statusText: ActivityEntry.t('activity.starting', 'Starting'), - state: 'running', - }); - showJobCard(data); - }); - - addTrackedEventListener('approval_needed', (e) => { - const data = JSON.parse(e.data); - const hasThread = !!data.thread_id; - const forCurrentThread = !hasThread || isCurrentThread(data.thread_id); - if (data.thread_id) { - activeWorkStore.updateThread(data.thread_id, { - statusText: ActivityEntry.t('activity.waitingApproval', 'Waiting for approval'), - blockedReason: 'approval', - }); - } - - if (forCurrentThread) { - showApproval(data); - } else { - // Keep thread list fresh when approval is requested in a background thread. - unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1); - debouncedLoadThreads(); - } - - // Extension setup flows can surface approvals from any settings subtab. - if (currentTab === 'settings') refreshCurrentSettingsTab(); - }); - - addTrackedEventListener('onboarding_state', (e) => { - const data = JSON.parse(e.data); - handleOnboardingState(data); - }); - - addTrackedEventListener('gate_required', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - const isCredentialGate = data.gate_name === 'credential' || data.gate_name === 'auth'; - activeWorkStore.updateThread(data.thread_id, { - statusText: isCredentialGate - ? ActivityEntry.t('activity.waitingAuth', 'Waiting for auth') - : ActivityEntry.t('activity.waitingApproval', 'Waiting for approval'), - blockedReason: isCredentialGate ? 'auth' : 'approval', - }); - } - handleGateRequired(data); - }); - - addTrackedEventListener('gate_resolved', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id) { - activeWorkStore.updateThread(data.thread_id, { - statusText: ActivityEntry.t('activity.resuming', 'Resuming'), - blockedReason: null, - }); - } - handleGateResolved(data); - }); - - addTrackedEventListener('extension_status', (e) => { - if (currentTab === 'settings') refreshCurrentSettingsTab(); - }); - - addTrackedEventListener('image_generated', (e) => { - const data = JSON.parse(e.data); - if (!isCurrentThread(data.thread_id)) return; - rememberGeneratedImage(data.thread_id, data.event_id, data.data_url, data.path); - addGeneratedImage(data.data_url, data.path, data.event_id); - }); - - addTrackedEventListener('error', (e) => { - if (e.data) { - const data = JSON.parse(e.data); - if (data.thread_id) activeWorkStore.clearThread(data.thread_id); - if (!isCurrentThread(data.thread_id)) return; - finalizeActivityGroup(); - addMessage('system', 'Error: ' + data.message); - enableChatInput(); - } - }); - - // Job event listeners (activity stream for all sandbox jobs) - const jobEventTypes = [ - 'job_message', 'job_tool_use', 'job_tool_result', - 'job_status', 'job_result' - ]; - for (const evtType of jobEventTypes) { - addTrackedEventListener(evtType, (e) => { - const data = JSON.parse(e.data); - const jobId = data.job_id; - if (!jobId) return; - if (evtType === 'job_message') { - activeWorkStore.updateJob(jobId, { - statusText: (data.role ? data.role + ': ' : '') + (data.content || ActivityEntry.t('activity.working', 'Working')), - }); - } else if (evtType === 'job_tool_use') { - activeWorkStore.updateJob(jobId, { - statusText: ActivityEntry.t('activity.runningTool', 'Running {name}', { - name: data.tool_name || ActivityEntry.t('activity.tool', 'tool'), - }), - }); - } else if (evtType === 'job_tool_result') { - activeWorkStore.updateJob(jobId, { - statusText: ActivityEntry.t('activity.finishedTool', 'Finished {name}', { - name: data.tool_name || ActivityEntry.t('activity.tool', 'tool'), - }), - }); - } else if (evtType === 'job_status') { - activeWorkStore.updateJob(jobId, { - statusText: data.message || JobActivityEntry.formatStatus('running'), - }); - } else if (evtType === 'job_result') { - activeWorkStore.updateJob(jobId, { - active: false, - state: JobActivityEntry.normalizeState(data.status), - statusText: JobActivityEntry.formatStatus(data.status), - }); - } - // Move jobId to end of Map insertion order (LRU: most-recent last). - // delete+set keeps the Map ordered by last-access time so that - // keys().next() always yields the least-recently-used entry in O(1). - const existing = jobEvents.get(jobId); - if (existing) jobEvents.delete(jobId); - const events = existing || []; - jobEvents.set(jobId, events); - events.push({ type: evtType, data: data, ts: Date.now() }); - // Cap per-job events to prevent memory leak - while (events.length > JOB_EVENTS_CAP) events.shift(); - // Cap total tracked jobs — evict the least-recently-used entry (O(1)). - // Skip currentJobId so the user's actively-viewed job detail panel - // doesn't go empty when many other jobs fire events. - if (jobEvents.size > JOB_EVENTS_MAX_JOBS) { - let evicted = false; - for (const k of jobEvents.keys()) { - if (k !== currentJobId) { - jobEvents.delete(k); - evicted = true; - break; - } - } - // Fallback: if every entry is currentJobId (impossible in practice), - // evict the first key to maintain the cap. - if (!evicted) { - jobEvents.delete(jobEvents.keys().next().value); - } - } - // If the Activity tab is currently visible for this job, refresh it - refreshActivityTab(jobId); - // Auto-refresh job list when on jobs tab (debounced) - if ((evtType === 'job_result' || evtType === 'job_status') && currentTab === 'jobs' && !currentJobId) { - clearTimeout(jobListRefreshTimer); - jobListRefreshTimer = setTimeout(loadJobs, 200); - } - // Clean up finished job events after a viewing window - if (evtType === 'job_result') { - setTimeout(() => jobEvents.delete(jobId), 60000); - } - }); - } - - // Plan progress checklist - addTrackedEventListener('plan_update', (e) => { - const data = JSON.parse(e.data); - if (data.thread_id && !isCurrentThread(data.thread_id)) return; - renderPlanChecklist(data); - }); -} - -// Check if an SSE event belongs to the currently viewed thread. -// Events without a thread_id are dropped (prevents notification leaking). -function isCurrentThread(threadId) { - if (!threadId) return false; - if (!currentThreadId) return true; - return threadId === currentThreadId; -} - -// --- Suggestion Chips --- - -function showSuggestionChips(suggestions) { - // Clear previous chips/ghost without restoring placeholder (we'll set it below) - _ghostSuggestion = ''; - const container = document.getElementById('suggestion-chips'); - container.innerHTML = ''; - const ghost = document.getElementById('ghost-text'); - ghost.style.display = 'none'; - const wrapper = document.querySelector('.chat-input-wrapper'); - if (wrapper) wrapper.classList.remove('has-ghost'); - - _ghostSuggestion = suggestions[0] || ''; - const input = document.getElementById('chat-input'); - suggestions.forEach(text => { - const chip = document.createElement('button'); - chip.className = 'suggestion-chip'; - chip.textContent = text; - chip.addEventListener('click', () => { - input.value = text; - clearSuggestionChips(); - autoResizeTextarea(input); - input.focus(); - sendMessage(); - }); - container.appendChild(chip); - }); - container.style.display = 'flex'; - // Show first suggestion as ghost text in the input so user knows Tab works - if (_ghostSuggestion && input.value === '') { - ghost.textContent = _ghostSuggestion; - ghost.style.display = 'block'; - input.closest('.chat-input-wrapper').classList.add('has-ghost'); - } -} - -function clearSuggestionChips() { - _ghostSuggestion = ''; - const container = document.getElementById('suggestion-chips'); - if (container) { - container.innerHTML = ''; - container.style.display = 'none'; - } - const ghost = document.getElementById('ghost-text'); - if (ghost) ghost.style.display = 'none'; - const wrapper = document.querySelector('.chat-input-wrapper'); - if (wrapper) wrapper.classList.remove('has-ghost'); -} - -// --- Chat --- - -function sendMessage() { - clearSuggestionChips(); - removeWelcomeCard(); - _turnResponseReceived = false; - if (_doneWithoutResponseTimer) { - clearTimeout(_doneWithoutResponseTimer); - _doneWithoutResponseTimer = null; - } - const input = document.getElementById('chat-input'); - if (authFlowPending) { - showToast(I18n.t('chat.authRequiredBeforeSend'), 'info'); - const tokenField = document.querySelector('.auth-card .auth-token-input input'); - if (tokenField) tokenField.focus(); - return; - } - if (!currentThreadId) { - console.warn('sendMessage: no thread selected, ignoring'); - return; - } - if (_sendCooldown) return; - const content = input.value.trim(); - if (!content && stagedImages.length === 0) return; - - // Intercept approval keywords when an unresolved approval card is pending. - // Find the most recent unresolved card for the current thread (resolved cards - // linger 1.5s before removal; cards from other threads must not be matched). - const approvalCards = Array.from(document.querySelectorAll('.approval-card')); - const approvalCard = approvalCards.reverse().find(card => { - if (card.querySelector('.approval-resolved')) return false; - const cardThreadId = card.getAttribute('data-thread-id'); - return !cardThreadId || cardThreadId === currentThreadId; - }); - if (approvalCard && content) { - const lower = content.toLowerCase(); - let action = null; - if (['yes', 'y', 'approve', 'ok', '/approve', '/yes', '/y'].includes(lower)) { - action = 'approve'; - } else if (['always', 'a', 'yes always', 'approve always', '/always', '/a'].includes(lower)) { - action = 'always'; - } else if (['no', 'n', 'deny', 'reject', 'cancel', '/deny', '/no', '/n'].includes(lower)) { - action = 'deny'; - } - if (action) { - input.value = ''; - autoResizeTextarea(input); - input.focus(); - const requestId = approvalCard.getAttribute('data-request-id'); - const threadId = approvalCard.getAttribute('data-thread-id'); - if (requestId) { - sendApprovalAction(requestId, action, threadId); - } - return; - } - } - - // Snapshot attached images before the body block clears stagedImages, so the - // optimistic display and the pending entry both keep them. - const attachedImageDataUrls = stagedImages.map(img => img.dataUrl); - const userMsg = addMessage('user', content || '(images attached)'); - if (attachedImageDataUrls.length > 0) { - appendImagesToMessage(userMsg, attachedImageDataUrls); - } - pruneOldMessages(); - if (currentThreadId) { - activeWorkStore.updateThread(currentThreadId, { - statusText: ActivityEntry.t('activity.starting', 'Starting'), - }); - } - input.value = ''; - autoResizeTextarea(input); - input.focus(); - - // Track as pending so loadHistory() can re-inject if DB hasn't persisted yet (#2409) - let pendingId = null; - const pendingThreadId = currentThreadId; - if (currentThreadId) { - const displayContent = content || '(images attached)'; - if (!_pendingUserMessages.has(currentThreadId)) { - _pendingUserMessages.set(currentThreadId, []); - } - pendingId = _nextPendingId++; - _pendingUserMessages.get(currentThreadId).push({ - id: pendingId, - content: displayContent, - images: attachedImageDataUrls, - timestamp: Date.now(), - }); - } - - const body = { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }; - if (stagedImages.length > 0) { - body.images = stagedImages.map(img => ({ media_type: img.media_type, data: img.data })); - stagedImages = []; - renderImagePreviews(); - } - - apiFetch('/api/chat/send', { - method: 'POST', - body: body, - }).catch((err) => { - // Remove the pending entry so it won't be re-injected on thread switch (#2498) - if (pendingId !== null && pendingThreadId) { - const arr = _pendingUserMessages.get(pendingThreadId); - if (arr) { - const filtered = arr.filter(p => p.id !== pendingId); - if (filtered.length > 0) { - _pendingUserMessages.set(pendingThreadId, filtered); - } else { - _pendingUserMessages.delete(pendingThreadId); - } - } - } - // Handle rate limiting (429) - if (err.status === 429) { - showToast(I18n.t('chat.rateLimited'), 'error'); - _sendCooldown = true; - const sendBtn = document.getElementById('send-btn'); - if (sendBtn) sendBtn.disabled = true; - setTimeout(() => { - _sendCooldown = false; - if (sendBtn) sendBtn.disabled = false; - }, 2000); - } - // Keep the user message in DOM, add a retry link - if (userMsg) { - userMsg.classList.add('send-failed'); - userMsg.style.borderStyle = 'dashed'; - const retryLink = document.createElement('a'); - retryLink.className = 'retry-link'; - retryLink.href = '#'; - retryLink.textContent = I18n.t('common.retry'); - retryLink.addEventListener('click', (e) => { - e.preventDefault(); - if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg); - input.value = content; - sendMessage(); - }); - userMsg.appendChild(retryLink); - } - }); -} - -function enableChatInput() { - if (currentThreadIsReadOnly || authFlowPending) return; - const input = document.getElementById('chat-input'); - const btn = document.getElementById('send-btn'); - if (input) { - input.disabled = false; - input.placeholder = I18n.t('chat.inputPlaceholder'); - } - if (btn) btn.disabled = false; -} - -// --- Image Upload --- - -function renderImagePreviews() { - const strip = document.getElementById('image-preview-strip'); - strip.innerHTML = ''; - stagedImages.forEach((img, idx) => { - const container = document.createElement('div'); - container.className = 'image-preview-container'; - - const preview = document.createElement('img'); - preview.className = 'image-preview'; - preview.src = img.dataUrl; - preview.alt = 'Attached image'; - - const removeBtn = document.createElement('button'); - removeBtn.className = 'image-preview-remove'; - removeBtn.textContent = '\u00d7'; - removeBtn.addEventListener('click', () => { - stagedImages.splice(idx, 1); - renderImagePreviews(); - }); - - container.appendChild(preview); - container.appendChild(removeBtn); - strip.appendChild(container); - }); -} - -const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB per image -const MAX_STAGED_IMAGES = 5; - -function handleImageFiles(files) { - Array.from(files).forEach(file => { - if (!file.type.startsWith('image/')) return; - if (file.size > MAX_IMAGE_SIZE_BYTES) { - alert(I18n.t('chat.imageTooBig', { name: file.name, size: (file.size / 1024 / 1024).toFixed(1) })); - return; - } - if (stagedImages.length >= MAX_STAGED_IMAGES) { - alert(I18n.t('chat.maxImages', { n: MAX_STAGED_IMAGES })); - return; - } - const reader = new FileReader(); - reader.onload = function(e) { - const dataUrl = e.target.result; - const commaIdx = dataUrl.indexOf(','); - const meta = dataUrl.substring(0, commaIdx); // e.g. "data:image/png;base64" - const base64 = dataUrl.substring(commaIdx + 1); - const mediaType = meta.replace('data:', '').replace(';base64', ''); - stagedImages.push({ media_type: mediaType, data: base64, dataUrl: dataUrl }); - renderImagePreviews(); - }; - reader.readAsDataURL(file); - }); -} - -document.getElementById('attach-btn').addEventListener('click', () => { - document.getElementById('image-file-input').click(); -}); - -document.getElementById('image-file-input').addEventListener('change', (e) => { - handleImageFiles(e.target.files); - e.target.value = ''; -}); - -document.getElementById('chat-input').addEventListener('paste', (e) => { - const items = (e.clipboardData || e.originalEvent.clipboardData).items; - for (let i = 0; i < items.length; i++) { - if (items[i].kind === 'file' && items[i].type.startsWith('image/')) { - const file = items[i].getAsFile(); - if (file) handleImageFiles([file]); - } - } -}); - -const chatMessagesEl = document.getElementById('chat-messages'); -chatMessagesEl.addEventListener('copy', (e) => { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) return; - const anchorNode = selection.anchorNode; - const focusNode = selection.focusNode; - if (!anchorNode || !focusNode) return; - if (!chatMessagesEl.contains(anchorNode) || !chatMessagesEl.contains(focusNode)) return; - const text = selection.toString(); - if (!text || !e.clipboardData) return; - // Force plain-text clipboard output so dark-theme styling never leaks on paste. - e.preventDefault(); - e.clipboardData.clearData(); - e.clipboardData.setData('text/plain', text); -}); - -function createGeneratedImageElement(dataUrl, path, eventId) { - const card = document.createElement('div'); - card.className = 'generated-image-card'; - if (eventId) { - card.dataset.imageEventId = eventId; - } - - if (isSafeGeneratedImageDataUrl(dataUrl)) { - const img = document.createElement('img'); - img.className = 'generated-image'; - img.src = dataUrl; - img.alt = 'Generated image'; - card.appendChild(img); - } else { - const placeholder = document.createElement('div'); - placeholder.className = 'generated-image-placeholder'; - placeholder.textContent = 'Generated image unavailable in history payload'; - card.appendChild(placeholder); - } - - if (path) { - const pathLabel = document.createElement('div'); - pathLabel.className = 'generated-image-path'; - pathLabel.textContent = path; - card.appendChild(pathLabel); - } - - return card; -} - -function isSafeGeneratedImageDataUrl(dataUrl) { - return typeof dataUrl === 'string' && /^data:image\//i.test(dataUrl); -} - -function hasRenderedGeneratedImage(container, eventId) { - if (!eventId) return false; - return Array.from(container.querySelectorAll('.generated-image-card')).some((card) => { - return card.dataset.imageEventId === eventId; - }); -} - -function addGeneratedImage(dataUrl, path, eventId, shouldScroll = true) { - const container = document.getElementById('chat-messages'); - if (hasRenderedGeneratedImage(container, eventId)) { - return; - } - const card = createGeneratedImageElement(dataUrl, path, eventId); - container.appendChild(card); - if (shouldScroll) { - container.scrollTop = container.scrollHeight; - } -} - -function rememberGeneratedImage(threadId, eventId, dataUrl, path) { - if (!threadId || !eventId || !isSafeGeneratedImageDataUrl(dataUrl)) return; - const normalizedPath = path || null; - let images = generatedImagesByThread.get(threadId); - if (!images) { - if (generatedImagesByThread.size >= GENERATED_IMAGE_THREAD_CACHE_CAP) { - const oldestThreadId = generatedImagesByThread.keys().next().value; - if (oldestThreadId) { - generatedImagesByThread.delete(oldestThreadId); - } - } - images = []; - generatedImagesByThread.set(threadId, images); - } else { - // Refresh insertion order so recently viewed/updated threads stay cached. - generatedImagesByThread.delete(threadId); - generatedImagesByThread.set(threadId, images); - } - if (images.some(img => img.eventId === eventId)) { - return; - } - images.push({ eventId, dataUrl, path: normalizedPath }); - while (images.length > GENERATED_IMAGES_PER_THREAD_CAP) { - images.shift(); - } -} - -function getRememberedGeneratedImage(threadId, eventId) { - if (!threadId || !eventId) return null; - const images = generatedImagesByThread.get(threadId); - if (!images) return null; - return images.find(img => img.eventId === eventId) || null; -} - -function resolveGeneratedImageForRender(threadId, image) { - const normalizedPath = image.path || null; - if (image.data_url) { - return { dataUrl: image.data_url, path: normalizedPath }; - } - const remembered = getRememberedGeneratedImage(threadId, image.event_id); - if (remembered) { - return { dataUrl: remembered.dataUrl, path: remembered.path }; - } - return { dataUrl: null, path: normalizedPath }; -} - -// --- Slash Autocomplete --- - -function showSlashAutocomplete(matches) { - const el = document.getElementById('slash-autocomplete'); - if (!el || matches.length === 0) { hideSlashAutocomplete(); return; } - _slashMatches = matches; - _slashSelected = -1; - el.innerHTML = ''; - matches.forEach((item, i) => { - const row = document.createElement('div'); - row.className = 'slash-ac-item'; - row.dataset.index = i; - var cmdSpan = document.createElement('span'); - cmdSpan.className = 'slash-ac-cmd'; - cmdSpan.textContent = item.cmd; - var descSpan = document.createElement('span'); - descSpan.className = 'slash-ac-desc'; - descSpan.textContent = item.desc; - row.appendChild(cmdSpan); - row.appendChild(descSpan); - row.addEventListener('mousedown', (e) => { - e.preventDefault(); // prevent blur - selectSlashItem(item.cmd); - }); - el.appendChild(row); - }); - el.style.display = 'block'; -} - -function hideSlashAutocomplete() { - const el = document.getElementById('slash-autocomplete'); - if (el) el.style.display = 'none'; - _slashSelected = -1; - _slashMatches = []; -} - -function selectSlashItem(cmd) { - const input = document.getElementById('chat-input'); - input.value = cmd + ' '; - input.focus(); - hideSlashAutocomplete(); - autoResizeTextarea(input); -} - -function updateSlashHighlight() { - const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item'); - items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected)); - if (_slashSelected >= 0 && items[_slashSelected]) { - items[_slashSelected].scrollIntoView({ block: 'nearest' }); - } -} - -function filterSlashCommands(value) { - if (!value.startsWith('/')) { hideSlashAutocomplete(); return; } - // Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new) - const lower = value.toLowerCase(); - const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower)); - if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) { - hideSlashAutocomplete(); - } else { - showSlashAutocomplete(matches); - } -} - -function sendApprovalAction(requestId, action, threadId) { - const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]'); - const targetThreadId = threadId || (card ? card.getAttribute('data-thread-id') : null) || currentThreadId; - apiFetch('/api/chat/gate/resolve', { - method: 'POST', - body: { - request_id: requestId, - thread_id: targetThreadId, - resolution: action === 'deny' ? 'denied' : 'approved', - always: action === 'always', - }, - }).catch((err) => { - addMessage('system', 'Failed to send approval: ' + err.message); - }); - - // Disable buttons and show confirmation on the card - if (card) { - const buttons = card.querySelectorAll('.approval-actions button'); - buttons.forEach((btn) => { - btn.disabled = true; - }); - const actions = card.querySelector('.approval-actions'); - const label = document.createElement('span'); - label.className = 'approval-resolved'; - const labelText = action === 'approve' ? I18n.t('approval.approved') : action === 'always' ? I18n.t('approval.alwaysApproved') : I18n.t('approval.denied'); - label.textContent = labelText; - actions.appendChild(label); - // Remove the card after showing the confirmation briefly - setTimeout(() => { card.remove(); }, 1500); - } -} - -function renderMarkdown(text) { - if (typeof marked !== 'undefined') { - // Escape raw HTML error pages instead of rendering them as markup. - // Only triggers when the text *starts with* a doctype or tag - // (after optional whitespace), so normal messages that mention HTML - // tags in prose or code fences are not affected. See #263. - if (/^\s*]/i.test(text)) { - return escapeHtml(text); - } - let html = marked.parse(text); - // Sanitize HTML output to prevent XSS from tool output or LLM responses. - html = sanitizeRenderedHtml(html); - // Inject copy buttons into
 blocks
-    html = html.replace(/
/g, '
');
-    return html;
-  }
-  return escapeHtml(text);
-}
-
-// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output
-// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer
-// that handles all known bypass vectors (SVG onload, newline-split event
-// handlers, mutation XSS, etc.) unlike the regex approach it replaces.
-function sanitizeRenderedHtml(html) {
-  if (typeof DOMPurify !== 'undefined') {
-    return DOMPurify.sanitize(html, {
-      USE_PROFILES: { html: true },
-      FORBID_TAGS: ['style', 'script'],
-      FORBID_ATTR: ['style', 'onerror', 'onload']
-    });
-  }
-  // DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML
-  return '';
-}
-
-// ==================== Structured Data Rendering ====================
-//
-// Detects JSON objects and key-value data in assistant messages and
-// renders them as styled cards instead of raw text. Also supports
-// extensible chat renderers via IronClaw.registerChatRenderer().
-
-/**
- * Post-process a .message-content element to upgrade structured data into cards.
- * Runs registered chat renderers first, then falls back to built-in JSON detection.
- */
-function upgradeStructuredData(contentEl) {
-  // 1. Run registered chat renderers.
-  //
-  // Each registered renderer receives the live `.message-content` element
-  // and the textContent. The renderer is allowed to mutate the element —
-  // attach event listeners, set data attributes, swap inner DOM — but any
-  // HTML it injects must still pass DOMPurify before it reaches the user.
-  // `renderMarkdown` already runs `sanitizeRenderedHtml` on the markdown
-  // output BEFORE this function is called, but a renderer that does
-  // `contentEl.innerHTML = '
...'` would - // bypass that sanitization step entirely. Re-run the sanitizer on - // whatever the renderer leaves behind so the same HTML allowlist - // applies regardless of how the content got there. - // - // CSP already blocks `