// === MyClaw Frontend v0.3 ===
// Supports: streaming text, reasoning display, tool calls, RAG, approval, loop warnings

(function() {
    'use strict';

    // === DOM refs ===
    const chatEl = document.getElementById('chat');
    const inputEl = document.getElementById('message-input');
    const sendBtn = document.getElementById('send-btn');
    const clearBtn = document.getElementById('clear-context');
    const connStatus = document.getElementById('conn-status');
    // --- kshare knowledge-base config entry ---
    const kshareBaseApi = document.getElementById('kshare-base-api');
    const kshareSaveBtn = document.getElementById('kshare-save');
    const kshareStatusEl = document.getElementById('kshare-status');

    // --- Topbar runtime controls (P1-1): icon buttons + dropdown menus ---
    const autonomyBtn = document.getElementById('autonomy-btn');
    const autonomyMenu = document.getElementById('autonomy-menu');
    const autonomyWrap = document.getElementById('autonomy-wrap');
    const providerBtn = document.getElementById('provider-btn');
    const providerMenu = document.getElementById('provider-menu');
    const providerWrap = document.getElementById('provider-wrap');
    const wsBrowseBtn = document.getElementById('workspace-browse');
    let currentProviders = [];      // [{id, name}]
    let currentProviderId = null;
    let currentProviderName = null;
    let currentAutonomy = 'autonomous';
    let serverPlatform = '';        // os of the SERVER (runtime_status.platform):
                                    // path joining must use the server's native
                                    // separator, not the browser's OS.
    const taskNewBtn = document.getElementById('task-new');

    // --- Skill palette (P1-2) ---
    const skillPalette = document.getElementById('skill-palette');

    // === State ===
    let ws = null;
    let authed = false;
    // Session token (minted by /api/login). Stored in localStorage so the
    // login survives page reloads/restarts (no need to retype the password);
    // sessionStorage is kept as a fallback.
    let sessionToken = localStorage.getItem('mclaw_token') || sessionStorage.getItem('mclaw_session');
    let isProcessing = false;
    let currentThinkingEl = null;
    let currentToolBlock = null;
    let currentStreamEl = null;      // streaming message bubble
    let currentStreamText = '';       // accumulated streaming text
    let currentReasoningEl = null;    // reasoning panel
    let currentReasoningText = '';    // accumulated reasoning text
    // Only the *current* in-flight process element (reasoning panel or tool
    // card) is shown at a time; when the next unit starts it replaces the
    // previous one (scroll-replace UX for the task chain).
    let currentProcessEl = null;
    let skills = [];             // configured slash skills (from /api/skills)
    // --- Task management state (each task = own context) ---
    let tasks = [];              // [{id,name,created_at,messages,active,history}]
    let activeTaskId = null;     // current task id (null = not yet known)
    let draftNewTask = false;    // pane shows the "新建任务" hero, NOT yet a real task
    let pendingNewChat = false;  // just sent a draft → waiting for the new task to appear
    let paneTaskId = null;       // task id whose conversation the chat pane shows

    // === WebSocket ===

    function connect() {
        authed = false;
        const proto = location.protocol === 'https:' ? 'wss' : 'ws';
        let url = proto + '://' + location.host + '/ws';
        if (sessionToken) {
            url += '?token=' + encodeURIComponent(sessionToken);
        }
        ws = new WebSocket(url);

        ws.onopen = function() {
            authed = true;
            connStatus.className = 'status-dot connected';
            connStatus.title = 'Connected';
            send({ type: 'get_status' });
            send({ type: 'task_list' });
            fetchSkills();
        };

        ws.onclose = function() {
            connStatus.className = 'status-dot disconnected';
            connStatus.title = 'Disconnected';
            // Release any in-flight processing state — otherwise a reconnect
            // keeps the input blocked forever (isProcessing stuck true).
            var wasProcessing = isProcessing;
            removeThinking();
            finalizeStream();
            clearProcessEl();
            setProcessing(false);
            // A mid-task disconnect means the run may never finish — tell the
            // user instead of silently reconnecting.
            if (wasProcessing) {
                var errEl = document.createElement('div');
                errEl.className = 'error-block';
                errEl.textContent = '⚠️ 连接中断：任务可能未完成（服务重启或网络断开）。请重新提问。';
                chatEl.appendChild(errEl);
                scrollToBottom();
            }
            // If we never authenticated (no token, or a rejected/expired token),
            // show the login screen instead of looping a reconnect.
            if (!authed) {
                if (sessionToken) {
                    sessionToken = null;
                    localStorage.removeItem('mclaw_token');
                    sessionStorage.removeItem('mclaw_session');
                }
                showLogin();
                return;
            }
            setTimeout(connect, 3000);
        };

        ws.onerror = function() {
            ws.close();
        };

        ws.onmessage = function(event) {
            let data;
            try {
                data = JSON.parse(event.data);
            } catch(e) {
                return;
            }
            handleMessage(data);
        };
    }

    // === Login (web auth) ===

    const loginOverlay = document.getElementById('login-overlay');
    const loginForm = document.getElementById('login-form');
    const loginInput = document.getElementById('login-password');
    const loginNameInput = document.getElementById('login-username');
    const loginSubmit = document.getElementById('login-submit');
    const loginError = document.getElementById('login-error');

    function showLogin() {
        if (loginOverlay) loginOverlay.classList.remove('hidden');
        if (loginInput) {
            loginInput.value = '';
            loginInput.focus();
        }
    }

    function hideLogin() {
        if (loginOverlay) loginOverlay.classList.add('hidden');
        if (loginError) loginError.classList.add('hidden');
    }

    // === 头像：用户可在设置中选择预置头像（localStorage myclaw_avatar）。
    var PRESET_AVATARS = ['😀', '🐱', '🐶', '🦊', '🐼', '🦁', '🐨', '🐯', '🦄', '🐸', '👩', '👨'];
    function getAvatar() {
        try { return localStorage.getItem('myclaw_avatar') || ''; } catch (e) { return ''; }
    }
    function setAvatar(a) {
        try { localStorage.setItem('myclaw_avatar', a); } catch (e) {}
        syncAvatarUI();
    }
    // 侧栏用户卡片与全部 user 消息头像同步（首字 fallback 用 name 首字母）。
    function applyAvatarInitials(initials) {
        var av = document.getElementById('user-avatar');
        if (!av) return;
        var emo = getAvatar();
        av.textContent = emo || initials || 'OP';
        av.style.fontSize = emo ? '16px' : '13px';
    }
    function syncAvatarUI() {
        var cur = getAvatar();
        var emo = cur || 'OP';
        var av = document.getElementById('user-avatar');
        if (av) { av.textContent = emo; av.style.fontSize = cur ? '16px' : '13px'; }
        // 刷新已渲染的用户消息头像（简单起见仅更新侧栏；新消息用最新值）
    }
    function makeUserAvatarEl() {
        var a = document.createElement('div');
        a.className = 'msg-avatar';
        var emo = getAvatar();
        if (emo) { a.textContent = emo; a.style.fontSize = '17px'; a.style.background = 'transparent'; }
        else {
            var name = 'OP';
            try { var u = JSON.parse(localStorage.getItem('mclaw_user') || '{}'); name = u.name || u.id || 'OP'; } catch (e) {}
            var inits = (name.match(/[A-Za-z0-9]/g) || []).slice(0, 2).join('').toUpperCase() || 'OP';
            a.textContent = inits;
        }
        return a;
    }
    function makeAgentAvatarEl() {
        var a = document.createElement('div');
        a.className = 'msg-avatar avatar-agent';
        a.innerHTML = '<svg width="30" height="30" viewBox="0 0 24 24" fill="none">' +
            '<circle cx="6" cy="7" r="2.2" fill="var(--accent2)"/>' +
            '<circle cx="12" cy="4.6" r="2.2" fill="var(--accent2)"/>' +
            '<circle cx="18" cy="7" r="2.2" fill="var(--accent2)"/>' +
            '<path d="M5.5 12.5C6.5 10 9 9 12 9s5.5 1 6.5 3.5c.8 2-.4 4-2.8 4H8.3c-2.4 0-3.6-2-2.8-4z" fill="var(--accent2)"/>' +
            '</svg>';
        return a;
    }

    // Reflect the signed-in operator in the sidebar user card.
    function applyUser(user) {
        if (!user) return;
        var name = user.name || user.id || 'operator';
        var initials = user.initials || (name.match(/[A-Za-z0-9]/g) || []).slice(0, 2).join('').toUpperCase() || 'OP';
        var id = user.id || '';
        var nm = document.getElementById('user-name');
        var ud = document.getElementById('user-id');
        if (nm) nm.textContent = name;
        if (ud) ud.textContent = id ? ('@' + id) : '';
        applyAvatarInitials(initials);
        if (id) localStorage.setItem('mclaw_user', JSON.stringify(user));
    }

    // Restore the previously remembered operator (so the card is populated
    // even before the first successful login of the session).
    try {
        var cached = localStorage.getItem('mclaw_user');
        if (cached) applyUser(JSON.parse(cached));
    } catch (e) { /* ignore corrupted cache */ }

    async function doLogin(e) {
        if (e) e.preventDefault();
        const pw = loginInput ? loginInput.value : '';
        if (!pw || !loginSubmit) return;
        // The server password is exactly the configured web_password.
        loginSubmit.disabled = true;
        if (loginError) loginError.classList.add('hidden');
        try {
            const resp = await fetch('/api/login', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    username: loginNameInput ? (loginNameInput.value || '').trim() : '',
                    password: pw
                })
            });
            const data = await resp.json().catch(function() { return {}; });
            if (data && data.ok && data.token) {
                sessionToken = data.token;
                localStorage.setItem('mclaw_token', sessionToken);
                sessionStorage.setItem('mclaw_session', sessionToken);
                applyUser(data.user);
                hideLogin();
                connect();
            } else if (loginError) {
                loginError.classList.remove('hidden');
            }
        } catch (err) {
            if (loginError) {
                loginError.textContent = '登录请求失败，请重试';
                loginError.classList.remove('hidden');
            }
        } finally {
            loginSubmit.disabled = false;
        }
    }

    if (loginForm) {
        loginForm.addEventListener('submit', doLogin);
    }

    // On load, ask the server whether a login is required. This avoids relying
    // on a failed WS upgrade (racy, brief unauthenticated flash) to discover it.
    async function bootstrap() {
        try {
            const resp = await fetch('/api/auth-status');
            const data = await resp.json().catch(function () { return { required: false }; });
            if (data && data.required) {
                if (sessionToken) {
                    connect();
                } else {
                    showLogin();
                }
                return;
            }
        } catch (e) {
            // Endpoint unavailable — fall back to a direct connection attempt.
        }
        connect();
    }

    function send(obj) {
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.send(JSON.stringify(obj));
        }
    }

    // Build info label at the far left of the chat toolbar.
    // Format: <manual-version>.<auto-build-number>, e.g. "1.0.1.12".
    // The version comes from Cargo.toml (edited by hand); the build number
    // increments on EVERY build (build.rs bumps build_count.txt).
    var buildLabel = document.getElementById('build-label');
    if (buildLabel) {
        fetch('/api/build')
            .then(function(r) { return r.json(); })
            .then(function(b) {
                buildLabel.textContent = '版本:' + (b.version || '?') + '.' + (b.build || '?');
                buildLabel.title = (b.git ? ('git ' + b.git + ' · ') : '') + '版本手工维护，build 号每次构建递增';
            })
            .catch(function() {
                buildLabel.textContent = '';
            });
    }

    // Debug hook: open F12 console and type `__myclaw.providers()` /
    // `__myclaw.autonomy()` to see what the dropdowns are rendering.
    // Lets us spot "browser cached an old app.js" without guesswork.
    window.__myclaw = {
        providers: function() {
            return {
                count: (typeof currentProviders !== 'undefined') ? currentProviders.length : null,
                ids: (currentProviders || []).map(function(p) { return p.id; }),
                currentId: typeof currentProviderId !== 'undefined' ? currentProviderId : null,
                menuItems: providerMenu ? providerMenu.querySelectorAll('.task-menu-item').length : null,
            };
        },
        autonomy: function() {
            return {
                current: typeof currentAutonomy !== 'undefined' ? currentAutonomy : null,
                menuItems: autonomyMenu ? autonomyMenu.querySelectorAll('.task-menu-item').length : null,
            };
        },
    };

    // === Message handler ===

    function handleMessage(data) {
        switch(data.type) {
            // --- Streaming events ---

            case 'thinking':
                // A new task iteration — ensure the agent-turn container and
                // advance round state (思考/执行说明段落随之换新).
                ensureRunBlock();
                if (typeof data.iteration === 'number') {
                    ensureRunIteration(data.iteration);
                }
                scrollToBottom();
                break;

            case 'text_delta':
                // Start streaming message if not yet created. createStreamingMessage
                // 已把流式段追加进同一组流区(runTailEl)，此处不再额外挂到 chatEl。
                if (!currentStreamEl) {
                    currentStreamEl = createStreamingMessage();
                    currentStreamText = '';
                }
                currentStreamText += data.delta;
                updateStreamingMessage(currentStreamEl, currentStreamText);
                scrollToBottom();
                break;

            case 'reasoning_delta':
                // Reasoning streams inside the run-block (live phase); it is
                // hidden once a tool starts executing.
                showRunReasoning(data.delta);
                break;

            case 'usage':
                showUsage(data.prompt_tokens, data.completion_tokens, data.total_tokens);
                break;

            case 'approval_request':
                showApprovalBanner(data);
                break;

            case 'loop_warning':
                showLoopWarning(data.message, data.repeated_count);
                break;

            case 'retry':
                // A failed LLM / knowledge-base call is being retried — show it
                // inside the current round's block so the user sees it happen.
                showRunRetry(data);
                break;

            case 'task_aborted':
                runState.exception = data.message;
                finalizeRunBlock(true);
                appendTurnClosure();
                setProcessing(false);
                stepListEl = null;
                break;

            // --- Existing events ---

            case 'plan':
                // 任务拆分计划：作为文档行即时插入 flow（贴合"a. 思考过程，拆分任务"）。
                runState.plan = data.steps || [];
                ensureRunBlock();
                appendRunPlan(runState.plan);
                scrollToBottom();
                break;

            case 'tool_call':
                // Keep the streamed decision text (model's plan for this step)
                // in the round's 执行说明 line, then show the executing tool.
                finalizeStreamToIteration();
                showRunTool(data.name, data.args);
                break;

            case 'tool_result':
                finishRunTool(data.name, data.result, data.success);
                break;

            // Live step status is folded into the run-block timeline; the
            // run-head spinner already signals "working".
            case 'step_status':
                break;

            case 'content':
                // The final reply text (may be empty when the model finished
                // purely via tool calls). The closing summary is rendered once
                // on `done`/`error` inside the agent-turn (任务已结束/失败 + 正文).
                runState.finalText = (data.text || '').trim();
                runState.pendingDesc = ''; // 未被工具消费的叙述文本不再归属后续步骤
                // Remove any residual streaming answer segment — the final text
                // is rebuilt (clean) inside the turn after the end row.
                if (runTailStreamEl && runTailStreamEl.parentNode) {
                    runTailStreamEl.parentNode.removeChild(runTailStreamEl);
                }
                runTailStreamEl = null;
                currentStreamEl = null;
                currentStreamText = '';
                clearProcessEl();
                scrollToBottom();
                break;

            case 'stopped':
                finalizeStream();
                finalizeRunBlock(true);
                if (!runState.exception) {
                    runState.userStopped = true;
                }
                appendTurnClosure();
                setProcessing(false);
                stepListEl = null;
                break;

            case 'error':
                runState.exception = data.message;
                finalizeStream();
                // An error ends the agent-turn immediately (not waiting for
                // `done`); appendTurnClosure is idempotent so a later `done`
                // will not duplicate the end row.
                finalizeRunBlock(true);
                appendTurnClosure();
                setProcessing(false);
                stepListEl = null;
                break;

            case 'done':
                finalizeStream();
                finalizeRunBlock(false);
                appendTurnClosure();
                setProcessing(false);
                stepListEl = null;  // next task starts a fresh step list
                // Collect file paths mentioned in the final answer and fold
                // them into the generated-files list if not already present.
                var extraFiles = extractFilePaths(runState.finalText);
                var known = {};
                runState.files.forEach(function(f) { known[f] = true; });
                var fresh = extraFiles.filter(function(f) { return !known[f]; });
                if (fresh.length > 0) {
                    runState.files = runState.files.concat(fresh);
                }
                // Generated files row (inside the agent-turn tail).
                if (runState.files && runState.files.length > 0) {
                    ensureTurnFiles(runState.files);
                }
                scrollToBottom();
                // Refresh the task list so a freshly-named task shows its
                // summarized title in the sidebar.
                send({ type: 'task_list' });
                break;

            case 'files':
                // Files generated by write_file during this run — accumulate
                // silently; the paperclip icon on `done` opens the list.
                if (data.paths && data.paths.length > 0) {
                    runState.files = runState.files.concat(data.paths);
                }
                break;

            case 'info':
                // 上下文压缩 / 记忆记录等 MyClaw 内部动作 → 作为当前阶段的
                // 「系统处理」行落在问答区（第二层细节）；其它通用提示
                //（如模型切换）仍走轻提示。
                if (data.message && /上下文|摘要|压缩|memory|记忆|文档/.test(data.message)
                    && runBlock && runBlock.parentNode) {
                    var sysHost = (runState.stepStage && runState.stepNo === runState.iteration)
                        ? runState.stepStage.body
                        : (runState.stageThink ? runState.stageThink.body : null);
                    appendSystemRow(sysHost || runFlowEl, data.message);
                } else {
                    showStatusMessage(data.message);
                }
                break;

            // --- Live runtime status (P1-1) ---
            case 'status':
                // Two shapes share the same type:
                //  * runtime status  {autonomy, model, providers, tools...}
                //  * progress notice {message: "Generating summary..."}
                // The duplicated `case 'status'` below made applyRuntimeStatus
                // unreachable, so the provider dropdown never populated.
                if (data && Array.isArray(data.providers)) {
                    applyRuntimeStatus(data);
                } else {
                    showStatusMessage(data && data.message);
                }
                break;

            case 'context_cleared':
                showStatusMessage('Context cleared');
                break;

            // 旧 config 事件已废弃（renderConfig 不存在，曾致 ReferenceError）：
            // 未知类型统一静默忽略，走 switch 默认分支即可。

            case 'task_list':
                renderTaskList(data.tasks || []);
                break;
            case 'dir_list':
                currentDir = data.path || '';
                renderDirList(data.dirs || []);
                break;
            case 'task_created':
            case 'task_switched':
            case 'task_deleted':
                send({ type: 'task_list' });
                break;
        }
    }

    // === Runtime controls (P1-1) ===

    // Providers arrive as [{id, name}] — `name` is the config label shown in
    // the dropdown, `id` is the routing key. On first load select the active
    // (last-used) provider, otherwise the first one.
    function applyRuntimeStatus(s) {
        if (!s) return;
        if (s.platform) serverPlatform = s.platform;
        // Tool count → the access row badge under the input box.
        if (typeof s.tool_count === 'number') {
            var ac = document.getElementById('access-count');
            if (ac) ac.textContent = s.tool_count + ' 个工具';
        }
        // kshare base api — fill the config input once (don't clobber typing).
        if (typeof s.kshare_base_api === 'string' && kshareBaseApi && !kshareBaseApi.value.trim()) {
            kshareBaseApi.value = s.kshare_base_api;
            setKshareStatus(s.kshare_base_api ? '已配置' : '未配置');
        }
        if (s.autonomy) {
            currentAutonomy = s.autonomy;
            if (autonomyBtn) autonomyBtn.title = 'Autonomy: ' + s.autonomy;
            updateAutonomyMenu();
        }
        if (s.providers && Array.isArray(s.providers)) {
            var active = s.active_provider || null;
            currentProviders = s.providers;
            var found = s.providers.find(function(p) { return p.id === active; });
            if (found) {
                currentProviderId = found.id;
            } else if (s.providers.length > 0) {
                currentProviderId = s.providers[0].id;
            }
            var cur = currentProviders.find(function(p) { return p.id === currentProviderId; });
            currentProviderName = cur ? cur.name : currentProviderId;
            if (providerBtn) providerBtn.title = '模型服务：' + (currentProviderName || '');
            buildProviderMenu();
        }
    }

    // Rebuild the provider dropdown menu from currentProviders.
    function buildProviderMenu() {
        if (!providerMenu) return;
        providerMenu.innerHTML = '';
        // Until runtime status arrives, treat the first provider as the
        // current one so the menu always shows a checked default.
        if (!currentProviderId && currentProviders.length > 0) {
            currentProviderId = currentProviders[0].id;
            var first = currentProviders.find(function(p) { return p.id === currentProviderId; });
            currentProviderName = first ? first.name : currentProviderId;
        }
        currentProviders.forEach(function(p) {
            var item = document.createElement('div');
            item.className = 'task-menu-item' + (p.id === currentProviderId ? ' selected' : '');
            item.textContent = p.name || p.id;
            item.addEventListener('click', function() {
                // Selecting the already-active provider is a no-op — do not
                // send set_provider, rebuild, or show a misleading "switched"
                // toast.
                if (p.id === currentProviderId) {
                    if (providerWrap) providerWrap.classList.remove('open');
                    return;
                }
                send({ type: 'set_provider', name: p.id });
                currentProviderId = p.id;
                currentProviderName = p.name || p.id;
                if (providerBtn) providerBtn.title = '模型服务：' + currentProviderName;
                buildProviderMenu();
                if (providerWrap) providerWrap.classList.remove('open');
                // Do NOT toast here: the backend replies with a single
                // authoritative `info` ("已切换到 …") which is shown once.
                // Toasting locally too duplicated the notice in the Q&A feed.
            });
            providerMenu.appendChild(item);
        });
    }

    // Highlight the current autonomy level in its menu.
    function updateAutonomyMenu() {
        if (!autonomyMenu) return;
        Array.prototype.forEach.call(autonomyMenu.children, function(item) {
            item.classList.toggle('selected', item.getAttribute('data-level') === currentAutonomy);
        });
    }

    // === Skill palette (P1-2) ===

    function fetchSkills() {
        fetch('/api/skills')
            .then(function(r) { return r.json(); })
            .then(function(data) {
                skills = (data && data.skills) || [];
            })
            .catch(function() { skills = []; });
    }

    function openSkillPalette() {
        if (!skillPalette || skills.length === 0) return;
        var html = skills.map(function(s, i) {
            return '<div class="skill-item" data-index="' + i + '">' +
                '<span class="skill-name">/' + escapeHtml(s.name) + '</span>' +
                '<span class="skill-desc">' + escapeHtml(s.description || '') + '</span>' +
            '</div>';
        }).join('');
        skillPalette.innerHTML = html;
        skillPalette.classList.remove('hidden');

        skillPalette.querySelectorAll('.skill-item').forEach(function(item) {
            item.addEventListener('mousedown', function(e) {
                e.preventDefault();
                var skill = skills[parseInt(item.dataset.index, 10)];
                if (skill) insertSkill(skill);
            });
        });
    }

    function insertSkill(skill) {
        var input = inputEl.value;
        // Replace the leading "/name ..." with the skill prompt + remaining args.
        var trimmed = input.trimStart();
        var afterSlash = trimmed.indexOf(' ');
        var args = afterSlash >= 0 ? trimmed.slice(afterSlash).trim() : '';
        var prompt = skill.prompt || '';
        if (args && prompt.indexOf('{input}') !== -1) {
            prompt = prompt.replace('{input}', args);
        } else if (args) {
            prompt = prompt + '\n' + args;
        }
        inputEl.value = prompt;
        closeSkillPalette();
        inputEl.focus();
        autoResize();
    }

    function closeSkillPalette() {
        if (skillPalette) skillPalette.classList.add('hidden');
    }

    // === Finalize streaming state ===

    function finalizeStream() {
        if (currentStreamEl) {
            // Keep the streaming bubble as the final message
            currentStreamEl = null;
            currentStreamText = '';
        }
    }

    // Show a new in-flight process element (reasoning / tool card), removing
    // whatever was shown before: only the current executing unit is visible.
    function setProcessEl(el) {
        if (currentProcessEl && currentProcessEl.parentNode) {
            currentProcessEl.parentNode.removeChild(currentProcessEl);
        }
        if (currentToolBlock && currentToolBlock !== el) currentToolBlock = null;
        if (currentReasoningEl && currentReasoningEl !== el) currentReasoningEl = null;
        if (currentThinkingEl && currentThinkingEl !== el) currentThinkingEl = null;
        currentProcessEl = el;
        chatEl.appendChild(el);
        return el;
    }

    // Drop the in-flight process element entirely (thinking/tool done).
    function clearProcessEl() {
        if (currentProcessEl && currentProcessEl.parentNode) {
            currentProcessEl.parentNode.removeChild(currentProcessEl);
        }
        currentProcessEl = null;
        currentToolBlock = null;
        currentReasoningEl = null;
        currentThinkingEl = null;
    }

    // === UI builders ===

    function createThinking(iteration) {
        const el = document.createElement('div');
        el.className = 'thinking';
        el.innerHTML = '<div class="thinking-dots"><span></span><span></span><span></span></div>Thinking (iteration ' + iteration + ')...';
        return el;
    }

    function removeThinking() {
        if (currentThinkingEl && currentThinkingEl.parentNode) {
            currentThinkingEl.parentNode.removeChild(currentThinkingEl);
        }
        currentThinkingEl = null;
    }

    function createReasoningPanel(initialText) {
        const el = document.createElement('div');
        el.className = 'reasoning-block expanded'; // expanded while thinking
        el.innerHTML =
            '<div class="reasoning-header" onclick="this.parentElement.classList.toggle(\'expanded\')">' +
                '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
                    '<circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.2"/>' +
                    '<path d="M7 4v3M7 9.5v.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>' +
                '</svg>' +
                'Reasoning' +
                '<span class="reasoning-chevron">&#9654;</span>' +
            '</div>' +
            '<div class="reasoning-body">' + escapeHtml(initialText) + '</div>';
        return el;
    }

    function updateReasoningPanel(el, text) {
        var body = el.querySelector('.reasoning-body');
        if (body) body.textContent = text;
        // Keep expanded while streaming; collapse happens in finalizeReasoning.
        el.classList.add('expanded');
    }

    function createStreamingMessage() {
        ensureRunBlock(); // 模型开始输出正文 → 组长回复容器存在
        var el = document.createElement('div');
        el.className = 'turn-answer streaming';
        // 首句内嵌时间戳（与步骤行同构）。
        var tline = document.createElement('div');
        tline.className = 'turn-answer-time';
        tline.innerHTML = nowTimeTag();
        el.appendChild(tline);
        var inner = document.createElement('div');
        inner.className = 'msg-bubble';
        inner.innerHTML = '';
        el.appendChild(inner);
        if (runTailEl) runTailEl.appendChild(el);
        runTailStreamEl = el;
        scrollToBottom();
        return el;
    }

    function updateStreamingMessage(el, text) {
        var bubble = el.querySelector('.msg-bubble');
        if (bubble) {
            bubble.innerHTML = formatMessage(text);
        }
    }

    function createPlan(steps) {
        const el = document.createElement('div');
        el.className = 'plan-block';
        let stepsHtml = steps.map(function(s) { return '<li>' + escapeHtml(s) + '</li>'; }).join('');
        el.innerHTML =
            '<div class="plan-header">' +
                '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
                    '<path d="M3 4h8M3 7h8M3 10h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
                '</svg>' +
                'Task Plan' +
            '</div>' +
            '<ol class="plan-steps">' + stepsHtml + '</ol>';
        return el;
    }

    // === Tool rendering: compact gray line, no card ===
    // Each tool maps to a Chinese label + emoji icon; the line shows
    // "icon 中文名 · 关键信息" (e.g. kshare_ask → "询问 '...'"). The full
    // result is available on hover (title).

    var TOOL_META = {
        search_content:   { label: '内容搜索',   icon: '🔍', key: function(a) { return '搜索 "' + (a.pattern || '') + '"' + (a.path && a.path !== '.' ? ' @' + pathBase(a.path) : ''); } },
        search_files:     { label: '文件查找',   icon: '📁', key: function(a) { return '匹配 ' + (a.pattern || ''); } },
        read_file:        { label: '读取文件',   icon: '📄', key: function(a) { return pathBase(a.path || ''); } },
        write_file:       { label: '写入文件',   icon: '✍️', key: function(a) { return pathBase(a.path || ''); } },
        run_command:      { label: '执行命令',   icon: '⚡', key: function(a) { var c = (a.command || '').replace(/\s+/g, ' ').trim(); return c.length > 64 ? c.slice(0, 64) + '…' : c; } },
        list_directory:   { label: '浏览目录',   icon: '📂', key: function(a) { return pathBase(a.path || '.'); } },
        create_plan:      { label: '任务规划',   icon: '🗂️', key: function() { return ''; } },
        delegate_task:    { label: '委派子任务', icon: '🤖', key: function(a) { return (a.agent || '') + (a.task ? ' · ' + String(a.task).slice(0, 40) : ''); } },
        add_capability:   { label: '添加能力',   icon: '🧩', key: function(a) { return a.name || ''; } },
        kshare_ask:       { label: '知识库问答', icon: '📚', key: function(a) { return '询问 "' + (a.query || '') + '"'; } },
        kshare_categories:{ label: '知识库分类', icon: '🏷️', key: function() { return '获取分类体系'; } }
    };

    function toolMeta(name) {
        return TOOL_META[name] || { label: name, icon: '🛠️', key: function() { return ''; } };
    }

    // === agent-turn: MyClaw 单组长回复（9/6 10:20 模型重构）===
    // 一次用户提问 → 一条 .msg.agent-turn：头像(唯一一次) + .turn-bubble。
    // 气泡内：状态首行(正在执行) → 文档步骤流(思考/工具/结果) → 结束行(任务
    // 已结束/失败·耗时) → 最终回答正文。无 run-head/history-run 折叠分隔条。
    // 每行文本前头均内嵌时间戳(hist-time)。

    var runBlock = null;          // current agent-turn container
    var runFlowEl = null;         // 步骤流容器(turn-flow)
    var runBubbleEl = null;       // 气泡容器(turn-bubble)
    var runStatusEl = null;       // 状态首行(turn-status)
    var runTailEl = null;         // 气泡尾部区(turn-tail: 结束行/正文/文件)
    var runTailStreamEl = null;   // 流式正文段(turn-answer.streaming)
    var busyEl = null;            // 任务执行中、文字最底部的"正在处理…"转圈指示
    var liveUsageBlock = null;    // 当前任务唯一的 Tokens 用量块（就地更新，避免重复出现）
    var runGoal = '';             // 本轮目标（用户提问摘要）→ "开始执行「XX」任务…"
    var runState = { tools: [], thinking: '', plan: null, startedAt: 0, aborted: false, iteration: 0, iterEl: null, thinkEl: null, pendingDesc: '', stageThink: null, stepStage: null, stepNo: 0, finalText: '', exception: null, userStopped: false, files: [], finalized: false, elapsedMs: 0, lastToolResult: '', lastToolLabel: '' };

    function resetRunState() {
        runState = { tools: [], thinking: '', plan: null, startedAt: Date.now(), aborted: false, iteration: 0, iterEl: null, thinkEl: null, pendingDesc: '', stageThink: null, stepStage: null, stepNo: 0, finalText: '', exception: null, userStopped: false, files: [], finalized: false, elapsedMs: 0, lastToolResult: '', lastToolLabel: '' };
    }

    function ensureRunBlock() {
        if (runBlock && runBlock.parentNode) return runBlock;
        // 新任务：清掉上一个任务遗留的 Tokens 用量块（仅保留当前任务一个）。
        if (liveUsageBlock && liveUsageBlock.parentNode) {
            liveUsageBlock.parentNode.removeChild(liveUsageBlock);
            liveUsageBlock = null;
        }
        runBlock = document.createElement('div');
        runBlock.className = 'msg agent-turn';
        // 图标独占行：MyClaw logo + 「开始执行 XX 任务…」文本（该组只出现一次）。
        var headRow = document.createElement('div');
        headRow.className = 'turn-head-row';
        headRow.appendChild(makeAgentAvatarEl());
        var st = document.createElement('div');
        st.className = 'turn-status';
        st.innerHTML =
            '<span class="turn-status-text">接收任务' +
            (runGoal ? '：开始执行「' + escapeHtml(runGoal) + '」…' : '，开始处理…') + '</span>' +
            '<span class="turn-status-spin">⟳</span>' +
            nowTimeTag();
        headRow.appendChild(st);
        runBlock.appendChild(headRow);
        var bubble = document.createElement('div');
        bubble.className = 'turn-bubble';
        var flow = document.createElement('div');
        flow.className = 'turn-flow';
        bubble.appendChild(flow);
        runBlock.appendChild(bubble);
        chatEl.appendChild(runBlock);
        runFlowEl = flow;
        runBubbleEl = bubble;
        // 单一组：尾区即流区，结束行/正文与各任务步骤按时间顺序内联平铺。
        runTailEl = flow;
        resetRunState();
        runStatusEl = st;
        scrollToBottom();
        return runBlock;
    }

    function updateRunHeadStatus(goal) {
        if (!runStatusEl || !runStatusEl.parentNode) return;
        var tx = runStatusEl.querySelector('.turn-status-text');
        if (tx) tx.textContent = '接收任务' +
            (goal ? '：开始执行「' + goal + '」…' : '，开始处理…');
    }

    // 新任务开始：复用同一组容器（不重建 → 不产生 run 分隔条），仅重置本轮状态
    // 与头像状态行文本。runBlock 在会话内持久，跨任务连续追加。
    function beginRunBlock(goal) {
        ensureRunBlock();
        if (goal !== undefined && goal !== null) runGoal = goal;
        resetRunState();
        if (liveUsageBlock && liveUsageBlock.parentNode) {
            liveUsageBlock.parentNode.removeChild(liveUsageBlock);
            liveUsageBlock = null;
        }
        updateRunHeadStatus(runGoal);
    }

    function fadeOutRunItem(el) {
        if (!el || !el.parentNode) return;
        el.classList.add('fade-out');
        setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 220);
    }

    function runBody() {
        return ensureRunBlock(), runFlowEl;
    }

    // 轮次切换只负责状态归零（thinking/decision 段落换新），不再创建任何
    // 迭代容器/横条——所有步骤行直接平铺进 turn-flow，无分隔符。
    function ensureRunIteration(iteration) {
        ensureRunBlock();
        if (runState.iteration !== iteration) {
            runState.iteration = iteration;
            runState.thinking = '';
            runState.thinkEl = null;
            runState.stepStage = null; // 新一轮 → 下一个步骤容器
            // 第一轮固定先建立「思考与拆解」阶段。
            if (iteration <= 1 && (!runState.stageThink || !runState.stageThink.body.parentNode)) {
                runState.stageThink = makeStage('思考与拆解', runFlowEl, 'stage-think', '🧭', null, false, null, nowTimeTag());
            }
        }
        return runFlowEl;
    }

    function runIterBody() {
        return ensureRunBlock(), runFlowEl;
    }

    // Collapse continuous blank lines to a single newline (compact style).
    function compactThinking(text) {
        return (text || '').replace(/\n{2,}/g, '\n').replace(/^\n+|\n+$/g, '');
    }

    // SVG icons for the thinking block: a lightbulb as the leading icon and a
    // chevron (rotated via CSS) as the fold control at the END of the label.
    var THINK_BULB_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none">' +
        '<path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1.1 2h5c.1-.8.5-1.5 1.1-2A6 6 0 0 0 12 3z" ' +
        'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
    var THINK_CHEVRON_SVG = '<svg class="think-chev" width="14" height="14" viewBox="0 0 24 24" fill="none">' +
        '<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';

    // "HH:MM:SS" for live blocks (time point before each output block).
    function nowTimeTag() {
        var d = new Date();
        var p = function(n) { return (n < 10 ? '0' : '') + n; };
        return '<span class="hist-time">' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) + '</span>';
    }

    // === 主流程阶段容器（第一层骨架）===
    // 接收任务 → 思考与拆解 → 步骤 1..N → 最终结果。
    // 每个阶段是一个可折叠容器：标题行（第一层）+ body（第二层细节）。
    // collapsed: 默认折叠(仅标题行可见, 适合历史已完成步骤);
    // hideLabel: 不渲染阶段标签(用于去除"步骤 N", 仅留概括描述)。
    function makeStage(label, hostFlow, kind, icon, desc, collapsed, hideLabel, tsHtml) {
        var el = document.createElement('div');
        el.className = 'proc-stage ' + (kind || '');
        var head = document.createElement('div');
        head.className = 'proc-stage-head';
        var lblHtml = (label && !hideLabel)
            ? ('<span class="proc-stage-label">' + escapeHtml(label) + '</span>')
            : '';
        head.innerHTML = '<span class="proc-stage-chev">' + (collapsed ? '▸' : '▾') + '</span>' +
            '<span class="proc-stage-icon">' + (icon || '') + '</span>' +
            lblHtml +
            '<span class="proc-stage-desc"></span>' +
            '<span class="proc-stage-state">⟳</span>' +
            (tsHtml || '');
        var body = document.createElement('div');
        body.className = 'proc-stage-body';
        el.appendChild(head);
        el.appendChild(body);
        if (collapsed) el.classList.add('collapsed');
        head.addEventListener('click', function() {
            el.classList.toggle('collapsed');
            var c = head.querySelector('.proc-stage-chev');
            if (c) c.textContent = el.classList.contains('collapsed') ? '▸' : '▾';
        });
        hostFlow.appendChild(el);
        if (desc) setStageDesc({ el: el, body: body, state: head.querySelector('.proc-stage-state') }, desc);
        return { el: el, body: body, state: head.querySelector('.proc-stage-state') };
    }

    // 折叠某个阶段(保留标题行, 隐藏 body)。
    function collapseStage(s) {
        if (!s || !s.el || !s.el.parentNode) return;
        s.el.classList.add('collapsed');
        var c = s.el.querySelector('.proc-stage-chev');
        if (c) c.textContent = '▸';
    }

    // 折叠某阶段内部细节（思考块 + 工具子行），用于"执行完自动折叠"。
    function collapseStageInner(stageEl) {
        if (!stageEl) return;
        var thinks = stageEl.querySelectorAll('.run-think');
        for (var i = 0; i < thinks.length; i++) thinks[i].classList.add('collapsed');
        var subs = stageEl.querySelectorAll('.tool-sub');
        for (var j = 0; j < subs.length; j++) subs[j].classList.remove('expanded');
    }

    // 标记/清除"执行中"状态（旋转 spinner）。
    function markRunning(stateEl) {
        if (stateEl) stateEl.classList.add('running');
    }
    function clearRunning(stateEl) {
        if (stateEl) stateEl.classList.remove('running');
    }

    // 把工具概括(desc)压成单行写进阶段标题行：换行符替换为空格、去首尾空白。
    function oneLineText(text, maxLen) {
        var s = String(text || '').replace(/\s*\r?\n\s*/g, ' ').replace(/\s{2,}/g, ' ').trim();
        if (maxLen && s.length > maxLen) s = s.slice(0, maxLen) + '…';
        return s;
    }

    // 回填/追加阶段标题行的描述（同一步骤多个工具用 " · " 连接）。
    function setStageDesc(stage, text) {
        if (!stage || !stage.el) return;
        var t = oneLineText(text, 0);
        if (!t) return;
        var d = stage.el.querySelector('.proc-stage-desc');
        if (!d) return;
        d.textContent = d.textContent ? (d.textContent + ' · ' + t) : t;
        d.title = d.textContent;
        stage.el.classList.add('has-desc');
    }

    // 当前步骤容器（第 N 轮 = 步骤 N）；不存在时创建。
    function ensureStepStage(n) {
        if (runState.stepStage && runState.stepNo === n && runState.stepStage.body.parentNode) {
            return runState.stepStage.body;
        }
        // 进入新步骤：折叠上一阶段(思考与拆解或上一步骤)内部细节，并去除"执行中"标记。
        var prev = (runState.stepStage && runState.stepStage.el && runState.stepStage.el.parentNode)
            ? runState.stepStage
            : (runState.stageThink && runState.stageThink.el && runState.stageThink.el.parentNode ? runState.stageThink : null);
        if (prev) {
            collapseStage(prev);
            collapseStageInner(prev.el);
            prev.el.classList.remove('executing');
            clearRunning(prev.state);
        }
        // 去除"步骤 N"标签(hideLabel)，仅留概括描述；当前步骤展开并标记"执行中"。
        var st = makeStage('', runFlowEl, 'stage-step', '⚙️', null, false, true, nowTimeTag());
        st.el.classList.add('executing');
        markRunning(st.state);
        runState.stepStage = st;
        runState.stepNo = n;
        return st.body;
    }

    // 系统处理行：上下文压缩 / 记录文档等 MyClaw 内部动作（第二层细节）。
    function appendSystemRow(host, text) {
        if (!host) return null;
        var el = document.createElement('div');
        el.className = 'proc-sys';
        el.innerHTML = '<span class="proc-sys-icon">⚙️</span>' +
            '<span class="proc-sys-text"></span>' + nowTimeTag();
        el.querySelector('.proc-sys-text').textContent = text;
        host.appendChild(el);
        scrollToBottom();
        return el;
    }

    function showRunReasoning(delta) {
        runState.thinking += delta;
        var flow = runBody();
        // 第一轮思考属于「思考与拆解」阶段；之后每轮思考属于对应步骤。
        var host;
        if (runState.iteration <= 1) {
            if (!runState.stageThink || !runState.stageThink.body.parentNode) {
                runState.stageThink = makeStage('思考与拆解', flow, 'stage-think', '🧭', null, false, null, nowTimeTag());
                markRunning(runState.stageThink.state);
                runState.stageThink.el.classList.add('executing');
            }
            host = runState.stageThink.body;
        } else {
            host = ensureStepStage(runState.iteration);
        }
        var el = runState.thinkEl;
        if (!el || !el.parentNode) {
            el = document.createElement('div');
            el.className = 'run-think streaming';
            // WorkBuddy 式：标题行「深度思考 ∨」可折叠，内容文章式排版。
            el.innerHTML =
                '<div class="run-think-head">' +
                    '<span class="run-think-icon">' + THINK_BULB_SVG + '</span>' +
                    '<span class="run-think-label">深度思考</span>' +
                    '<span class="run-think-toggle">' + THINK_CHEVRON_SVG + '</span>' +
                    nowTimeTag() +
                '</div>' +
                '<div class="run-think-text"></div>';
            el.querySelector('.run-think-head').addEventListener('click', function() {
                el.classList.toggle('collapsed');
            });
            host.appendChild(el);
            runState.thinkEl = el;
        }
        el.querySelector('.run-think-text').textContent = compactThinking(runState.thinking);
        // 续写（同一阶段再次收到思考）：恢复展开态，避免上一步完成折叠后隐藏。
        el.classList.remove('collapsed');
        var stEl = el.closest ? el.closest('.proc-stage') : null;
        if (stEl) { stEl.classList.add('executing'); stEl.classList.remove('collapsed'); }
        scrollToBottom();
        return el;
    }

    // 思考段保留展开（内容即"拆分任务"的思考过程，供用户阅读）；此函数
    // 不再自动折叠，仅保留兼容调用点。
    function collapseRunThinking() {
    }

    // Move any streamed model text (the "decision" that precedes a tool call)
    // into the NEXT tool block's 概括说明（依据性总结，常显正文）——不再单独
    // 成行。若之后没有工具调用，文本仍留在尾部流式段作为回答正文。
    function finalizeStreamToIteration() {
        if (!currentStreamEl) return;
        if (currentStreamEl.parentNode) {
            var text = currentStreamText;
            currentStreamEl.parentNode.removeChild(currentStreamEl);
            if (text && text.trim()) {
                runState.pendingDesc = text.trim();
            }
        }
        currentStreamEl = null;
        currentStreamText = '';
    }

    // 折叠块：默认只显示标签行（一行），点击标签展开全文（具体命令 / 原始输出）。
    function makeFoldBlock(labelText, preClass, content) {
        var block = document.createElement('div');
        block.className = 'fold-block';
        var lab = document.createElement('span');
        lab.className = 'tool-detail-label';
        lab.innerHTML = '<span class="fold-chev">▸</span>' + escapeHtml(labelText);
        var pre = document.createElement('pre');
        pre.className = preClass;
        pre.textContent = content;
        lab.addEventListener('click', function(e) {
            e.stopPropagation();
            block.classList.toggle('expanded');
            var c = lab.querySelector('.fold-chev');
            if (c) c.textContent = block.classList.contains('expanded') ? '▾' : '▸';
        });
        block.appendChild(lab);
        block.appendChild(pre);
        return { block: block, pre: pre };
    }

    // 单行文本行（结果概括等）：默认一行截断，点击展开全文。
    function makeOneLineRow(cls, text, isErr) {
        var el = document.createElement('div');
        el.className = cls + (isErr ? ' err' : '');
        var full = String(text || '');
        el.textContent = oneLineText(full, 120);
        el.title = full;
        el.addEventListener('click', function(e) {
            e.stopPropagation();
            el.classList.toggle('line-expanded');
        });
        return el;
    }

    // （makeToolHeadRow 已删除：三级工具行结构由 buildToolRow 单层展开取代）

    // 工具行（直播与历史共用）：head = 图标 + 命令/参数简略内容（标题即
    // 内容本身，不再显示工具名 title）+ 时间 + 状态；点击一次同时展开
    // 「具体命令 / 参数」+「原始输出」两节 —— 比旧三级结构每步少一次点击。
    function buildToolRow(meta, argsPretty, titleText, tsHtml, stateHtml, historyMode) {
        var el = document.createElement('div');
        el.className = 'run-tool' + (historyMode ? ' history-tool' : ' running');
        var head = document.createElement('div');
        head.className = 'run-tool-head';
        head.innerHTML =
            '<span class="tool-line-icon">' + meta.icon + '</span>' +
            '<span class="tool-line-chev">▶</span>' +
            '<span class="tool-line-name">' + escapeHtml(oneLineText(titleText, 120)) + '</span>' +
            (tsHtml || '') +
            '<span class="tool-line-state">' + (stateHtml || '<span class="running">⟳</span>') + '</span>';
        var detail = document.createElement('div');
        detail.className = 'run-tool-detail';
        var argLabel = document.createElement('div');
        argLabel.className = 'tool-detail-label';
        argLabel.textContent = '具体命令 / 参数：';
        var argPre = document.createElement('pre');
        argPre.className = 'tool-args';
        argPre.textContent = argsPretty;
        var outLabel = document.createElement('div');
        outLabel.className = 'tool-detail-label';
        outLabel.textContent = '原始输出：';
        var outPre = document.createElement('pre');
        outPre.className = 'tool-out';
        outPre.textContent = historyMode ? '' : '（执行中…）';
        detail.appendChild(argLabel);
        detail.appendChild(argPre);
        detail.appendChild(outLabel);
        detail.appendChild(outPre);
        head.addEventListener('click', function() {
            el.classList.toggle('expanded');
        });
        el.appendChild(head);
        el.appendChild(detail);
        el.dataset.cmd = oneLineText(titleText, 120); // 无结果时回退标题用
        return {
            el: el,
            outPre: outPre,
            nameEl: head.querySelector('.tool-line-name'),
            state: head.querySelector('.tool-line-state')
        };
    }

    function showRunTool(name, args) {
        // 工具执行属于当前步骤（第 N 轮 = 步骤 N）。
        var flow = runBody();
        var host = ensureStepStage(runState.iteration || 1);
        var meta = toolMeta(name);
        var parsed = {};
        try { parsed = (typeof args === 'string' && args.trim()) ? JSON.parse(args) : (args || {}); } catch (e) {}
        var key = meta.key(parsed);
        // 概括说明：优先用模型在调用前的叙述（有依据的总结）→ 并入阶段
        // 标题行（齿轮后）；工具行标题本身 = 命令/参数简略内容。
        var desc = runState.pendingDesc || '';
        runState.pendingDesc = '';
        if (desc) setStageDesc(runState.stepStage, desc);
        var row = buildToolRow(meta, prettyJson(parsed, args), key || meta.label, nowTimeTag());
        host.appendChild(row.el);
        // 工具执行时确保当前步骤展开（若该步骤此前已被折叠收起）。
        if (runState.stepStage && runState.stepStage.el && runState.stepStage.el.parentNode) {
            runState.stepStage.el.classList.remove('collapsed');
            runState.stepStage.el.classList.add('executing');
        }
        // 步骤状态跟随工具执行结果。
        if (runState.stepStage && runState.stepStage.state) {
            runState.stepStage.state.textContent = '⟳';
        }
        scrollToBottom();
        return row.el;
    }

    // Pretty-print tool args for the foldable detail block: parsed object →
    // indented JSON; unparseable string stays raw.
    function prettyJson(parsed, raw) {
        if (parsed && typeof parsed === 'object' && Object.keys(parsed).length) {
            try { return JSON.stringify(parsed, null, 2); } catch (e) { /* fallthrough */ }
        }
        return typeof raw === 'string' ? raw : '';
    }
    function resultSummary(result) {
        if (!result) return '';
        // 取第一行"实质内容"作为概括：跳过代码注释、分隔线、exit code 等
        // 无信息量的行（read_file 输出首行常是 "# -*- coding -*-"）。
        var lines = String(result).split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
        for (var i = 0; i < lines.length; i++) {
            var l = lines[i];
            if (/^(#|\/\/|\/\*|\*|--|===|```|<\/?[a-z!])/i.test(l)) continue;
            if (/^exit code/i.test(l)) continue;
            if (/^(stdout|stderr|--- stdout ---|--- stderr ---)$/i.test(l)) continue;
            return l.length > 80 ? l.slice(0, 80) + '…' : l;
        }
        return lines.length ? (lines[0].length > 80 ? lines[0].slice(0, 80) + '…' : lines[0]) : '';
    }

    // A failed LLM / knowledge-base call is being retried — show the retry
    // inside the current round so the user sees it instead of a silent stall.
    function showRunRetry(data) {
        var stageLabel = data.stage === 'llm' ? 'LLM' : '知识库';
        var el = document.createElement('div');
        el.className = 'run-retry';
        el.innerHTML = nowTimeTag() + '<span>↻ ' + stageLabel + ' 调用失败，正在重试（' + data.attempt + '/' + data.max + '）</span>';
        if (data.reason) el.title = data.reason;
        // 归属当前步骤（无步骤时归思考与拆解）。
        var host = (runState.stepStage && runState.stepNo === runState.iteration)
            ? runState.stepStage.body
            : (runState.stageThink ? runState.stageThink.body : runBody());
        host.appendChild(el);
        scrollToBottom();
        return el;
    }

    function finishRunTool(name, result, success) {
        var flow = runFlowEl;
        var el = flow ? flow.querySelector('.run-tool.running') : null;
        var meta = toolMeta(name);
        var summary = resultSummary(result);
        // 记住最后一个工具结果 —— 若模型未给出最终总结，收尾时把它作为
        // "最后输出"直接显示（历史任务同样能看到结果）。
        runState.lastToolResult = String(result || '');
        runState.lastToolLabel = meta.label;
        runState.tools.push({
            name: name,
            label: meta.label,
            icon: meta.icon,
            summary: summary,
            success: success
        });
        if (el) {
            el.classList.remove('running');
            el.classList.add(success ? 'ok' : 'err');
            var st = el.querySelector('.tool-line-state');
            if (st) { st.textContent = success ? '✓' : '✗'; clearRunning(st); }
            // 标题替换规则：有结果 → 用执行结果的首行实质文字做标题；
            // 无内容 → 用「执行命令」+ 命令本身。整行点击仍可展开参数/输出。
            var nm = el.querySelector('.run-tool-head .tool-line-name');
            if (nm) {
                var t = summary || ('执行命令 ' + (el.dataset.cmd || ''));
                nm.textContent = oneLineText(t, 120);
                nm.title = summary || t;
            }
            // 执行结果全文写入折叠的原始输出区（页面内显隐，非 hover/tips）。
            var outPre = el.querySelector('.tool-out');
            if (outPre) outPre.textContent = (success ? '' : '[失败] ') + String(result || '(空输出)');
        }
        // 步骤状态：本轮工具完成 → ✓ / ✗。
        if (runState.stepStage && runState.stepStage.state) {
            runState.stepStage.state.textContent = success ? '✓' : '✗';
            runState.stepStage.state.classList.add(success ? 'ok' : 'err');
            clearRunning(runState.stepStage.state);
        }
        // 步骤执行完成：整段折叠（仅留阶段头 ✓/✗），内部思考/命令/输出全部收起。
        // 仍在执行的下一阶段会重新展开；已完成步骤不再重复展开。
        if (runState.stepStage && runState.stepStage.el && runState.stepStage.el.parentNode) {
            runState.stepStage.el.classList.remove('executing');
            collapseStage(runState.stepStage);
            collapseStageInner(runState.stepStage.el);
        }
        scrollToBottom();
    }

    function finalizeRunBlock(aborted, _defaultCollapsed) {
        if (!runBlock || !runBlock.parentNode) return;
        if (aborted) runState.aborted = true;
        if (runState.finalized) return;
        runState.finalized = true;
        runState.elapsedMs = Date.now() - runState.startedAt;
        // Stop any tool spinner that never received its result.
        var pend = runBlock.querySelectorAll('.run-tool.running');
        for (var i = 0; i < pend.length; i++) {
            var st = pend[i].querySelector('.tool-line-state');
            if (st) { st.textContent = '✗'; clearRunning(st); }
            pend[i].classList.remove('running');
            pend[i].classList.add('err');
        }
        scrollToBottom();
    }

    // 任务拆分计划：与「思考与拆解」阶段头合并 —— 阶段 label 直接改写为
    // 「任务拆分（N 步）」，步骤列表平铺进阶段 body（不再有二级 run-plan 标题）。
    function appendRunPlan(steps) {
        if (!steps || !steps.length) return;
        var flow = runBody();
        var planLabel = '任务拆分（' + steps.length + ' 步）';
        if (!runState.stageThink || !runState.stageThink.body.parentNode) {
            runState.stageThink = makeStage(planLabel, flow, 'stage-think', '🧭', null, false, null, nowTimeTag());
            markRunning(runState.stageThink.state);
            runState.stageThink.el.classList.add('executing');
        } else {
            // thinking 先到、阶段已存在 → 把「思考与拆解」label 原位替换。
            var lbl = runState.stageThink.el.querySelector('.proc-stage-label');
            if (lbl) lbl.textContent = planLabel;
        }
        var host = runState.stageThink.body;
        if (host.querySelector('.run-plan-body')) return; // already shown
        var body = document.createElement('div');
        body.className = 'run-plan-body';
        steps.forEach(function(s, i) {
            var l = document.createElement('div');
            l.className = 'run-plan-step';
            l.textContent = (i + 1) + '. ' + s;
            body.appendChild(l);
        });
        host.appendChild(body);
    }

    // 结尾行 + 最终回答正文 + 产出文件 —— 全部落在 agent-turn 尾部（幂等）。
    function appendTurnClosure() {
        if (!runBlock || !runBlock.parentNode) return;
        if (runState.closureDone) return;
        runState.closureDone = true;
        // 中断场景可能残留未收尾的流式段 → 移除（正文以最终文本重建）。
        if (runTailStreamEl && runTailStreamEl.parentNode) {
            runTailStreamEl.parentNode.removeChild(runTailStreamEl);
        }
        runTailStreamEl = null;
        var kind = runState.exception ? 'error' : (runState.userStopped ? 'stopped' : 'done');
        // 状态首行收尾：⟳ → ✓ / ✗ / ⏹。
        if (runStatusEl && runStatusEl.parentNode) {
            var spin = runStatusEl.querySelector('.turn-status-spin');
            if (spin) {
                spin.textContent = kind === 'error' ? '✗' : (kind === 'stopped' ? '⏹' : '✓');
                spin.classList.remove('turn-status-spin');
            }
        }
        var dur = fmtDuration(Math.round(runState.elapsedMs / 1000) || 0);
        var endText;
        var endCls = 'done';
        if (kind === 'error') {
            endText = '任务失败 · 耗时 ' + dur;
            endCls = 'err';
        } else if (kind === 'stopped') {
            endText = '任务已停止 · 耗时 ' + dur;
            endCls = 'stopped';
        } else {
            endText = '任务已结束 · 耗时 ' + dur +
                (runState.tools.length ? ' · ' + runState.tools.length + ' 步' : '');
        }
        var end = document.createElement('div');
        end.className = 'turn-end ' + endCls;
        end.innerHTML = '<span class="turn-end-tag">最终结果</span>' +
            '<span class="turn-end-text">' + escapeHtml(endText) + '</span>' + nowTimeTag();
        runTailEl.appendChild(end);
        // 思考与拆解阶段收尾。
        if (runState.stageThink && runState.stageThink.state) {
            runState.stageThink.state.textContent = '✓';
            runState.stageThink.state.classList.add('ok');
            clearRunning(runState.stageThink.state);
        }
        // 最终回答正文 / 失败原因：放在结束行之后（无横线、文档段落）。
        var bodyText = kind === 'error' ? (runState.exception || '') : runState.finalText;
        if (bodyText && String(bodyText).trim()) {
            var ans = document.createElement('div');
            ans.className = 'turn-answer';
            // 最终正文首句内嵌时间戳。
            var ansTime = document.createElement('div');
            ansTime.className = 'turn-answer-time';
            ansTime.innerHTML = nowTimeTag();
            ans.appendChild(ansTime);
            var inner = document.createElement('div');
            inner.className = 'msg-bubble' + (kind === 'error' ? ' turn-answer-err' : '');
            inner.innerHTML = formatMessage(String(bodyText));
            ans.appendChild(inner);
            runTailEl.appendChild(ans);
        }
        // 最后一步工具结果：任务被中断/未给出总结时是唯一可见的"结果"，
        // 有正文时折叠展示（不打断阅读，需要时点开即可看到产出）。
        appendLastOut(runTailEl, runState.lastToolLabel, runState.lastToolResult,
            nowTimeTag(), !!(bodyText && String(bodyText).trim()));
        // 任务结束：收起仍在"执行中"的步骤（外层 + 内部细节自动折叠）。
        var execStage = runFlowEl ? runFlowEl.querySelector('.proc-stage.executing') : null;
        if (execStage) {
            execStage.classList.remove('executing');
            collapseStageInner(execStage);
            execStage.classList.add('collapsed');
            var ec = execStage.querySelector('.proc-stage-chev');
            if (ec) ec.textContent = '▸';
        }
        // 单一组：runBlock 持久保留，跨任务连续追加（不再置 null 重建）。
    }

    // "最后输出"块：回显最后一个工具的执行结果（可折叠，页内显隐）。
    function appendLastOut(container, label, text, tsTag, collapsed) {
        if (!container || !text || !String(text).trim()) return null;
        var lo = document.createElement('div');
        lo.className = 'turn-lastout' + (collapsed ? ' collapsed' : '');
        var lab = document.createElement('div');
        lab.className = 'turn-lastout-label';
        lab.innerHTML = '<span class="turn-lastout-chev">' + (collapsed ? '▶' : '▼') + '</span>' +
            '<span class="turn-lastout-title">最后输出' + (label ? '（' + escapeHtml(label) + '）' : '') + '</span>' +
            (tsTag || '');
        lab.addEventListener('click', function() {
            lo.classList.toggle('collapsed');
            var c = lab.querySelector('.turn-lastout-chev');
            if (c) c.textContent = lo.classList.contains('collapsed') ? '▶' : '▼';
        });
        lo.appendChild(lab);
        var pre = document.createElement('div');
        pre.className = 'turn-lastout-text';
        pre.textContent = String(text);
        lo.appendChild(pre);
        container.appendChild(lo);
        return lo;
    }

    // 最后一个工具行默认展开其命令/输出子行（结果直接可见），其余保持折叠。
    function expandLastToolRow(flow) {
        if (!flow) return;
        var tools = flow.querySelectorAll('.run-tool');
        if (!tools.length) return;
        var last = tools[tools.length - 1];
        var subs = last.querySelectorAll('.tool-sub');
        for (var i = 0; i < subs.length; i++) {
            subs[i].classList.add('expanded');
        }
        last.classList.remove('collapsed');
    }

    // 产出文件行（组尾，回形针按钮弹列表）。
    function ensureTurnFiles(paths) {
        if (!runTailEl) return;
        var wrap = document.createElement('div');
        wrap.className = 'turn-files';
        wrap.innerHTML = nowTimeTag() +
            '<button type="button" class="files-icon-btn" title="查看生成的文件（' + paths.length + ' 个）">📎 ' + paths.length + ' 个产出文件</button>';
        var btn = wrap.querySelector('.files-icon-btn');
        if (btn) btn.addEventListener('click', function() { openFilesPopup(paths); });
        runTailEl.appendChild(wrap);
    }

    function createAgentMessage(text) {
        var el = document.createElement('div');
        el.className = 'msg agent';
        // 左侧 MyClaw 图标（与用户头像对应，表示这是 MyClaw 输出）。
        el.appendChild(makeAgentAvatarEl());
        var bubble = document.createElement('div');
        bubble.className = 'msg-bubble';
        bubble.innerHTML = formatMessage(text);
        el.appendChild(bubble);
        return el;
    }

    // 时间嵌入内容行内：timeHtml 为可选的 <span class="hist-time">…</span>
    // （实时发送时省略 → 自动用当前时刻；历史回放传入 fmtHistoryTime）。
    function createUserMessage(text, timeHtml) {
        var el = document.createElement('div');
        el.className = 'msg user';
        var bubble = document.createElement('div');
        bubble.className = 'msg-bubble';
        var t = document.createElement('span');
        t.className = 'inline-time';
        t.innerHTML = timeHtml || nowTimeTag();
        bubble.appendChild(t);
        var c = document.createElement('span');
        c.className = 'user-text';
        c.textContent = text;
        bubble.appendChild(c);
        el.appendChild(bubble);
        // 右侧用户头像（row-reverse 下排到气泡右侧）。
        el.appendChild(makeUserAvatarEl());
        return el;
    }

    // (旧 createErrorBlock / appendTaskStarted / createFinalSummary 已由
    //  agent-turn 结束行 + 正文统一取代，此处移除。)

    // Downloadable file list for files the agent generated (write_file).
    // Image files are also rendered inline so the user can see them directly.
    var IMAGE_FILE_RE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
    function isImagePath(p) { return IMAGE_FILE_RE.test(String(p)); }

    // Extract file paths (~/... or /abs/... or rel) mentioned in the final
    // answer, so files the model reports at the end land in the download list.
    function extractFilePaths(text) {
        var out = [];
        if (!text) return out;
        var re = /((?:~|\/|[A-Za-z]:)[^\s'"<>|;:`!@#$%^&*()=+\[\]{}\\]*(?:\.(?:html?|js|css|md|txt|json|rs|toml|py|png|jpe?g|gif|webp|svg|pdf|docx?|xlsx?|zip|tar|gz)))/g;
        var seen = {};
        var m;
        while ((m = re.exec(text)) !== null) {
            var p = m[1];
            if (!seen[p]) { seen[p] = true; out.push(p); }
        }
        return out;
    }

    // Paperclip icon appended after the closing summary: hidden file list,
    // click opens a small popup with the generated files (download links).
    function createFilesIcon(paths) {
        var el = document.createElement('div');
        el.className = 'msg files-icon-wrap';
        el.innerHTML = nowTimeTag() +
            '<button type="button" class="files-icon-btn" title="查看生成的文件（' + paths.length + ' 个）">📎 ' + paths.length + '</button>';
        el.querySelector('.files-icon-btn').addEventListener('click', function() {
            openFilesPopup(paths);
        });
        return el;
    }

    // Small popup (modal) listing the generated files with download links.
    function openFilesPopup(paths) {
        var overlay = document.createElement('div');
        overlay.className = 'files-popup-overlay';
        var box = document.createElement('div');
        box.className = 'files-popup';
        var head = document.createElement('div');
        head.className = 'files-popup-head';
        head.innerHTML = '<span>📎 生成的文件（' + paths.length + '）</span>' +
            '<button type="button" class="files-popup-close" aria-label="关闭">✕</button>';
        var list = document.createElement('div');
        list.className = 'files-popup-list';
        paths.forEach(function(p) {
            var name = String(p).split(/[\\/]/).pop() || p;
            var tokenQ = '&token=' + encodeURIComponent(sessionToken || '');
            var row = document.createElement('div');
            row.className = 'files-popup-row';
            if (isImagePath(p)) {
                var img = document.createElement('img');
                img.className = 'file-img';
                img.src = '/api/files?path=' + encodeURIComponent(p) + '&inline=1' + tokenQ;
                img.alt = name;
                img.loading = 'lazy';
                row.appendChild(img);
            }
            var a = document.createElement('a');
            a.className = 'file-link';
            a.href = '/api/files?path=' + encodeURIComponent(p) + tokenQ;
            a.download = name;
            a.textContent = '⬇ ' + name;
            a.title = p;
            row.appendChild(a);
            list.appendChild(row);
        });
        box.appendChild(head);
        box.appendChild(list);
        overlay.appendChild(box);
        document.body.appendChild(overlay);
        function close() {
            if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
            document.removeEventListener('keydown', esc);
        }
        function esc(e) { if (e.key === 'Escape') close(); }
        overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); });
        box.querySelector('.files-popup-close').addEventListener('click', close);
        document.addEventListener('keydown', esc);
        return overlay;
    }

    function showUsage(prompt, completion, total) {
        var nums = '<span class="usage-val">' + prompt + ' in</span> ' +
            '<span class="usage-val">' + completion + ' out</span> ' +
            '<span class="usage-total">' + total + ' total</span>';
        // 同一任务内只保留一个 Tokens 块：已存在则就地更新数值，避免 DeepSeek 多轮
        // usage 事件导致界面上同时出现多个 "Tokens:"。
        if (liveUsageBlock && liveUsageBlock.parentNode) {
            var body = liveUsageBlock.querySelector('.usage-body');
            if (body) body.innerHTML = nums;
            scrollToBottom();
            return;
        }
        var el = document.createElement('div');
        el.className = 'usage-block';
        el.innerHTML = nowTimeTag() +
            '<span class="usage-label">Tokens:</span> ' +
            '<span class="usage-body">' + nums + '</span>';
        (runFlowEl || chatEl).appendChild(el);
        liveUsageBlock = el;
        scrollToBottom();
    }

    function showApprovalBanner(data) {
        var el = document.createElement('div');
        el.className = 'approval-banner';
        el.innerHTML =
            '<div class="approval-icon">&#9888;</div>' +
            '<div class="approval-content">' +
                '<div class="approval-title">Approval needed: ' + escapeHtml(data.tool_name) + '</div>' +
                '<div class="approval-args">' + escapeHtml(data.args) + '</div>' +
                '<div class="approval-reason">' + escapeHtml(data.reason) + '</div>' +
            '</div>' +
            '<div class="approval-actions">' +
                '<button class="approve-btn" data-id="' + escapeHtml(data.id) + '">Approve</button>' +
                '<button class="deny-btn" data-id="' + escapeHtml(data.id) + '">Deny</button>' +
            '</div>';
        (runFlowEl || chatEl).appendChild(el);
        scrollToBottom();

        el.querySelector('.approve-btn').addEventListener('click', function() {
            send({ type: 'approve', id: data.id, approved: true });
            el.remove();
        });
        el.querySelector('.deny-btn').addEventListener('click', function() {
            send({ type: 'approve', id: data.id, approved: false });
            el.remove();
        });
    }

    function showLoopWarning(message, count) {
        var el = document.createElement('div');
        el.className = 'loop-warning-block';
        el.innerHTML = nowTimeTag() +
            '<span class="loop-warning-icon">&#128260;</span>' +
            '<span>' + escapeHtml(message) + '</span>';
        (runFlowEl || chatEl).appendChild(el);
        scrollToBottom();
        setTimeout(function() {
            if (el.parentNode) el.parentNode.removeChild(el);
        }, 8000);
    }

    function showStatusMessage(message) {
        var el = document.createElement('div');
        el.className = 'status-msg';
        el.innerHTML = nowTimeTag() + '<span>' + escapeHtml(message) + '</span>';
        (runFlowEl || chatEl).appendChild(el);
        scrollToBottom();
        setTimeout(function() {
            if (el.parentNode) el.parentNode.removeChild(el);
        }, 5000);
    }

    // === Task observability (P1-5) ===

    var stepListEl = null;  // live step-status container

    function upsertStepStatus(label, status, detail) {
        if (!stepListEl || !stepListEl.parentNode) {
            stepListEl = document.createElement('div');
            stepListEl.className = 'steps-block';
            chatEl.appendChild(stepListEl);
        }
        var item = stepListEl.querySelector('[data-label="' + escapeHtml(label) + '"]');
        if (!item) {
            item = document.createElement('div');
            item.className = 'step-item pending';
            item.dataset.label = label;
            item.innerHTML =
                '<span class="step-dot"></span>' +
                '<span class="step-label">' + escapeHtml(label) + '</span>' +
                '<span class="step-status-text">pending</span>';
            stepListEl.appendChild(item);
        }
        item.className = 'step-item ' + status;
        var txt = item.querySelector('.step-status-text');
        if (txt) txt.textContent = status + (detail ? ' (' + detail + ')' : '');
        scrollToBottom();
    }

    // === Task management (each task keeps its own context) ===

    function renderTaskList(list) {
        tasks = list || [];
        var listEl = document.getElementById('task-list-items');
        var hasActive = false;
        activeTaskId = null;
        if (!listEl) return;
        listEl.innerHTML = '';
        // WorkBuddy-style group header: "任务 (N) ⌄".
        if (tasks.length > 0) {
            var head = document.createElement('div');
            head.className = 'task-group-head';
            head.innerHTML = '<span>任务 (' + tasks.length + ')</span>' +
                '<svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M2 3.5l3 3 3-3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
            listEl.appendChild(head);
        }
        tasks.forEach(function(t) {
            if (t.active) { activeTaskId = t.id; hasActive = true; }
            var item = document.createElement('div');
            item.className = 'task-item' + (t.active ? ' active' : '');
            item.dataset.id = t.id;
            item.title = '切换到此任务（独立上下文）';
            // Running spinner while this task is executing.
            if (isProcessing && t.id === activeTaskId) {
                item.appendChild(createTaskSpinner());
            }
            var name = document.createElement('div');
            name.className = 'task-item-name';
            name.textContent = t.name;
            name.title = t.name;
            item.appendChild(name);
            // Relative time on the right (WorkBuddy style).
            var rt = relTime(t.created_at);
            if (rt) {
                var timeEl = document.createElement('span');
                timeEl.className = 'task-item-time';
                timeEl.textContent = rt;
                timeEl.title = new Date(t.created_at * 1000).toLocaleString();
                item.appendChild(timeEl);
            }
            item.addEventListener('click', function() {
                draftNewTask = false; // leaving the draft hero
                if (t.id !== activeTaskId) {
                    send({ type: 'task_switch', id: t.id });
                    enterChatMode(); // 切换任务：输入窗回到底部
                }
                // Mobile: auto-collapse the off-canvas sidebar after picking a
                // task (or re-picking the active one) so the pane is visible.
                closeSidebar();
            });
            // "..." menu: rename / delete
            var menu = document.createElement('div');
            menu.className = 'task-item-menu';
            var more = document.createElement('button');
            more.className = 'task-item-more';
            more.title = '更多操作';
            more.innerHTML = '&#8942;';
            var dd = document.createElement('div');
            dd.className = 'task-menu-dropdown';
            var renameItem = document.createElement('div');
            renameItem.className = 'task-menu-item';
            renameItem.textContent = '重命名';
            renameItem.addEventListener('click', function(e) {
                e.stopPropagation();
                menu.classList.remove('open');
                var newName = prompt('重命名任务：', t.name);
                if (newName && newName.trim()) send({ type: 'task_rename', id: t.id, name: newName.trim() });
            });
            var delItem = document.createElement('div');
            delItem.className = 'task-menu-item danger';
            delItem.textContent = '删除任务';
            delItem.addEventListener('click', function(e) {
                e.stopPropagation();
                menu.classList.remove('open');
                if (!confirm('删除任务「' + t.name + '」？其对话上下文将丢失。')) return;
                send({ type: 'task_delete', id: t.id });
            });
            dd.appendChild(renameItem);
            dd.appendChild(delItem);
            more.addEventListener('click', function(e) {
                e.stopPropagation();
                // Close any other open menu first.
                document.querySelectorAll('.task-item-menu.open').forEach(function(m) {
                    if (m !== menu) m.classList.remove('open');
                });
                menu.classList.toggle('open');
            });
            menu.appendChild(more);
            menu.appendChild(dd);
            item.appendChild(menu);
            listEl.appendChild(item);
        });
        if (listEl.querySelector('.empty-state') && tasks.length > 0) {
            // clear stale empty hint
        }
        if (tasks.length === 0) {
            var empty = document.createElement('div');
            empty.className = 'empty-state';
            empty.innerHTML = '暂无任务。在下方提问会自动创建第一个任务。';
            listEl.appendChild(empty);
        }
        // Title bar shows the active task name plus its message count
        // (the sidebar no longer shows per-task meta).
        var taskNameEl = document.getElementById('topbar-task-name');
        if (taskNameEl) {
            if (draftNewTask) {
                // Draft mode: keep the "新对话" label; a task_list arriving
                // from another client must not stomp the fresh-task hero.
                taskNameEl.textContent = '新对话';
            } else {
                var active = tasks.find(function(x) { return x.active; });
                taskNameEl.textContent = active ? active.name + '（' + active.messages + ' 条消息）' : '新对话';
                taskNameEl.title = active ? active.name : '';
            }
        }
        // Workspace pin button tooltip mirrors the active task's workspace:
        // full path on hover, click opens the directory picker.
        if (wsBrowseBtn) {
            var activeForWs = tasks.find(function(x) { return x.active; });
            var ws = activeForWs ? (activeForWs.workspace || '') : '';
            wsBrowseBtn.title = ws ? '工作区：' + ws : '选择工作区目录';
        }
        // Replay the active task's conversation when the pane shows a
        // different task (page load, task switch from the sidebar, a switch
        // performed by another connected client, ...). Switching is allowed
        // even while a task is processing: the pane follows the newly active
        // task, and the live event stream keeps feeding the run-block for the
        // task that is still executing.
        var activeTask = tasks.find(function(x) { return x.active; });
        if (activeTask) {
            // Draft mode: the pane shows the new-task hero, not any existing
            // task's history — don't replay over it until the user sends.
            if (activeTask.id !== paneTaskId && !draftNewTask) {
                // Just-sent draft: the backend created + activated a fresh
                // task; pin the pane to it WITHOUT replaying (the user message
                // and the live run-block are already rendered).
                if (pendingNewChat) {
                    paneTaskId = activeTask.id;
                    pendingNewChat = false;
                } else {
                    renderTaskHistory(activeTask);
                }
            }
            // Keep the shown-task marker in sync so the post-run `task_list`
            // does not replay over the live pane either.
            if (activeTask.id === activeTaskId) paneTaskId = activeTask.id;
        } else {
            // 无活跃任务：进入空白态，输入窗居中显示「新对话」。
            paneTaskId = null;
            if (!draftNewTask) enterBlankMode();
        }
    }

    // Spinner shown next to a task while its agent is executing.
    function createTaskSpinner() {
        var s = document.createElement('span');
        s.className = 'task-item-running';
        s.title = '执行中…';
        s.innerHTML = '<svg class="spin" width="12" height="12" viewBox="0 0 24 24">' +
            '<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="42" stroke-linecap="round"/></svg>';
        return s;
    }

    // Sync the running spinner across already-rendered task items.
    function updateTaskRunningUI() {
        document.querySelectorAll('.task-item').forEach(function(item) {
            var id = item.getAttribute('data-id');
            var running = isProcessing && id === activeTaskId;
            var spin = item.querySelector('.task-item-running');
            if (running && !spin) {
                item.insertBefore(createTaskSpinner(), item.firstChild);
            } else if (!running && spin) {
                spin.remove();
            }
        });
    }

    // Close any open task "..." menu when clicking elsewhere.
    if (!window.__taskMenuCloseBound) {
        window.__taskMenuCloseBound = true;
        document.addEventListener('click', function() {
            document.querySelectorAll('.task-item-menu.open').forEach(function(m) {
                m.classList.remove('open');
            });
        });
    }

    // Called when the user switches task: the chat pane is reset because a
    // task is its own conversation — history is not replayed (MVP).
    // 单一输入组件：始终复用底部 #message-input，仅通过 body.mode-newtask
    // 在「居中(空白态) / 底部(对话态)」之间切换，避免两份输入实现。
    function setComposerCentered(on) {
        document.body.classList.toggle('mode-newtask', !!on);
        if (on && inputEl) inputEl.focus();
    }
    function enterBlankMode() {
        resetChatPane('有什么可以帮你？');
        setComposerCentered(true);
    }
    function enterChatMode() {
        setComposerCentered(false);
    }

    function resetChatPane(title) {
        paneTaskId = null;
        // 清空整个会话面板（不再保留任何永久子节点）。
        Array.from(chatEl.children).forEach(function(child) { child.remove(); });
        var h = document.createElement('div');
        h.className = 'welcome-msg';
        // Fresh-task hero (WorkBuddy-style): big greeting + one-line hint.
        // 居中的底部输入窗 #message-input 渲染在该标题正下方。
        h.innerHTML = '<div class="welcome-icon">🗂️</div><h3>' + escapeHtml(title) +
            '</h3><p>今天帮你做些什么？输入内容开始，任务名将自动生成。</p>';
        chatEl.insertBefore(h, chatEl.firstChild);
    }

    // Replay a task's stored execution timeline in the chat pane: user
    // prompts, thinking chains, every tool call + result, and final answers —
    // each with its wall-clock time point. This is what makes switching to a
    // historical task show the real run process instead of an empty/stale
    // pane (fix: history used to only carry user + final assistant text).
    // "1m23s" style duration (XX分XX秒). Sub-minute shows 0m + seconds.
    function fmtDuration(sec) {
        if (!sec || sec < 0) return '0m0s';
        var m = Math.floor(sec / 60);
        var s = sec % 60;
        return m + 'm' + s + 's';
    }
    // Relative time for the sidebar task rows (WorkBuddy style):
    // "刚刚 / N分钟前 / N小时前 / N天前 / YYYY-MM-DD".
    function relTime(sec) {
        if (!sec) return '';
        var d = new Date(sec * 1000);
        if (isNaN(d.getTime())) return '';
        var diff = (Date.now() - d.getTime()) / 1000;
        if (diff < 60) return '刚刚';
        if (diff < 3600) return Math.floor(diff / 60) + '分钟前';
        if (diff < 86400) return Math.floor(diff / 3600) + '小时前';
        if (diff < 7 * 86400) return Math.floor(diff / 86400) + '天前';
        var p = function(n) { return (n < 10 ? '0' : '') + n; };
        return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
    }
    function fmtHistoryTime(ts) {        if (!ts) return '';
        var d = new Date(ts * 1000);
        if (isNaN(d.getTime())) return '';
        var p = function(n) { return (n < 10 ? '0' : '') + n; };
        return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
    }
    function historyTimeTag(m) {
        var ts = fmtHistoryTime(m && m.ts);
        return ts ? '<span class="hist-time">' + ts + '</span>' : '';
    }

    // 构造一条"工具行"（思考后的执行步骤）：与直播路径共用 buildToolRow ——
    // 标题 = 命令/参数简略内容，点击一次展开参数+输出两节。结果由后续
    // tool 消息回填 outPre。
    function buildHistoryToolRow(fn, args, tsTag, desc) {
        var meta = toolMeta(fn);
        var argsS = '';
        try {
            argsS = (typeof args === 'string') ? args
                : (args && typeof args === 'object' ? JSON.stringify(args, null, 2) : String(args || ''));
        } catch (e) { argsS = String(args || ''); }
        var summary = '';
        try {
            var parsed = (typeof args === 'string' && args.trim()) ? JSON.parse(args) : (args || {});
            summary = meta.key(parsed) || '';
        } catch (e) {}
        var row = buildToolRow(meta, argsS, summary || meta.label, tsTag || '', '✓', true);
        return { el: row.el, outPre: row.outPre, state: row.state, label: meta.label, descEl: null, descText: (desc || summary || '') };
    }

    // 历史回放：把连续的非 user 消息（assistant 轮 + tool 结果）合并为
    // 一条 MyClaw 长回复(agent-turn)。行内时间戳前置；无 run-head/history-run
    // 分隔折叠条；结尾追加"任务已结束 · 耗时"行。
    // 历史回放：把连续的非 user 消息（assistant 轮 + tool 结果）合并渲染进
    // 传入的共享 flow（单一组对话模型：不再为每个 run 新建 wrapper / 头行 /
    // 折叠分隔条）。行内时间戳前置；结尾追加"任务已结束 · 耗时"行。
    function buildHistoryTurn(msgs, goal, flow) {
        // 组装各阶段：思考与拆解 / 步骤 N（与实时主流程同构）。
        var pending = []; // 未回填结果的工具行 {id, el, outPre, state, descEl}
        var notes = [];
        var lastAnswer = null;
        var lastToolResult = '';
        var lastToolLabel = '';
        var hasProcess = false;
        var stepNo = 0;
        var curStage = null;   // 当前阶段容器 {el, body, state}
        var thinkStage = null;
        // 阶段内的思考块（历史：reasoning 归入所属阶段）。
        function histThinkBlock(m, host) {
            var th = document.createElement('div');
            th.className = 'run-think';
            th.innerHTML =
                '<div class="run-think-head">' +
                    '<span class="run-think-icon">' + THINK_BULB_SVG + '</span>' +
                    '<span class="run-think-label">深度思考</span>' +
                    '<span class="run-think-toggle">' + THINK_CHEVRON_SVG + '</span>' +
                    historyTimeTag(m) +
                '</div>' +
                '<div class="run-think-text"></div>';
            th.querySelector('.run-think-head').addEventListener('click', function() {
                th.classList.toggle('collapsed');
            });
            th.querySelector('.run-think-text').textContent = compactThinking(m.reasoning);
            host.appendChild(th);
            return th;
        }
        msgs.forEach(function(m) {
            var tag = historyTimeTag(m);
            if (m.role === 'assistant') {
                var tcs = m.tool_calls || [];
                var c0 = String(m.content || '').trim();
                var opened = false; // 本条 assistant 是否已打开阶段
                if (m.reasoning) {
                    hasProcess = true;
                    // 第一条思考 → 「思考与拆解」阶段；之后每轮思考 → 新步骤。
                    if (!thinkStage && stepNo === 0) {
                        thinkStage = makeStage('思考与拆解', flow, 'stage-think', '🧭', null, true, null, historyTimeTag(m));
                        thinkStage.state.textContent = '✓';
                        curStage = thinkStage;
                        histThinkBlock(m, thinkStage.body);
                    } else {
                        stepNo += 1;
                        curStage = makeStage('', flow, 'stage-step', '⚙️', null, true, true, historyTimeTag(m));
                        histThinkBlock(m, curStage.body);
                    }
                    opened = true;
                }
                // content 与 tool_calls 同现时，content 是模型对该步骤的
                // 概括说明（有依据的总结）→ 作为工具行的常显描述。
                var tcsDesc = (tcs.length && c0) ? c0 : '';
                tcs.forEach(function(tc) {
                    hasProcess = true;
                    // 本轮尚未开阶段（无思考的纯工具轮）→ 新起一个步骤；
                    // 同一条 assistant 的多个工具调用归入同一步骤。
                    if (!opened) {
                        stepNo += 1;
                        curStage = makeStage('', flow, 'stage-step', '⚙️', null, true, true, historyTimeTag(m));
                        opened = true;
                    }
                    var fn = (tc.function || {}).name || 'tool';
                    var args = (tc.function || {}).arguments;
                    var r = buildHistoryToolRow(fn, args, tag, tcsDesc);
                    (curStage ? curStage.body : flow).appendChild(r.el);
                    // 工具概括并入步骤标题行（单行显示）。
                    if (r.descText) setStageDesc(curStage, r.descText);
                    var id = tc.id || tc.index;
                    if (id !== undefined && id !== null) {
                        pending.push({ id: String(id), el: r.el, outPre: r.outPre, state: r.state, label: r.label, descEl: r.descEl, stage: curStage });
                    }
                });
                // content：带 tool_calls 时已作 desc；否则它是最终回答（最后一条生效）。
                if (c0 && !tcs.length) {
                    lastAnswer = { text: c0, tsTag: tag };
                }
            } else if (m.role === 'tool') {
                hasProcess = true;
                var raw = String(m.content || '');
                lastToolResult = raw;
                lastToolLabel = '';
                var summary = resultSummary(raw);
                var idx = -1;
                var cid = m.tool_call_id;
                if (cid !== undefined) {
                    for (var k = 0; k < pending.length; k++) {
                        if (pending[k].id === String(cid)) { idx = k; break; }
                    }
                } else if (pending.length) {
                    idx = pending.length - 1; // 顺序兜底
                }
                if (idx >= 0) {
                    var target = pending.splice(idx, 1)[0];
                    lastToolLabel = target.label || '';
                    if (target.outPre) target.outPre.textContent = String(raw || '(空输出)');
                    if (target.state) target.state.textContent = '✓';
                    if (target.stage && target.stage.state) {
                        target.stage.state.textContent = '✓';
                        target.stage.state.classList.add('ok');
                    }
                    if (summary) {
                        // 结果概括：单行显示（点击展开全文）。
                        var rs = makeOneLineRow('run-tool-result', summary, false);
                        var anchor = target.el.querySelector('.run-tool-head');
                        if (anchor && anchor.parentNode) {
                            anchor.parentNode.insertBefore(rs, anchor.nextSibling);
                        } else {
                            target.el.appendChild(rs);
                        }
                    }
                    target.el.classList.remove('running');
                    target.el.classList.add('ok');
                } else {
                    // 孤立的工具结果（无对应调用记录）→ 结果行。
                    var lone = document.createElement('div');
                    lone.className = 'run-tool history-tool ok';
                    var loneHead = document.createElement('div');
                    loneHead.className = 'run-tool-head';
                    loneHead.innerHTML = tag +
                        '<span class="tool-line-icon">🔧</span>' +
                        '<span class="tool-line-name">工具结果</span>' +
                        '<span class="tool-line-state">✓</span>' +
                        '<span class="run-tool-summary hist-tool-summary">' + escapeHtml(summary.slice(0, 120)) + '</span>';
                    flow.appendChild(loneHead);
                    void lone;
                }
            } else if (m.role === 'system') {
                // 上下文压缩产生的「历史对话摘要」→ 独立第一层行(turn-head-row 样式，
                // 直接挂在 flow 下，与步骤/最终结果同级)；标题即摘要正文（单行不换行，
                // 超长省略号），点击状态行展开/收起全文。
                var sTxt = String(m.content || '').trim();
                if (!sTxt || sTxt.indexOf('历史对话摘要') < 0) return;
                // 单行、裁短；前文直接嵌入「任务摘要：」前缀，看起来像一行。
                var preview = oneLineText(sTxt.replace(/【历史对话摘要】/g, ''), 90);
                var full = sTxt.replace(/^【历史对话摘要】\s*/, '');
                var wrap = document.createElement('div');
                wrap.className = 'hist-digest collapsed';
                var dg = document.createElement('div');
                dg.className = 'turn-head-row hist-digest-status';
                dg.innerHTML =
                    '<span class="turn-status-icon">🗂️</span>' +
                    '<span class="hist-digest-text">任务摘要：' + escapeHtml(preview) + '</span>' +
                    '<span class="hist-digest-chev">▾</span>' +
                    historyTimeTag(m);
                var dgBody = document.createElement('div');
                dgBody.className = 'hist-digest-body';
                dgBody.textContent = full;
                wrap.appendChild(dg);
                wrap.appendChild(dgBody);
                // 按真实发生时刻嵌回步骤流：forEach 按消息顺序处理，此时前面的
                // 阶段已就位，挂到 flow 即落在摘要实际产生的 chrono 位置（步骤之间）。
                flow.appendChild(wrap);
                dg.addEventListener('click', function() {
                    wrap.classList.toggle('collapsed');
                    var c = dg.querySelector('.hist-digest-chev');
                    if (c) c.textContent = wrap.classList.contains('collapsed') ? '▸' : '▾';
                });
            }
        });
        // 结束行：时间前缀 + 任务已结束（可算耗时则附上）。
        var firstTs = msgs.length ? (msgs[0] && msgs[0].ts) : null;
        var lastTs = msgs.length ? (msgs[msgs.length - 1] && msgs[msgs.length - 1].ts) : null;
        var durTxt = '';
        if (hasProcess && firstTs && lastTs && lastTs > firstTs) {
            durTxt = ' · 耗时 ' + fmtDuration(Math.round(lastTs - firstTs));
        }
        if (hasProcess) {
            var end = document.createElement('div');
            end.className = 'turn-end done';
            end.innerHTML = '<span class="turn-end-tag">最终结果</span>' +
                '<span class="turn-end-text">任务已结束' + (durTxt ? escapeHtml(durTxt) : '') + '</span>' +
                (msgs.length ? historyTimeTag(msgs[msgs.length - 1]) : '');
            flow.appendChild(end);
        }
        // 最终正文（组内最后一段 assistant 内容）。
        if (lastAnswer) {
            var ans = document.createElement('div');
            ans.className = 'turn-answer';
            // 最终正文首句内嵌时间戳。
            var ansTime = document.createElement('div');
            ansTime.className = 'turn-answer-time';
            ansTime.innerHTML = msgs.length ? historyTimeTag(msgs[msgs.length - 1]) : nowTimeTag();
            ans.appendChild(ansTime);
            var inner = document.createElement('div');
            inner.className = 'msg-bubble';
            inner.innerHTML = formatMessage(lastAnswer.text);
            ans.appendChild(inner);
            flow.appendChild(ans);
            // 有正文时，"最后输出"折叠附在末尾（需要产出详情时点开）。
            appendLastOut(flow, lastToolLabel, lastToolResult,
                msgs.length ? historyTimeTag(msgs[msgs.length - 1]) : '', true);
        } else if (lastToolResult && String(lastToolResult).trim()) {
            // 该轮没有最终总结（任务中断/纯工具流）→ 直接展示最后一步结果。
            appendLastOut(flow, lastToolLabel, lastToolResult,
                msgs.length ? historyTimeTag(msgs[msgs.length - 1]) : '', false);
        }
        // 最后一个工具行默认展开（结果直接可见）。
        expandLastToolRow(flow);
    }

    // 判断消息是否属于"过程"(思考/工具) — 决定是否需要 agent-turn 组。
    function isProcessMsg(m) {
        if (m.role === 'tool') return true;
        // 上下文压缩摘要（system）也算过程，保证它能落进 agent-turn 的阶段内。
        if (m.role === 'system' && String(m.content || '').indexOf('历史对话摘要') >= 0) return true;
        if (m.role === 'assistant') {
            if (m.reasoning) return true;
            if ((m.tool_calls || []).length) return true;
        }
        return false;
    }

    var histPending = [];

    // 把已收集的连续 assistant/tool 消息渲染为一组（直到下一个 user/system）。
    function renderPendingHistory(goal) {
        if (!histPending.length) return;
        var grp = [];
        while (histPending.length) {
            var m = histPending[0];
            // 只有 user 才切分新组；system 中仅「历史对话摘要」属于过程
            //（放入当前组作系统处理行），其它系统注入直接丢弃。
            if (m.role === 'user') break;
            var isDigest = m.role === 'system'
                && String(m.content || '').indexOf('历史对话摘要') >= 0;
            if (m.role === 'system' && !isDigest) {
                histPending.shift();
                continue;
            }
            grp.push(histPending.shift());
        }
        if (!grp.length) return;
        ensureRunBlock();              // 单一组：复用同一 run-flow（不另起 wrapper）
        var anyProcess = grp.some(isProcessMsg);
        if (anyProcess) {
            buildHistoryTurn(grp, goal || '', runFlowEl);
        } else {
            // 纯文本 assistant 消息 → 各自独立图标消息（system 由阶段渲染，跳过）。
            grp.forEach(function(am) {
                if (am.role !== 'assistant') return;
                var c = String(am.content || '').trim();
                if (!c) return;
                var amEl = createAgentMessage(c);
                var ts = fmtHistoryTime(am.ts);
                if (ts) {
                    var tb = document.createElement('div');
                    tb.className = 'hist-time-row';
                    tb.innerHTML = '<span class="hist-time">' + ts + '</span>';
                    amEl.insertBefore(tb, amEl.firstChild);
                }
                runFlowEl.appendChild(amEl);
            });
        }
        scrollToBottom();
    }

    function renderTaskHistory(task) {
        paneTaskId = task ? task.id : null;
        // Drop stale live-streaming refs before wiping the pane.
        removeThinking();
        finalizeStream();
        clearProcessEl();
        stepListEl = null;
        histPending = [];
        Array.from(chatEl.children).forEach(function(child) { child.remove(); });
        if (task && task.history && task.history.length > 0) {
            // 单一组：整个会话只建一个 agent-turn 容器（头像/状态头行仅一次），
            // 历史步骤与用户气泡连续平铺进同一个 turn-flow。
            runGoal = task && task.name ? String(task.name).replace(/\s+/g, ' ').slice(0, 30) : '';
            ensureRunBlock();
            // 顺序把消息压入队列，遇到 user/system 即把此前积累的 assistant/tool
            // 组渲染掉，再处理分隔消息。
            var lastUserText = '';
            task.history.forEach(function(m) {
                // 单条消息渲染失败不应让整个回放变空白。
                try {
                if (m.role === 'user' || m.role === 'system') {
                    if (m.role === 'user') {
                        renderPendingHistory(lastUserText);
                        lastUserText = String(m.content || '').replace(/\s+/g, ' ').slice(0, 30);
                        // 时间嵌入内容行内(单行小字灰)，不再用独立的 hist-time-row。
                        var tsHtml = fmtHistoryTime(m.ts)
                            ? '<span class="hist-time">' + fmtHistoryTime(m.ts) + '</span>' : '';
                        var u = createUserMessage(m.content || '', tsHtml);
                        runFlowEl.appendChild(u);
                        } else {
                        // 上下文压缩摘要（system 角色）随所属回合一起进入组，
                        // 由 buildHistoryTurn 放进当前阶段的「系统处理」行；
                        // 其它系统注入（kshare 预检索等）不渲染。
                        var sc = String(m.content || '');
                        if (sc.indexOf('历史对话摘要') >= 0) {
                            histPending.push(m);
                        }
                        }
                } else {
                    histPending.push(m);
                }
                } catch (e) {
                    window.__lastErr = String((e && e.stack) || e);
                    if (window.console) console.error('history replay entry failed', e);
                }
            });
            try {
                renderPendingHistory(lastUserText);
            } catch (e2) {
                window.__lastErr = String((e2 && e2.stack) || e2);
                if (window.console) console.error('history replay group failed', e2);
            }
            // 历史回放完成：状态头行的 ⟳ 不应持续旋转（已是过去记录）。
            if (runStatusEl && runStatusEl.parentNode) {
                var sp = runStatusEl.querySelector('.turn-status-spin');
                if (sp) sp.remove();
                var tx = runStatusEl.querySelector('.turn-status-text');
                if (tx) tx.textContent = '历史对话' + (task && task.name ? '：' + task.name : '');
            }
            // 有历史记录：输入窗回到页面底部(对话态)。
            setComposerCentered(false);
        } else {
            var h = document.createElement('div');
            h.className = 'welcome-msg';
            h.innerHTML = '<div class="welcome-icon">🗂️</div><h3>' + escapeHtml(task ? task.name : '新对话') +
                '</h3><p>每个任务拥有独立的对话上下文。发送消息后开始。</p>';
            chatEl.insertBefore(h, chatEl.firstChild);
            // 无历史记录：输入窗居中(空白态)。
            setComposerCentered(true);
        }
        scrollToBottom();
    }

    // === Chat actions ===

    // Demo: "全列表块显示用例" replays every renderable block in the Q&A
    // pane with realistic content — no task is actually run, no tools are
    // executed. Useful to eyeball each block's styling / timeline order.
    function runAllBlocksDemo() {
        var queue = [];  // [{type, data, delay}]
        function push(type, data, delay) { queue.push({ type: type, data: data, delay: delay || 0 }); }

        // Round 1
        push('thinking', { iteration: 1 });
        push('reasoning_delta', { delta: '我需要先分析任务：\n1. 检查目标目录结构\n2. 生成入口页面\n3. 生成两个游戏页面\n4. 验证文件完整性\n\n开始执行。' }, 300);
        push('text_delta', { delta: '先查看目录，确认当前环境。' }, 400);
        push('tool_call', { name: 'list_directory', args: JSON.stringify({ path: '/Users/admin/cube' }) }, 300);
        push('tool_result', { name: 'list_directory', result: '[DIR]  games\n[FILE]   README.md', success: true }, 400);
        // Retry demo
        push('retry', { stage: 'kshare', attempt: 1, max: 2, reason: 'connection reset' }, 500);
        push('retry', { stage: 'llm', attempt: 2, max: 3, reason: 'timeout' }, 400);
        // Failing tool
        push('tool_call', { name: 'read_file', args: JSON.stringify({ path: '/Users/admin/cube/missing.txt' }) }, 300);
        push('tool_result', { name: 'read_file', result: 'No such file or directory (os error 2)', success: false }, 400);

        // Round 2
        push('thinking', { iteration: 2 });
        push('reasoning_delta', { delta: '目标文件不存在，改写入新文件。生成完整可玩的网页游戏合集：入口页 + 俄罗斯方块 + 贪吃蛇。' }, 300);
        push('text_delta', { delta: '开始写入三个页面文件。' }, 400);
        push('tool_call', { name: 'write_file', args: JSON.stringify({ path: '/Users/admin/cube/games/index.html', content: '<!DOCTYPE html>...入口页...' }) }, 300);
        push('tool_result', { name: 'write_file', result: 'index.html written: 2646 bytes', success: true }, 400);
        push('tool_call', { name: 'write_file', args: JSON.stringify({ path: '/Users/admin/cube/games/tetris.html', content: '<!DOCTYPE html>...俄罗斯方块...' }) }, 300);
        push('tool_result', { name: 'write_file', result: 'tetris.html written: 10383 bytes', success: true }, 400);

        // Files + final
        push('files', { paths: ['/Users/admin/cube/games/index.html', '/Users/admin/cube/games/tetris.html'] }, 400);
        push('plan', { steps: ['查看目录', '创建入口页', '创建俄罗斯方块', '创建贪吃蛇', '验证'] }, 300);
        push('usage', { prompt_tokens: 1204, completion_tokens: 863, total_tokens: 2067 }, 200);
        push('loop_warning', { message: '模型可能陷入重复循环：同一工具调用已重复 3 次', repeated_count: 3 }, 300);
        push('approval_request', { id: 'demo-approval-1', tool_name: 'run_command', args: '{"command":"rm -rf /tmp/demo"}', reason: 'This tool can modify your system. Approve to proceed.' }, 400);
        push('content', { text: '已完成三个页面文件：\n\n| 文件 | 大小 | 说明 |\n|------|------|------|\n| index.html | 2.6 KB | 游戏入口页 |\n| tetris.html | 10.4 KB | 俄罗斯方块 |\n| snake.html | 7.9 KB | 贪吃蛇 |\n\n代码已全部写入磁盘。' }, 300);
        push('done', {}, 200);

        setProcessing(true);
        var i = 0;
        (function step() {
            if (i >= queue.length) { setProcessing(false); return; }
            var item = queue[i++];
            var data = item.data || {};
            if (item.type === 'content') {
                // content must arrive WITHOUT a pending streaming bubble; the
                // earlier text_delta streams were finalized at tool_call.
                handleMessage({ type: 'content', text: data.text });
            } else {
                handleMessage({ type: item.type, ...data });
            }
            var d = (typeof item.delay === 'number' ? item.delay : 0) + 150;
            setTimeout(step, d);
        })();
    }

    function doSend(text, opts) {
        // "全列表块显示用例" — render every block locally (no WS, no real run).
        if (text && /全列表块显示用例/.test(text)) {
            var welcome = chatEl.querySelector('.welcome-msg');
            if (welcome) welcome.remove();
            // 单一组：用户气泡与 MyClaw 步骤汇入同一个 turn-flow。
            beginRunBlock(text.replace(/\s+/g, ' ').slice(0, 30));
            runFlowEl.appendChild(createUserMessage(text));
            scrollToBottom();
            // 草稿态触发 demo：退出居中新任务态，输入窗回底部（与普通发送一致），
            // 否则居中输入窗会遮挡 demo 渲染的步骤列表。
            if (draftNewTask) {
                draftNewTask = false;
                enterChatMode();
            }
            if (activeTaskId) paneTaskId = activeTaskId;
            runAllBlocksDemo();
            return;
        }
        var welcome = chatEl.querySelector('.welcome-msg');
        if (welcome) welcome.remove();

        // 本轮目标 → 图标行显示"开始执行「XX」任务…"（取提问前 30 字）。
        runGoal = String(text || '').replace(/\s+/g, ' ').slice(0, 30);
        beginRunBlock(runGoal);
        // 单一组：用户提问作为气泡内联进同一组流区（不另起 run 容器/分隔条）。
        runFlowEl.appendChild(createUserMessage(text));
        scrollToBottom();

        // newTask mode: this draft turn creates a fresh task on the server
        // (chat with no task_id auto-creates). Do NOT pin the pane to the old
        // active task — mark pendingNewChat so the incoming task_list (new
        // active task) locks the pane without replaying history over the
        // message we just rendered.
        if (opts && opts.newTask) {
            draftNewTask = false;
            pendingNewChat = true;
            paneTaskId = null;
            enterChatMode(); // 新建任务后退出新任务态，输入回底部
        } else if (activeTaskId) {
            // From here on the pane belongs to this task's live conversation,
            // so the next task_list must not replay its history over it.
            paneTaskId = activeTaskId;
        }
        setProcessing(true);
        var tid = (opts && opts.newTask) ? undefined : (activeTaskId || undefined);
        send({ type: 'chat', message: text, task_id: tid });
    }

    function sendMessage() {
        var text = inputEl.value.trim();
        if (!text || isProcessing) return;

        inputEl.value = '';
        autoResize();
        // 新任务态(草稿)走 #message-input(已上移)，需带 newTask 标记以新建任务。
        doSend(text, draftNewTask ? { newTask: true } : undefined);
    }

    function setProcessing(val) {
        isProcessing = val;
        sendBtn.disabled = false; // enabled in both states; behavior changes
        inputEl.disabled = false;
        // Swap the send icon for a stop icon while processing.
        var title = val ? '停止' : 'Send';
        sendBtn.title = title;
        sendBtn.classList.toggle('btn-stop', val);
        if (val) {
            sendBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg>';
        } else {
            sendBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 20 20" fill="none"><path d="M3 10l14-6-6 14-2-6-6-2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
        }
        // Sidebar running spinner: show on the executing task, hide when done.
        updateTaskRunningUI();
        // 顶栏"执行中"转圈动画：处理中显示，停止后消失。
        var execSp = document.getElementById('exec-spinner');
        if (execSp) execSp.classList.toggle('active', val);
        // 文字最底部"正在处理…"转圈：处理中显示，停止后移除。
        showBusyIndicator(val);
    }

    // === Index action ===


    // === Helpers ===

    function scrollToBottom() {
        // 任务执行中：把"正在处理…"转圈保持在文字最底部（后追加的内容之后）。
        relocateBusy();
        chatEl.scrollTop = chatEl.scrollHeight;
    }

    // 任务执行期间，文字最底部的"MyClaw 正在处理…"转圈指示。
    // 挂在 runTailEl（= runFlowEl，单一组流区末端），处理中显示、停止后移除。
    function showBusyIndicator(on) {
        if (on) {
            if (!busyEl) {
                busyEl = document.createElement('div');
                busyEl.className = 'busy-indicator';
                busyEl.innerHTML = '<span class="busy-spin"></span>' +
                    '<span class="busy-text">MyClaw 正在处理…</span>';
            }
            var host = runTailEl || runFlowEl || chatEl;
            if (busyEl.parentNode !== host) host.appendChild(busyEl);
            relocateBusy();
        } else if (busyEl && busyEl.parentNode) {
            busyEl.parentNode.removeChild(busyEl);
            busyEl = null;
        }
    }

    // 把转圈指示移到其所在容器的最后一个子节点（始终位于当前文字最底部）。
    function relocateBusy() {
        if (!busyEl || !busyEl.parentNode) return;
        var p = busyEl.parentNode;
        if (p.lastElementChild !== busyEl) p.appendChild(busyEl);
    }

    function autoResize() {
        // Growth cap: 240px max, no manual drag-resize (resize:none in CSS).
        inputEl.style.height = 'auto';
        inputEl.style.height = Math.min(inputEl.scrollHeight, 240) + 'px';
    }

    function escapeHtml(str) {
        if (!str) return '';
        return String(str)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
    }

    function formatJson(str) {
        try {
            return JSON.stringify(JSON.parse(str), null, 2);
        } catch(e) {
            return str;
        }
    }

    // Lightweight markdown renderer: code blocks, tables, images, links,
    // headings, lists, inline code, bold. Input is pre-escaped so nothing
    // user/model-controlled can inject raw HTML.
    function formatMessage(text) {
        if (!text) return '';
        var html = escapeHtml(text);
        // 1) Protect code blocks from inline/block processing below.
        var codeBlocks = [];
        html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
            codeBlocks.push('<pre><code>' + code.trim() + '</code></pre>');
            return '\u0000CODE' + (codeBlocks.length - 1) + '\u0000';
        });

        var lines = html.split('\n');
        var out = [];
        var i = 0;
        while (i < lines.length) {
            var line = lines[i];
            var trimmed = line.trim();
            if (trimmed === '') { i++; continue; }

            // Markdown table: header line + separator line + data rows.
            if (trimmed.indexOf('|') !== -1 && /^\|.*\|\s*$/.test(trimmed) &&
                i + 1 < lines.length && /^\|?[\s:|-]+\|?\s*$/.test(lines[i + 1].trim()) &&
                lines[i + 1].indexOf('-') !== -1) {
                var headerCells = trimmed.replace(/^\||\|$/g, '').split('|').map(function(c) { return c.trim(); });
                var rows = [];
                i += 2;
                while (i < lines.length && /^\|.*\|\s*$/.test(lines[i].trim())) {
                    var cells = lines[i].trim().replace(/^\||\|$/g, '').split('|').map(function(c) { return c.trim(); });
                    rows.push('<tr>' + cells.map(function(c) { return '<td>' + inlineMd(c) + '</td>'; }).join('') + '</tr>');
                    i++;
                }
                out.push('<table><thead><tr>' +
                    headerCells.map(function(c) { return '<th>' + inlineMd(c) + '</th>'; }).join('') +
                    '</tr></thead><tbody>' + rows.join('') + '</tbody></table>');
                continue;
            }

            // Headings
            var hm = trimmed.match(/^(#{1,4})\s+(.*)$/);
            if (hm) {
                var lvl = Math.min(hm[1].length, 4);
                out.push('<h' + lvl + '>' + inlineMd(hm[2]) + '</h' + lvl + '>');
                i++;
                continue;
            }

            // Unordered list
            if (/^[-*+]\s+/.test(trimmed)) {
                var items = [];
                while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
                    items.push('<li>' + inlineMd(lines[i].trim().replace(/^[-*+]\s+/, '')) + '</li>');
                    i++;
                }
                out.push('<ul>' + items.join('') + '</ul>');
                continue;
            }

            // Ordered list
            if (/^\d+\.\s+/.test(trimmed)) {
                var oitems = [];
                while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
                    oitems.push('<li>' + inlineMd(lines[i].trim().replace(/^\d+\.\s+/, '')) + '</li>');
                    i++;
                }
                out.push('<ol>' + oitems.join('') + '</ol>');
                continue;
            }

            out.push('<p>' + inlineMd(line) + '</p>');
            i++;
        }

        var result = out.join('\n');
        // Restore code blocks.
        result = result.replace(/\u0000CODE(\d+)\u0000/g, function(m, idx) {
            return codeBlocks[parseInt(idx, 10)];
        });
        return result;
    }

    // Inline markdown: images, links (http/https only), inline code, bold.
    function inlineMd(s) {
        var h = s;
        // Images: ![alt](http(s)://...)
        h = h.replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g, '<img src="$2" alt="$1" loading="lazy">');
        // Links: [text](http(s)://...) — target=_blank, noopener
        h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
        // Inline code
        h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
        // Bold
        h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
        return h;
    }

    function formatSize(bytes) {
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
        return (bytes / 1048576).toFixed(1) + ' MB';
    }

    function truncate(str, max) {
        if (str.length <= max) return str;
        return '...' + str.substring(str.length - max);
    }

    // Last path component for compact display — handles both / and \ separators
    // (POSIX and Windows drive paths). Full path stays available via title.
    function pathBase(p) {
        if (!p) return '';
        var parts = String(p).split(/[\\/]/).filter(Boolean);
        return parts.length ? parts[parts.length - 1] : p;
    }

    function getDocTitle(id) {
        var doc = documents.find(function(d) { return d.id === id; });
        return doc ? doc.title : id;
    }

    // === Config window ===

    // === Event listeners ===

    // Send / Stop: while processing the same button stops the agent run.
    sendBtn.addEventListener('click', function() {
        if (isProcessing) {
            send({ type: 'stop' });
            sendBtn.disabled = true; // one shot until the stopped/done event
        } else {
            sendMessage();
        }
    });

    inputEl.addEventListener('keydown', function(e) {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            sendMessage();
        }
    });

    inputEl.addEventListener('input', autoResize);

    // Skill palette: typing "/" opens it; any other input closes it.
    inputEl.addEventListener('input', function() {
        var v = inputEl.value.trimStart();
        if (v === '/') {
            openSkillPalette();
        } else if (!v.startsWith('/')) {
            closeSkillPalette();
        }
    });
    inputEl.addEventListener('keydown', function(e) {
        if (e.key === 'Escape') closeSkillPalette();
    });
    if (skillPalette) {
        skillPalette.addEventListener('mousedown', function(e) { e.preventDefault(); });
    }
    document.addEventListener('click', function(e) {
        if (skillPalette && !skillPalette.classList.contains('hidden') &&
            !skillPalette.contains(e.target) && e.target !== inputEl) {
            closeSkillPalette();
        }
    });

    // Provider / autonomy: icon buttons open a dropdown menu; clicking
    // outside closes it (reuses the task-menu document-level close handler,
    // which closes every `.task-item-menu.open`).
    if (providerBtn && providerWrap) {
        providerBtn.addEventListener('click', function(e) {
            e.stopPropagation();
            if (autonomyWrap) autonomyWrap.classList.remove('open');
            // Rebuild before showing so all options render and the current
            // (default) provider is checked.
            buildProviderMenu();
            providerWrap.classList.toggle('open');
        });
    }
    if (autonomyBtn && autonomyWrap) {
        autonomyBtn.addEventListener('click', function(e) {
            e.stopPropagation();
            if (providerWrap) providerWrap.classList.remove('open');
            updateAutonomyMenu();
            autonomyWrap.classList.toggle('open');
        });
    }
    // Autonomy menu items (fixed levels).
    if (autonomyMenu) {
        ['supervised', 'autonomous', 'yolo'].forEach(function(level) {
            var item = document.createElement('div');
            item.className = 'task-menu-item';
            item.setAttribute('data-level', level);
            item.textContent = level;
            item.addEventListener('click', function() {
                send({ type: 'set_autonomy', level: level });
                currentAutonomy = level;
                if (autonomyBtn) autonomyBtn.title = 'Autonomy: ' + level;
                updateAutonomyMenu();
                if (autonomyWrap) autonomyWrap.classList.remove('open');
                showStatusMessage('Autonomy: ' + level);
            });
            autonomyMenu.appendChild(item);
        });
        updateAutonomyMenu();
    }
    // Ensure the document-level menu closer is bound even when the task list
    // has never been rendered (renderTaskList normally installs it).
    if (!window.__taskMenuCloseBound) {
        window.__taskMenuCloseBound = true;
        document.addEventListener('click', function() {
            document.querySelectorAll('.task-item-menu.open').forEach(function(m) {
                m.classList.remove('open');
            });
        });
    }

    // Voice input button (Web Speech API where available).
    // Click toggles recording: active shows a gray circle; clicking again
    // (or recognition end/error) cancels and restores the default look.
    var voiceBtn = document.getElementById('voice-btn');
    if (voiceBtn) {
        voiceBtn.addEventListener('click', function() {
            var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
            if (!SR) {
                showStatusMessage('当前浏览器不支持语音输入');
                return;
            }
            if (voiceBtn.classList.contains('recording')) {
                voiceBtn.classList.remove('recording');
                if (window.__myclawRec) {
                    try { window.__myclawRec.stop(); } catch (e) {}
                    window.__myclawRec = null;
                }
                showStatusMessage('已取消聆听');
                return;
            }
            var rec = new SR();
            window.__myclawRec = rec;
            rec.lang = 'zh-CN';
            rec.interimResults = false;
            rec.onresult = function(e) {
                var text = e.results[0][0].transcript;
                var inputEl = document.getElementById('message-input');
                if (inputEl) inputEl.value = text;
            };
            rec.onerror = function() {
                voiceBtn.classList.remove('recording');
                showStatusMessage('语音识别失败，请重试');
            };
            rec.onend = function() {
                voiceBtn.classList.remove('recording');
                window.__myclawRec = null;
            };
            rec.start();
            voiceBtn.classList.add('recording');
            showStatusMessage('正在聆听…（点击可停止）');
        });
    }

    // --- Task controls ---
    // Task switching is now driven by clicking items in the sidebar list
    // (renderTaskList); the old <select> change handler is gone.
    taskNewBtn.addEventListener('click', function() {
        // Draft mode: show the fresh-task hero WITHOUT creating a task yet.
        // A task is only created when the user actually sends a message
        // (backend auto-creates on chat with no task_id) — no more empty
        // tasks piling up from just clicking the button.
        draftNewTask = true;
        enterBlankMode();
        // Mobile: the sidebar covers the pane (off-canvas) — auto-collapse it
        // so the fresh-task hero + input box are immediately visible.
        closeSidebar();
        var taskNameEl = document.getElementById('topbar-task-name');
        if (taskNameEl) taskNameEl.textContent = '新对话';
    });

    // === Server directory picker (workspace browse) ===
    var dirModal = document.getElementById('dir-modal');
    var dirList = document.getElementById('dir-list');
    var dirPath = document.getElementById('dir-path');
    var currentDir = '';
    // Last folder name of a path, e.g. "C:\Projects\myclaw" -> "myclaw".
    function folderName(p) {
        if (!p) return '';
        var s = String(p).replace(/[\\/]+$/, '');
        var idx = Math.max(s.lastIndexOf('\\'), s.lastIndexOf('/'));
        return idx < 0 ? s : s.slice(idx + 1);
    }
    function openDirPicker() {
        if (!dirModal) return;
        dirModal.classList.remove('hidden');
        var cur = wsBrowseBtn ? (wsBrowseBtn.title.replace(/^工作区：/, '') || '') : '';
        dirPath.value = cur;
        currentDir = '';
        send({ type: 'list_dirs', path: undefined });
    }
    function closeDirPicker() {
        if (dirModal) dirModal.classList.add('hidden');
    }
    function joinDir(base, name) {
        if (!base) return name;
        // Use the SERVER's native separator (browser OS may differ, e.g. a
        // Windows browser talking to a macOS/Linux server).
        var sep = serverPlatform === 'windows' ? '\\' : '/';
        return base.replace(/[\\/]+$/, '') + sep + name;
    }
    function parentDir(p) {
        if (!p) return '';
        var norm = p.replace(/[\\/]+$/, '');
        var idx = Math.max(norm.lastIndexOf('\\'), norm.lastIndexOf('/'));
        if (idx < 0) return '';
        var parent = norm.slice(0, idx);
        if (/^[A-Za-z]:$/.test(parent)) return parent + (serverPlatform === 'windows' ? '\\' : '/');
        return parent;
    }
    // True for an absolute root path (e.g. "C:\", "D:/", "/", "\\server").
    // Top-level drive entries returned by the server are absolute, so they
    // must REPLACE the current path rather than be joined onto it.
    function isAbsolutePath(p) {
        if (!p) return false;
        if (/^[A-Za-z]:[\\/]/.test(p)) return true;
        return p[0] === '/' || p[0] === '\\';
    }
    function renderDirList(dirs) {
        if (!dirList) return;
        dirList.innerHTML = '';
        if (currentDir) dirPath.value = currentDir;
        if (!dirs || dirs.length === 0) {
            var empty = document.createElement('div');
            empty.className = 'empty-state';
            empty.textContent = currentDir ? '（无子目录）' : '（未检测到磁盘）';
            dirList.appendChild(empty);
            return;
        }
        dirs.forEach(function(name) {
            var item = document.createElement('div');
            item.className = 'dir-item';
            item.textContent = '📁 ' + name;
            item.addEventListener('click', function() {
                if (isAbsolutePath(name)) {
                    // Top-level drive root (e.g. "C:\"): replace the path
                    // rather than join — joining onto a stale box value would
                    // produce a malformed path like "D:\C:\".
                    currentDir = name;
                } else {
                    currentDir = joinDir(currentDir, name);
                }
                send({ type: 'list_dirs', path: currentDir || undefined });
            });
            dirList.appendChild(item);
        });
    }
    var browseBtn = document.getElementById('workspace-browse');
    if (browseBtn) browseBtn.addEventListener('click', openDirPicker);
    if (document.getElementById('close-dir')) {
        document.getElementById('close-dir').addEventListener('click', closeDirPicker);
        document.getElementById('dir-backdrop').addEventListener('click', closeDirPicker);
    }
    if (document.getElementById('dir-up')) {
        document.getElementById('dir-up').addEventListener('click', function() {
            // Already at the top-level drive list: nothing sits above it.
            if (!currentDir) return;
            currentDir = parentDir(currentDir);
            send({ type: 'list_dirs', path: currentDir || undefined });
        });
        document.getElementById('dir-go').addEventListener('click', function() {
            currentDir = dirPath.value.trim();
            send({ type: 'list_dirs', path: currentDir || undefined });
        });
        document.getElementById('dir-ok').addEventListener('click', function() {
            var v = currentDir || dirPath.value.trim();
            if (wsBrowseBtn) {
                wsBrowseBtn.title = v ? '工作区：' + v : '选择工作区目录';
            }
            if (v) send({ type: 'task_set_workspace', workspace: v });
            closeDirPicker();
        });
    }
    // Task deletion now lives on each row in the sidebar list (× button),
    // so there is no toolbar "删除任务" button anymore.

    clearBtn.addEventListener('click', function() {
        var welcome = chatEl.querySelector('.welcome-msg');
        if (welcome) welcome.remove();
        var children = Array.from(chatEl.children);
        children.forEach(function(child) {
            child.remove();
        });
        var welcomeDiv = document.createElement('div');
        welcomeDiv.className = 'welcome-msg';
        welcomeDiv.innerHTML =
            '<div class="welcome-icon">' +
                '<svg width="48" height="48" viewBox="0 0 48 48" fill="none">' +
                    '<rect x="6" y="10" width="36" height="28" rx="4" stroke="var(--accent)" stroke-width="2"/>' +
                    '<path d="M14 22h20M14 28h14" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/>' +
                '</svg>' +
            '</div>' +
            '<h3>Ready to help</h3>' +
            '<p>Ask me anything, or index some documents to build a knowledge base.</p>';
        chatEl.appendChild(welcomeDiv);
        send({ type: 'clear_context', task_id: activeTaskId || undefined });
    });

    // === kshare knowledge-base config ===
    function setKshareStatus(msg) {
        if (kshareStatusEl) {
            kshareStatusEl.textContent = msg || '';
        }
    }

    if (kshareSaveBtn) {
        kshareSaveBtn.addEventListener('click', function() {
            var base = (kshareBaseApi.value || '').trim();
            setKshareStatus('保存中…');
            send({ type: 'set_kshare', base_api: base });
        });
    }

    // === Init ===
    bootstrap();

    // === Sidebar: collapse + view tabs (DeepSeek-style) ===
    const sidebarEl = document.getElementById('sidebar');
    const sidebarToggle = document.getElementById('sidebar-toggle');
    const sidebarCollapseBtn = document.getElementById('sidebar-collapse');
    const sidebarBackdrop = document.getElementById('sidebar-backdrop');
    function closeSidebar() {
        if (sidebarEl) sidebarEl.classList.remove('open');
        if (sidebarBackdrop) sidebarBackdrop.classList.add('hidden');
    }
    function toggleSidebar() {
        // Desktop: collapse the sidebar to a thin rail; mobile: slide in.
        if (window.matchMedia('(min-width: 769px)').matches) {
            sidebarEl.classList.toggle('collapsed');
        } else {
            const open = sidebarEl.classList.toggle('open');
            if (sidebarBackdrop) sidebarBackdrop.classList.toggle('hidden', !open);
        }
    }
    // Top-bar hamburger (mobile) and sidebar header collapse button (desktop).
    [sidebarToggle, sidebarCollapseBtn].forEach(function(btn) {
        if (btn && sidebarEl) btn.addEventListener('click', toggleSidebar);
    });
    // Rail expand button (visible when sidebar is collapsed).
    const sidebarRailOpen = document.getElementById('sidebar-rail-open');
    if (sidebarRailOpen && sidebarEl) {
        sidebarRailOpen.addEventListener('click', function() {
            sidebarEl.classList.remove('collapsed');
        });
    }
    if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', closeSidebar);

    // Sidebar view tabs: 任务 / 知识库.
    document.querySelectorAll('.sidebar-tab').forEach(function(tab) {
        tab.addEventListener('click', function() {
            document.querySelectorAll('.sidebar-tab').forEach(function(t) {
                t.classList.remove('active');
            });
            tab.classList.add('active');
            var view = tab.getAttribute('data-tab');
            var kb = document.getElementById('kb-view');
            var tasksV = document.getElementById('tasks-view');
            if (kb) kb.classList.toggle('hidden', view !== 'kb');
            if (tasksV) tasksV.classList.toggle('hidden', view !== 'tasks');
            // Refresh the task list when switching to it.
            if (view === 'tasks') send({ type: 'task_list' });
        });
    });

    // Nav row 资料库 handled in the kb-explorer block below (opens the main
    // KB browser view). The old sidebar kb-view is kept only for config.

    // --- Top-bar actions: search / notify / copy (WorkBuddy-style) ---

    // Simple toast used by all three actions.
    function toast(msg) {
        var t = document.getElementById('topbar-toast');
        if (!t) {
            t = document.createElement('div');
            t.id = 'topbar-toast';
            t.className = 'topbar-toast';
            document.body.appendChild(t);
        }
        t.textContent = msg;
        t.classList.add('show');
        clearTimeout(t._timer);
        t._timer = setTimeout(function() { t.classList.remove('show'); }, 2200);
    }

    // 1) Search: prompt for a keyword, highlight matches inside chat bubbles,
    //    scroll to the first one. Enter again / Esc clears the highlight.
    //    安全实现：只在文本节点层面拆分并包 <mark>（TreeWalker），
    //    绝不把关键词拼进 innerHTML —— 旧实现经属性引号可注入事件处理器（XSS）。
    var searchActive = false;
    var searchBtn = document.getElementById('topbar-search');
    function highlightTextMatches(container, kw) {
        var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
        var textNodes = [];
        while (walker.nextNode()) textNodes.push(walker.currentNode);
        textNodes.forEach(function(node) {
            if (node.nodeValue.indexOf(kw) === -1) return;
            var frag = document.createDocumentFragment();
            var rest = node.nodeValue, pos = 0, idx;
            while ((idx = rest.indexOf(kw, pos)) !== -1) {
                if (idx > pos) frag.appendChild(document.createTextNode(rest.slice(pos, idx)));
                var mark = document.createElement('mark');
                mark.className = 'search-hl';
                mark.textContent = kw;
                frag.appendChild(mark);
                pos = idx + kw.length;
            }
            if (pos < rest.length) frag.appendChild(document.createTextNode(rest.slice(pos)));
            node.parentNode.replaceChild(frag, node);
        });
    }
    if (searchBtn) {
        searchBtn.addEventListener('click', function() {
            var kw = window.prompt('搜索当前对话（输入关键词，回车高亮）：', '');
            if (kw === null) return;
            kw = kw.trim();
            if (!kw) { clearSearchHighlights(); return; }
            clearSearchHighlights();
            var nodes = chatEl.querySelectorAll('.msg-bubble');
            var found = 0, first = null;
            nodes.forEach(function(b) {
                var txt = b.textContent || '';
                if (txt.indexOf(kw) !== -1) {
                    highlightTextMatches(b, kw);
                    if (!first) first = b;
                    found++;
                }
            });
            searchActive = found > 0;
            if (first) first.scrollIntoView({ behavior: 'smooth', block: 'center' });
            toast(found ? ('找到 ' + found + ' 处匹配') : ('未找到「' + kw + '」'));
        });
    }
    function clearSearchHighlights() {
        chatEl.querySelectorAll('.search-hl').forEach(function(m) {
            var parent = m.parentNode;
            parent.replaceChild(document.createTextNode(m.textContent), m);
            parent.normalize();
        });
        searchActive = false;
    }
    document.addEventListener('keydown', function(e) {
        if (searchActive && (e.key === 'Escape')) clearSearchHighlights();
    });

    // 2) Notify: show a small "no new notifications" popup (placeholder for a
    //    real notification center; the run-block already surfaces task events).
    var notifyBtn = document.getElementById('topbar-notify');
    if (notifyBtn) {
        notifyBtn.addEventListener('click', function() {
            toast('暂无新通知');
        });
    }

    // 2.5) Light/dark theme toggle: persist in localStorage, apply on load.
    //      html[data-theme="light"] re-maps the CSS variables (style.css).
    function applyTheme(theme) {
        document.documentElement.setAttribute('data-theme', theme);
        try { localStorage.setItem('myclaw-theme', theme); } catch (e) {}
        var icon = document.getElementById('theme-icon');
        if (icon) {
            // Sun for "switch to light is active" affordance is confusing; keep
            // a simple filled/hollow hint: light theme → hollow moon-less sun
            // (current = light), dark theme → filled disc ring.
            icon.setAttribute('data-theme', theme);
        }
    }
    function initTheme() {
        var saved = 'dark';
        try { saved = localStorage.getItem('myclaw-theme') || 'dark'; } catch (e) {}
        // Follow OS preference only on the very first visit (no saved value).
        if (!localStorage.getItem('myclaw-theme') && window.matchMedia) {
            saved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
        }
        applyTheme(saved);
    }
    initTheme();
    var themeBtn = document.getElementById('topbar-theme');
    if (themeBtn) {
        themeBtn.addEventListener('click', function() {
            var cur = document.documentElement.getAttribute('data-theme') || 'dark';
            applyTheme(cur === 'dark' ? 'light' : 'dark');
        });
    }

    // 3) Copy conversation: export the current pane's chat text (messages +
    //    thinking/tool lines stripped to plain text) to the clipboard.
    var copyBtn = document.getElementById('topbar-copy');
    if (copyBtn) {
        copyBtn.addEventListener('click', function() {
            var parts = [];
            chatEl.querySelectorAll('.msg').forEach(function(m) {
                var role = m.classList.contains('user') ? '用户' : '助手';
                var b = m.querySelector('.msg-bubble');
                if (b) parts.push(role + ': ' + (b.textContent || '').trim());
            });
            // Include tool/thinking lines from the run-block too.
            var rb = document.getElementById('run-block');
            if (rb) {
                var lines = [];
                rb.querySelectorAll('.run-tool-line, .run-reasoning-text').forEach(function(el) {
                    var t = (el.textContent || '').trim();
                    if (t) lines.push(t);
                });
                if (lines.length) parts.push('—— 执行过程 ——\n' + lines.join('\n'));
            }
            var text = parts.join('\n\n') || '（空对话）';
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(text).then(function() {
                    toast('对话已复制到剪贴板');
                }).catch(function() { toast('复制失败'); });
            } else {
                var ta = document.createElement('textarea');
                ta.value = text;
                document.body.appendChild(ta);
                ta.select();
                try { document.execCommand('copy'); toast('对话已复制到剪贴板'); }
                catch (e) { toast('复制失败'); }
                document.body.removeChild(ta);
            }
        });
    }

    // Notify-bell on the user card (bottom of sidebar) — same toast.
    // === 设置弹窗：选择头像 ===
    function renderAvatarPicker() {
        var picker = document.getElementById('avatar-picker');
        if (!picker) return;
        var cur = getAvatar();
        picker.querySelectorAll('.avatar-opt[data-avatar]').forEach(function(o) {
            var v = o.getAttribute('data-avatar');
            o.classList.toggle('selected', (v === '__initials__' && !cur) || v === cur);
        });
    }
    function refreshRenderedUserAvatars() {
        var emo = getAvatar();
        document.querySelectorAll('.msg.user .msg-avatar').forEach(function(av) {
            if (emo) { av.textContent = emo; av.style.background = 'transparent'; av.style.fontSize = '17px'; }
            else {
                var nm = 'OP';
                try { var u = JSON.parse(localStorage.getItem('mclaw_user') || '{}'); nm = u.name || u.id || 'OP'; } catch (e) {}
                av.textContent = (nm.match(/[A-Za-z0-9]/g) || []).slice(0, 2).join('').toUpperCase() || 'OP';
                av.style.background = ''; av.style.fontSize = '15px';
            }
        });
    }
    function openSettings() {
        // 注入预置 emoji 头像（首次）。
        var picker = document.getElementById('avatar-picker');
        if (picker && picker.querySelectorAll('.avatar-opt[data-avatar]').length <= 1) {
            PRESET_AVATARS.forEach(function(em) {
                var b = document.createElement('button');
                b.className = 'avatar-opt';
                b.setAttribute('data-avatar', em);
                var s = document.createElement('span');
                s.className = 'msg-avatar avatar-preview';
                s.textContent = em;
                s.style.background = 'transparent';
                b.appendChild(s);
                b.addEventListener('click', function() { setAvatar(em); renderAvatarPicker(); refreshRenderedUserAvatars(); });
                picker.appendChild(b);
            });
            // 首字模式
            var initBtn = picker.querySelector('.avatar-opt[data-avatar="__initials__"]');
            if (initBtn) initBtn.addEventListener('click', function() { setAvatar(''); renderAvatarPicker(); refreshRenderedUserAvatars(); });
        }
        renderAvatarPicker();
        var m = document.getElementById('settings-modal');
        if (m) m.classList.remove('hidden');
    }
    function closeSettings() {
        var m = document.getElementById('settings-modal');
        if (m) m.classList.add('hidden');
    }
    var settingsBtn = document.getElementById('user-settings');
    if (settingsBtn) settingsBtn.addEventListener('click', openSettings);
    var settingsClose = document.getElementById('settings-close');
    if (settingsClose) settingsClose.addEventListener('click', closeSettings);
    var settingsDone = document.getElementById('settings-done');
    if (settingsDone) settingsDone.addEventListener('click', closeSettings);
    var settingsBackdrop = document.getElementById('settings-backdrop');
    if (settingsBackdrop) settingsBackdrop.addEventListener('click', closeSettings);

    // Notify-bell on the user card (bottom of sidebar).
    var userNotifyBtn = document.getElementById('user-notify');
    if (userNotifyBtn) {
        userNotifyBtn.addEventListener('click', function() { toast('暂无新通知'); });
    }

    // 资料库浏览器 (kshare explorer)
    // 主区视图：侧栏「资料库」→ 隐藏 chat/输入框，展示分类树/文档列表/详情。
    var kbExplorer = document.getElementById('kb-explorer');
    var kbFilter = { cat: null, dom: null, q: null, offset: 0 };
    var kbPageSize = 40;

    function kbFetch(path) {
        var url = '/api/kb/' + path;
        if (sessionToken) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(sessionToken);
        return fetch(url).then(function(r) {
            if (!r.ok) return r.text().then(function(t) { throw new Error(t || ('HTTP ' + r.status)); });
            return r.json();
        });
    }
    function kbEl(id) { return document.getElementById(id); }

    function kbShowConfig(why) {
        var c = kbEl('kb-config');
        if (c) c.classList.remove('hidden');
        var m = kbEl('kb-docs');
        if (m) m.innerHTML = '<div class="kb-empty">' + escapeHtml(why || '') + '</div>';
    }

    function showKbExplorer() {
        if (!kbExplorer) return;
        draftNewTask = false;
        // 隐藏聊天区与底部输入，进入浏览视图
        var chat = document.getElementById('chat');
        var inputArea = document.getElementById('input-area');
        if (chat) chat.classList.add('hidden');
        if (inputArea) inputArea.classList.add('hidden');
        kbExplorer.classList.remove('hidden');
        kbFilter = { cat: null, dom: null, q: null, offset: 0 };
        var qi = kbEl('kb-q'); if (qi) qi.value = '';
        loadKbStats();
        loadKbTree();
        loadKbDomains();
        loadKbDocs();
    }
    function hideKbExplorer() {
        if (!kbExplorer) return;
        kbExplorer.classList.add('hidden');
        var chat = document.getElementById('chat');
        var inputArea = document.getElementById('input-area');
        if (chat) chat.classList.remove('hidden');
        if (inputArea) inputArea.classList.remove('hidden');
    }
    var kbBackBtn = kbEl('kb-back');
    if (kbBackBtn) kbBackBtn.addEventListener('click', hideKbExplorer);

    function loadKbStats() {
        kbFetch('stats').then(function(d) {
            var el = kbEl('kb-stats');
            if (!el) return;
            var t = d || {};
            el.textContent = '📊 ' + (t.documents ?? t.docs ?? '-') + ' 文档 · ' +
                (t.categories ?? '-') + ' 分类';
        }).catch(function(e) { /* stats optional */ });
    }
    function loadKbTree() {
        var box = kbEl('kb-tree');
        if (!box) return;
        box.innerHTML = '<div class="kb-loading">加载分类…</div>';
        kbFetch('tree').then(function(data) {
            // 契约：顶层可能是 {tree:[...]} 或数组；节点 {id,name,level,doc_count,children}
            var roots = Array.isArray(data) ? data : (data.tree || data.categories || []);
            box.innerHTML = '';
            if (!roots.length) { box.innerHTML = '<div class="kb-loading">（无分类）</div>'; return; }
            var all = document.createElement('button');
            all.className = 'kb-cat active';
            all.innerHTML = '<span class="kb-cat-name">全部文档</span>';
            all.addEventListener('click', function() {
                kbFilter.cat = null;
                setCatActive(box, all);
                loadKbDocs();
            });
            box.appendChild(all);
            roots.forEach(function(c) { appendCat(box, c, 0); });
        }).catch(function(e) {
            box.innerHTML = '';
            if (/未配置|kshare/.test(String(e.message || e))) kbShowConfig(String(e.message || e));
            else box.innerHTML = '<div class="kb-loading">分类加载失败</div>';
        });
    }
    function appendCat(container, node, depth) {
        var b = document.createElement('button');
        b.className = 'kb-cat depth-' + Math.min(depth, 2);
        b.innerHTML = '<span class="kb-cat-name">' + escapeHtml(node.name || node.title || '?') +
            '</span>' + (node.doc_count ? '<span class="kb-cat-count">' + node.doc_count + '</span>' : '');
        b.addEventListener('click', function() {
            kbFilter.cat = node.id;
            setCatActive(container.parentNode, b);
            loadKbDocs();
        });
        container.appendChild(b);
        (node.children || []).forEach(function(ch) { appendCat(container, ch, depth + 1); });
    }
    function setCatActive(root, activeBtn) {
        if (!root) return;
        root.querySelectorAll('.kb-cat').forEach(function(c) { c.classList.remove('active'); });
        if (activeBtn) activeBtn.classList.add('active');
    }
    function loadKbDomains() {
        var box = kbEl('kb-domains');
        if (!box) return;
        kbFetch('domains').then(function(data) {
            var list = Array.isArray(data) ? data : (data.domains || []);
            box.innerHTML = '';
            if (!list.length) { box.innerHTML = '<div class="kb-loading">（无领域）</div>'; return; }
            list.forEach(function(dm) {
                var b = document.createElement('button');
                b.className = 'kb-dom';
                b.innerHTML = '<span class="kb-cat-name">' + escapeHtml(dm.name || dm.id || '?') +
                    '</span><span class="kb-cat-count">' + (dm.doc_count || dm.count || '') + '</span>';
                b.addEventListener('click', function() {
                    kbFilter.dom = kbFilter.dom === dm.id ? null : dm.id;
                    box.querySelectorAll('.kb-dom').forEach(function(x) { x.classList.remove('active'); });
                    b.classList.toggle('active', !!kbFilter.dom);
                    loadKbDocs();
                });
                box.appendChild(b);
            });
        }).catch(function() {});
    }
    function loadKbDocs() {
        var box = kbEl('kb-docs');
        if (!box) return;
        box.innerHTML = '<div class="kb-loading">加载文档…</div>';
        var p = 'docs?limit=' + kbPageSize + '&offset=' + kbFilter.offset;
        if (kbFilter.cat) p += '&category_id=' + encodeURIComponent(kbFilter.cat);
        if (kbFilter.dom) p += '&domain_id=' + encodeURIComponent(kbFilter.dom);
        if (kbFilter.q) p += '&q=' + encodeURIComponent(kbFilter.q);
        kbFetch(p).then(function(d) {
            var docs = (d && d.docs) || [];
            var total = (d && d.total) || docs.length;
            box.innerHTML = '';
            if (!docs.length) {
                box.innerHTML = '<div class="kb-empty">' + (kbFilter.q ? '未找到匹配文档' : '该分类下暂无文档') + '</div>';
                return;
            }
            docs.forEach(function(doc) { box.appendChild(makeDocItem(doc)); });
            var totalPages = Math.ceil(total / kbPageSize);
            var page = Math.floor(kbFilter.offset / kbPageSize) + 1;
            var pager = document.createElement('div');
            pager.className = 'kb-pager';
            var prev = document.createElement('button');
            prev.textContent = '上一页'; prev.disabled = kbFilter.offset <= 0;
            prev.addEventListener('click', function() { kbFilter.offset = Math.max(0, kbFilter.offset - kbPageSize); loadKbDocs(); });
            var info = document.createElement('span');
            info.textContent = page + ' / ' + Math.max(totalPages, 1) + '（共 ' + total + ' 篇）';
            var next = document.createElement('button');
            next.textContent = '下一页'; next.disabled = kbFilter.offset + kbPageSize >= total;
            next.addEventListener('click', function() { kbFilter.offset += kbPageSize; loadKbDocs(); });
            pager.appendChild(prev); pager.appendChild(info); pager.appendChild(next);
            box.appendChild(pager);
        }).catch(function(e) {
            box.innerHTML = '';
            if (/未配置|kshare/.test(String(e.message || e))) kbShowConfig(String(e.message || e));
            else box.innerHTML = '<div class="kb-empty">加载失败：' + escapeHtml(String(e.message || e)) + '</div>';
        });
    }
    function makeDocItem(doc) {
        var el = document.createElement('div');
        el.className = 'kb-doc-item';
        var body = document.createElement('div');
        body.className = 'kb-doc-body';
        var title = document.createElement('div');
        title.className = 'kb-doc-title';
        title.textContent = doc.title || doc.path || doc.id;
        var meta = document.createElement('div');
        meta.className = 'kb-doc-meta';
        var catPath = (doc.category_paths && doc.category_paths[0]) ? doc.category_paths[0].join('/') : (doc.category_path || '未分类');
        meta.textContent = catPath + (doc.domains && doc.domains.length ? ' · ' + doc.domains.join(', ') : '') +
            (doc.chunk_count ? ' · ' + doc.chunk_count + ' 段' : '');
        body.appendChild(title); body.appendChild(meta);
        el.appendChild(body);
        el.addEventListener('click', function() { openKbDoc(doc.id || doc.doc_id, el); });
        return el;
    }
    function openKbDoc(id, itemEl) {
        // 详情抽屉
        var backdrop = document.createElement('div');
        backdrop.className = 'kb-drawer-backdrop';
        var drawer = document.createElement('div');
        drawer.className = 'kb-drawer';
        drawer.innerHTML = '<div class="kb-drawer-head">' +
            '<button class="icon-btn kb-drawer-x" title="关闭">&times;</button>' +
            '<div class="kb-drawer-title">加载中…</div></div>' +
            '<div class="kb-drawer-body"><div class="kb-loading">加载详情…</div></div>' +
            '<div class="kb-drawer-foot"><button class="btn-primary kb-use">加入当前任务</button></div>';
        document.body.appendChild(backdrop);
        document.body.appendChild(drawer);
        var close = function() { if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); if (drawer.parentNode) drawer.parentNode.removeChild(drawer); };
        backdrop.addEventListener('click', close);
        drawer.querySelector('.kb-drawer-x').addEventListener('click', close);
        drawer.querySelector('.kb-use').addEventListener('click', function() {
            var title = drawer.querySelector('.kb-drawer-title').textContent;
            useDocInTask(title, docDataHint);
            close();
        });
        var docDataHint = '';
        kbFetch('doc?id=' + encodeURIComponent(id)).then(function(d) {
            var doc = (d && d.document) || d || {};
            docDataHint = doc.title || doc.path || id;
            drawer.querySelector('.kb-drawer-title').textContent = doc.title || doc.path || id;
            var body = drawer.querySelector('.kb-drawer-body');
            var html = '';
            var catPaths = doc.category_paths || [];
            if (catPaths.length) {
                html += '<div class="kb-doc-sec"><div class="kb-sec-label">分类</div>' +
                    catPaths.map(function(p) { return '<span class="kb-cat-chip">' + escapeHtml(p.join(' / ')) + '</span>'; }).join('') + '</div>';
            }
            var sum = doc.summary || (d && d.summaries && d.summaries[0] && (d.summaries[0].summary || d.summaries[0].text)) || '';
            if (sum) html += '<div class="kb-doc-sec"><div class="kb-sec-label">摘要</div><div>' + escapeHtml(sum) + '</div></div>';
            var ents = (d && d.entities) || doc.entities || [];
            if (ents && ents.length) {
                html += '<div class="kb-doc-sec"><div class="kb-sec-label">实体</div><div>' +
                    ents.map(function(en) { return '<span class="kb-cat-chip">' + escapeHtml(en.name || '') + '</span>'; }).join('') + '</div></div>';
            }
            var content = doc.content || '';
            if (content) {
                html += '<div class="kb-doc-sec"><div class="kb-sec-label">正文预览</div><div class="kb-drawer-content">' + escapeHtml(content.slice(0, 3000)) + '</div></div>';
            }
            var rels = (d && d.relations) || doc.relations || [];
            if (rels && rels.length) {
                html += '<div class="kb-doc-sec"><div class="kb-sec-label">关联关系</div><div>' +
                    rels.slice(0, 12).map(function(r) {
                        return '<div>· ' + escapeHtml((r.src_name || '') + ' —' + (r.rel_type || '关联') + '→ ' + (r.dst_name || '')) + '</div>';
                    }).join('') + '</div></div>';
            }
            body.innerHTML = html || '<div class="kb-empty">（无更多内容）</div>';
        }).catch(function(e) {
            drawer.querySelector('.kb-drawer-body').innerHTML = '<div class="kb-empty">加载失败：' + escapeHtml(String(e.message || e)) + '</div>';
        });
    }
    function useDocInTask(titleHint) {
        // 加入当前任务：切回聊天并把「基于该文档提问」的引导填入输入框，
        // agent 每轮会经 kshare 预检索命中该文档。
        hideKbExplorer();
        var t = (titleHint || '').trim();
        if (inputEl) {
            inputEl.value = '请基于知识库文档' + (t ? '《' + t + '》' : '') + '的内容帮我：';
            inputEl.focus();
            autoResize && autoResize();
            scrollToBottom();
        }
    }

    // 配置条
    var kbConfigSave = kbEl('kb-config-save');
    if (kbConfigSave) {
        kbConfigSave.addEventListener('click', function() {
            var v = (kbEl('kb-config-input').value || '').trim();
            send({ type: 'set_kshare', base_api: v });
            kbEl('kb-config').classList.add('hidden');
            setTimeout(function() { loadKbTree(); loadKbDocs(); loadKbDomains(); loadKbStats(); }, 600);
        });
    }
    var kbConfigCancel = kbEl('kb-config-cancel');
    if (kbConfigCancel) kbConfigCancel.addEventListener('click', function() { kbEl('kb-config').classList.add('hidden'); });
    // 搜索回车
    var kbQ = kbEl('kb-q');
    if (kbQ) {
        kbQ.addEventListener('keydown', function(e) {
            if (e.key === 'Enter') {
                kbFilter.q = kbQ.value.trim() || null;
                kbFilter.offset = 0;
                loadKbDocs();
            }
        });
    }
    // 侧栏「资料库」导航行：改为在主区打开浏览器
    var navKb2 = document.getElementById('nav-kb');
    if (navKb2) {
        navKb2.addEventListener('click', function() {
            showKbExplorer();
            closeSidebar();
        });
    }

})();
