(function () { "use strict"; const $ = (id) => document.getElementById(id); const embedBridge = window.AetherVscodexEmbed; const embeddedInAether = Boolean(embedBridge?.active); const i18n = window.VscodexI18n; const t = (value) => i18n?.t ? i18n.t(value) : String(value ?? ""); const uiLocale = () => i18n?.locale?.() || "zh-CN"; // Translate renderer-owned labels while leaving values supplied by the host // (conversation titles, file paths, commands, and message text) untouched. const uiText = (zh, en) => uiLocale() === "en-US" ? en : zh; const uiWithRaw = (zhPrefix, enPrefix, raw, zhSuffix = "", enSuffix = "") => `${uiText(zhPrefix, enPrefix)}${String(raw ?? "")}${uiText(zhSuffix, enSuffix)}`; const appStatusLabel = (value) => { const normalized = String(value || "").trim().toLowerCase(); const labels = { ready: "已连接", online: "已连接", offline: "VS Code 主机未连接", waiting_for_host: "等待 VS Code 主机连接", app_not_ready: "等待 VS Code 主机连接", starting: "正在连接", connecting: "正在连接", stopped: "已停止", }; return t(labels[normalized] || value || "未知"); }; const state = { ws: null, token: "", role: null, appReady: false, lastSeq: 0, // A control snapshot is authoritative for every event up to this // sequence. Replayed notifications from the subscribe handshake must not // resurrect an already-finished turn or duplicate its transcript. lastSnapshotSeq: 0, awaitingSnapshot: false, threadId: "", turnId: "", attachMode: false, authRequired: null, outputSynced: false, structuredMessages: [], requests: new Map(), responding: new Set(), commandResults: new Set(), reconnectTimer: null, embedTicket: "", embedWsUrl: "", embedDeviceId: "", embedTicketRequested: false, embedStopped: false, activeAssistantBody: null, activeAssistantStream: null, activeAssistantText: "", pendingUserText: "", retiredTurnIds: new Set(), syncedThreadId: null, snapshotNoticeShown: false, // Live work items are rendered as updateable transcript entries. The // adapter may emit item lifecycle notifications or only output chunks; // keeping a small client-side index lets both forms converge on one row. activities: new Map(), commandDisclosure: new Map(), activitySequence: 0, activityTimer: null, activeAssistantActivityKey: null, turnStartedAt: null, // The official worked-for row measures from the first work item until // the final assistant response starts. Keep this separate from the // overall turn clock because the latter also includes queue/approval time. turnWorkStartedAt: null, finalAssistantStartedAt: null, turnStatus: "idle", lastTurnDurationMs: null, lastWorkedDurationMs: null, workedDurationMs: null, currentActivity: "idle", currentActivityStartedAt: null, currentActivityDurationMs: null, currentActivityTurnId: "", currentModel: "", // Empty means the host has not reported an effort yet; null is an // authoritative "use the model default" value and must not be serialized // as medium on the next turn. currentEffort: "", sandboxPolicy: "workspace-write", approvalPolicy: "on-request", tokenUsage: null, availableModels: [], subagents: [], controlMode: "sync", modeEpoch: -1, capabilities: { followsVscodeRoute: true, sessionList: false, sessionSelect: false, sessionCreate: false, threadSettings: false, }, modeSnapshotReady: false, modeSwitching: false, modeCommandId: "", requestedControlMode: "", modeRequestEpoch: -1, sessions: [], sessionPickerOpen: false, sessionSearch: "", sessionFocusedId: "", sessionListLoading: false, sessionListError: "", sessionListCommandId: "", sessionSelectCommandId: "", newSessionCommandId: "", sessionSelectedThreadId: "", sessionSwitching: false, // Keep the previous view/title visible until the host confirms the target // with an authoritative session snapshot. A target can be listed as // attachable and still time out during owner hand-off; dropping the old // DOM at `session.switching` would leave the browser blank in that case. sessionSwitchContext: null, modelUpdatePending: false, modelAdvancedOpen: false, // The official composer keeps the background-agent disclosure closed on // first render; the @ hint appears only after the reader expands it. subagentsCollapsed: true, subagentsExpanded: { active: false, done: false }, lastRenderedDateKey: "", lastRenderedTimestamp: null, lastRenderedRole: "", hasRenderedUser: false, lastDateSeparatorTimestamp: null, turnDividers: new Map(), // Preserve an explicit worked-for toggle across authoritative snapshot // rebuilds. Unset entries follow the official default: the latest turn is // open while older completed turns remain compact. turnExpansion: new Map(), // Legacy attach snapshots may omit turnId on individual projected items. // Keep the derived association by object identity for the duration of a // snapshot so all grouping/timing paths use the same anonymous turn key. structuredTurnKeys: new WeakMap(), liveActivityKey: null, pendingUserArticle: null, outputDistanceFromBottom: 0, timelineAnchorLockUntil: 0, timelineAnchorCancel: null, timelineRevealCancel: null, }; const RESPONDABLE_METHODS = new Set([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval", "item/tool/requestUserInput", "mcpServer/elicitation/request", "applyPatchApproval", "execCommandApproval", ]); const requestKey = (requestId) => `${typeof requestId}:${String(requestId)}`; function normalizeControlMode(value) { const normalized = String(value || "").trim().toLowerCase(); return normalized === "sync" || normalized === "async" ? normalized : ""; } function normalizedControlCapabilities(value) { const source = isRecord(value) ? value : {}; return { followsVscodeRoute: source.followsVscodeRoute === true, sessionList: source.sessionList === true, sessionSelect: source.sessionSelect === true, sessionCreate: source.sessionCreate === true, threadSettings: source.threadSettings === true, }; } function sessionControlAllowed(capability) { return state.modeSnapshotReady && state.controlMode === "async" && state.capabilities[capability] === true; } function threadSettingsAllowed() { return state.modeSnapshotReady && state.capabilities.threadSettings === true; } function controlModeChangeBlocked() { return !state.modeSnapshotReady || state.modeSwitching || state.sessionSwitching || !state.appReady || !state.ws || state.ws.readyState !== WebSocket.OPEN || !["operator", "owner", "host"].includes(String(state.role || "")) || Boolean(state.turnId) || state.turnStartedAt !== null || state.turnStatus === "active" || state.turnStatus === "waiting" || Boolean(state.pendingUserText) || state.requests.size > 0 || state.responding.size > 0 || Boolean(state.newSessionCommandId) || Boolean(state.sessionListCommandId) || Boolean(state.sessionSelectCommandId) || state.modelUpdatePending; } function clearControlModeRequest() { state.modeSwitching = false; state.modeCommandId = ""; state.requestedControlMode = ""; state.modeRequestEpoch = -1; } function renderControlMode() { const control = $("controlModeSwitch"); if (!control) return; const listAllowed = sessionControlAllowed("sessionList"); const createAllowed = sessionControlAllowed("sessionCreate"); const settingsAllowed = threadSettingsAllowed(); const blocked = controlModeChangeBlocked(); control.dataset.mode = state.controlMode; control.dataset.epoch = String(state.modeEpoch); control.dataset.switching = String(state.modeSwitching); control.setAttribute("aria-label", t("控制模式")); control.setAttribute("aria-busy", String(state.modeSwitching)); for (const button of control.querySelectorAll("[data-control-mode]")) { const mode = normalizeControlMode(button.dataset.controlMode); const current = mode === state.controlMode; const pending = state.modeSwitching && mode === state.requestedControlMode; button.textContent = t(mode === "async" ? "异步" : "同步"); button.title = t(mode === "async" ? "异步模式可独立管理会话" : "同步模式跟随 VS Code 当前会话"); button.setAttribute("aria-pressed", String(current)); button.dataset.pending = String(pending); button.disabled = blocked || current; } const sessionPickerButton = $("sessionPickerButton"); if (sessionPickerButton) { sessionPickerButton.disabled = !listAllowed || state.modeSwitching || state.sessionSwitching; sessionPickerButton.setAttribute("aria-disabled", String(sessionPickerButton.disabled)); } for (const id of ["backButton", "historyButton"]) { const button = $(id); if (button) button.hidden = !listAllowed; } const newSessionButton = $("newSessionButton"); if (newSessionButton) newSessionButton.hidden = !createAllowed; const sessionsMenuItem = document.querySelector('[data-menu-action="sessions"]'); if (sessionsMenuItem) sessionsMenuItem.hidden = !listAllowed; const refresh = $("sessionPickerRefresh"); if (refresh) refresh.disabled = !listAllowed || state.modeSwitching; if (!listAllowed && state.sessionPickerOpen) setSessionPicker(false); for (const action of document.querySelectorAll('[data-settings-action="model"], [data-settings-action="permission"]')) { action.disabled = !settingsAllowed || state.modeSwitching || state.sessionSwitching; } for (const id of ["modelPickerButton", "permissionChip"]) { const button = $(id); if (button) button.disabled = !settingsAllowed || state.modeSwitching || state.sessionSwitching; } if (!settingsAllowed) { setModelMenu(false); setPermissionMenu(false); } } function applyControlModeSnapshot(metadata) { if (!isRecord(metadata)) return false; const mode = normalizeControlMode(metadata.controlMode); const epoch = finiteNumber(metadata.modeEpoch); if (!mode || epoch === null || (state.modeSnapshotReady && epoch < state.modeEpoch)) return false; const wasSwitching = state.modeSwitching; const requestedMode = state.requestedControlMode; const requestEpoch = state.modeRequestEpoch; state.controlMode = mode; state.modeEpoch = epoch; state.capabilities = normalizedControlCapabilities(metadata.capabilities); state.modeSnapshotReady = true; const requestResolved = wasSwitching && (epoch > requestEpoch || mode === requestedMode); if (requestResolved) { clearControlModeRequest(); setConversationStatus(mode === requestedMode ? "控制模式已切换" : "控制模式切换失败", mode === requestedMode ? "ready" : "warning"); } updateIds(); return true; } function resolveRequestKey(requestId) { const exact = requestKey(requestId); if (state.requests.has(exact)) return exact; const text = String(requestId); const candidates = [...state.requests] .filter(([, request]) => String(request.requestId) === text) .map(([key]) => key); return candidates.length === 1 ? candidates[0] : exact; } function shouldFollowOutput(output) { return output.scrollHeight - output.scrollTop - output.clientHeight <= 24; } const disclosureAnimations = new WeakMap(); const activityAnimations = new WeakMap(); const commandAnimations = new WeakMap(); function motionDuration(value = 220) { try { if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) return 0; } catch { // Older embedded webviews may not expose matchMedia. } return Math.max(0, Number(value) || 0); } function motionClock() { return typeof performance === "object" && typeof performance.now === "function" ? performance.now() : Date.now(); } function scheduleFrame(callback) { if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback); return setTimeout(callback, 0); } function cancelFrame(frame) { if (frame === null || frame === undefined) return; if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame); clearTimeout(frame); } function runMeasuredCssTransition(element, from, to, duration, onFinish) { if (!element) return null; const priorTransition = element.style.transition; let frame = null; let timer = null; let cancelled = false; const setStyles = (values) => { for (const [property, value] of Object.entries(values)) element.style[property] = String(value); }; const finish = () => { if (cancelled) return; cancelled = true; cancelFrame(frame); frame = null; if (timer !== null) clearTimeout(timer); timer = null; element.style.transition = priorTransition; onFinish?.(); }; const controller = { cancel() { if (cancelled) return; cancelled = true; cancelFrame(frame); frame = null; if (timer !== null) clearTimeout(timer); timer = null; element.style.transition = priorTransition; }, }; const properties = Object.keys(to).map((property) => property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)); element.style.transition = properties .map((property) => `${property} ${duration}ms cubic-bezier(.33,1,.68,1)`) .join(", "); setStyles(from); // Force a layout boundary before moving to the measured target. This is // the fallback for embedded Chromium builds without Element.animate(). void element.offsetHeight; frame = scheduleFrame(() => { frame = null; if (cancelled) return; setStyles(to); }); timer = setTimeout(finish, duration + 55); return controller; } // Keep the element nearest the reader at the same viewport coordinate while // a disclosure or streamed row changes height. This mirrors the official // preserve-timeline-anchor-position helper and avoids bottom-relative jumps // when the reader is inspecting an earlier turn. function preserveTimelineAnchor(anchor, duration = 250) { const target = anchor?.nodeType === 1 ? anchor : null; const output = target?.closest?.(".chat-scroll") || $("output"); if (!target || !output || !target.isConnected || !output.isConnected) return; if (typeof state.timelineAnchorCancel === "function") state.timelineAnchorCancel(); const initialTop = target.getBoundingClientRect().top; if (!Number.isFinite(initialTop)) return; const deadline = motionClock() + duration + 120; state.timelineAnchorLockUntil = Math.max(state.timelineAnchorLockUntil || 0, deadline); let frame = null; let timer = null; let disposed = false; const adjust = () => { if (disposed || !target.isConnected || !output.isConnected) return; const delta = target.getBoundingClientRect().top - initialTop; if (Number.isFinite(delta) && Math.abs(delta) >= 0.1) { const maxScroll = Math.max(0, output.scrollHeight - output.clientHeight); output.scrollTop = Math.max(0, Math.min(maxScroll, output.scrollTop + delta)); updateScrollToBottom(output); } // A disclosure can change height without producing a ResizeObserver // callback on older Chromium builds. Keep one frame queued for the full // measured transition so the scrollbar and reader anchor move together. if (motionClock() < deadline) schedule(); }; const schedule = () => { if (disposed || frame !== null) return; frame = scheduleFrame(() => { frame = null; adjust(); }); }; const immediate = () => { if (frame !== null) { cancelFrame(frame); frame = null; } adjust(); schedule(); }; const observed = target.closest("[data-turn-key]") || target.closest(".message") || target; let observer = null; if (typeof ResizeObserver === "function") { observer = new ResizeObserver(immediate); observer.observe(observed); if (observed !== target) observer.observe(target); } schedule(); const finish = () => { if (disposed) return; disposed = true; cancelFrame(frame); frame = null; if (timer !== null) clearTimeout(timer); timer = null; observer?.disconnect(); if (state.timelineAnchorCancel === finish) state.timelineAnchorCancel = null; }; state.timelineAnchorCancel = finish; timer = setTimeout(finish, Math.max(250, duration + 150)); } function setDisclosureBodyState(details, expanded) { const body = details?.querySelector?.(":scope > .details-body"); if (!body) return; body.style.display = "block"; body.style.height = expanded ? "auto" : "0px"; body.style.opacity = expanded ? "1" : "0"; body.style.overflow = expanded ? "" : "hidden"; body.style.pointerEvents = expanded ? "auto" : "none"; body.dataset.disclosureState = expanded ? "expanded" : "collapsed"; details.dataset.expanded = String(Boolean(expanded)); details.querySelector("summary")?.setAttribute("aria-expanded", String(Boolean(expanded))); } function animateDisclosureBody(details, expanded, options = {}) { const body = details?.querySelector?.(":scope > .details-body"); if (!body) return; const previous = disclosureAnimations.get(body); if (previous) { try { previous.commitStyles?.(); } catch { /* animation may already be finished */ } previous.cancel(); } disclosureAnimations.delete(body); const next = Boolean(expanded); details.dataset.expanded = String(next); details.dataset.animating = "true"; body.style.display = "block"; const duration = motionDuration(options.duration ?? 220); const computed = getComputedStyle(body); const currentHeight = Math.max(0, body.getBoundingClientRect().height || 0); const currentOpacity = Number.parseFloat(computed.opacity); const fromOpacity = Number.isFinite(currentOpacity) ? currentOpacity : (next ? 0 : 1); if (options.immediate || duration === 0) { setDisclosureBodyState(details, next); details.dataset.animating = "false"; return; } let targetHeight = 0; if (next) { body.style.height = "auto"; body.style.opacity = "1"; body.style.overflow = "hidden"; targetHeight = Math.max(0, body.getBoundingClientRect().height || body.scrollHeight || 0); body.style.height = `${currentHeight}px`; } else { body.style.height = `${currentHeight}px`; body.style.opacity = String(fromOpacity); body.style.overflow = "hidden"; } if (Math.abs(targetHeight - currentHeight) < 0.5 && (next ? fromOpacity >= 0.99 : fromOpacity <= 0.01)) { setDisclosureBodyState(details, next); details.dataset.animating = "false"; return; } if (typeof body.animate !== "function") { let transition; transition = runMeasuredCssTransition( body, { height: `${currentHeight}px`, opacity: fromOpacity }, { height: `${targetHeight}px`, opacity: next ? 1 : 0 }, duration, () => { if (disclosureAnimations.get(body) !== transition) return; disclosureAnimations.delete(body); setDisclosureBodyState(details, next); details.dataset.animating = "false"; }, ); if (transition) disclosureAnimations.set(body, transition); return; } const animation = body.animate([ { height: `${currentHeight}px`, opacity: fromOpacity }, { height: `${targetHeight}px`, opacity: next ? 1 : 0 }, ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" }); disclosureAnimations.set(body, animation); animation.addEventListener("finish", () => { if (disclosureAnimations.get(body) !== animation) return; disclosureAnimations.delete(body); setDisclosureBodyState(details, next); details.dataset.animating = "false"; // `fill: forwards` keeps the animation in the cascade after finish. // Release it only after the stable inline state is written, otherwise a // later open can still measure the previous collapsed height (zero). animation.cancel(); }, { once: true }); animation.addEventListener("cancel", () => { if (disclosureAnimations.get(body) === animation) disclosureAnimations.delete(body); }, { once: true }); } function installDisclosure(details, initiallyOpen) { if (!details || details.dataset.disclosureInstalled === "true") return; const summary = details.querySelector(":scope > summary"); const body = details.querySelector(":scope > .details-body"); if (!summary || !body) return; details.dataset.disclosureInstalled = "true"; // Keep the native details element mounted. The summary still supplies the // familiar keyboard/focus semantics, while the body itself is animated // with measured height so a close does not jump the transcript. details.open = true; setDisclosureBodyState(details, Boolean(initiallyOpen)); summary.addEventListener("click", (event) => { event.preventDefault(); setDetailsExpanded(details, !isDetailsExpanded(details)); }, true); details.addEventListener("toggle", () => { // A browser/plugin script may still assign `.open = false`; restore the // mounted shell and leave the visual state under our measured body. if (!details.open && !details.__codexRestoring) { details.__codexRestoring = true; details.open = true; details.__codexRestoring = false; } }); } function isDetailsExpanded(details) { if (!details) return false; if (details.dataset.expanded !== undefined) return details.dataset.expanded === "true"; return details.open === true; } function setDetailsExpanded(details, expanded, options = {}) { if (!details) return; const next = Boolean(expanded); const previous = isDetailsExpanded(details); const installed = details.dataset.disclosureInstalled === "true"; if (!installed) { details.open = next; return; } if (previous === next && !options.force) { if (options.immediate) animateDisclosureBody(details, next, { immediate: true }); return; } details.dataset.expanded = String(next); if (options.preserve !== false && !options.immediate) preserveTimelineAnchor(details, options.duration ?? 230); animateDisclosureBody(details, next, { immediate: Boolean(options.immediate), duration: options.duration }); if (next && !options.immediate && options.reveal !== false) { scheduleTimelineReveal(details.querySelector(":scope > .details-body") || details, options.duration ?? 220); } } function animateActivityArticle(article, expanded, options = {}) { if (!article) return; const previous = activityAnimations.get(article); if (previous) { try { previous.commitStyles?.(); } catch { /* animation may already be finished */ } previous.cancel(); } activityAnimations.delete(article); const next = Boolean(expanded); const duration = motionDuration(options.duration ?? 230); const wasCollapsed = article.classList.contains("turn-collapsed"); article.dataset.turnExpanded = String(next); // `worked-for` is an outer disclosure. The official renderer removes the // whole activity group from layout while it is collapsed, rather than // leaving one summary row per command. Animate the article shell itself; // nested command/read disclosures keep their own expanded state. if (next) article.classList.remove("turn-collapsed"); article.style.display = ""; article.style.height = ""; article.style.opacity = ""; article.style.visibility = ""; article.style.pointerEvents = ""; article.style.overflow = ""; const currentHeight = Math.max(0, article.getBoundingClientRect().height || 0); const naturalHeight = Math.max(0, article.scrollHeight || currentHeight); const fromHeight = next && wasCollapsed ? 0 : currentHeight; const targetHeight = next ? naturalHeight : 0; const fromOpacity = next ? (wasCollapsed ? 0 : 1) : 1; const targetOpacity = next ? 1 : 0; const finish = () => { if (next) { article.classList.remove("turn-collapsed"); article.style.height = ""; article.style.opacity = ""; article.style.visibility = ""; article.style.pointerEvents = ""; article.style.overflow = ""; } else { article.classList.add("turn-collapsed"); article.style.height = "0px"; article.style.opacity = "0"; article.style.visibility = "hidden"; article.style.pointerEvents = "none"; article.style.overflow = "hidden"; } }; if (options.immediate || duration === 0 || Math.abs(targetHeight - fromHeight) < 0.5) { finish(); return; } article.style.height = `${fromHeight}px`; article.style.opacity = String(fromOpacity); article.style.overflow = "hidden"; if (typeof article.animate !== "function") { let transition; transition = runMeasuredCssTransition( article, { height: `${fromHeight}px`, opacity: fromOpacity }, { height: `${targetHeight}px`, opacity: targetOpacity }, duration, () => { if (activityAnimations.get(article) !== transition) return; activityAnimations.delete(article); finish(); }, ); if (transition) activityAnimations.set(article, transition); return; } const animation = article.animate([ { height: `${fromHeight}px`, opacity: fromOpacity }, { height: `${targetHeight}px`, opacity: targetOpacity }, ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" }); activityAnimations.set(article, animation); animation.addEventListener("finish", () => { if (activityAnimations.get(article) !== animation) return; activityAnimations.delete(article); finish(); animation.cancel(); }, { once: true }); animation.addEventListener("cancel", () => { if (activityAnimations.get(article) === animation) activityAnimations.delete(article); }, { once: true }); } function animateCommandRow(commandRow, expanded, options = {}) { if (!commandRow) return; const previous = commandAnimations.get(commandRow); if (previous) { try { previous.commitStyles?.(); } catch { /* animation may already be finished */ } previous.cancel(); } commandAnimations.delete(commandRow); const next = Boolean(expanded); const duration = motionDuration(options.duration ?? 190); const fromHeight = Math.max(0, commandRow.getBoundingClientRect().height || 0); commandRow.dataset.expanded = String(next); commandRow.setAttribute("aria-expanded", String(next)); commandRow.style.overflow = "hidden"; commandRow.style.height = "auto"; const targetHeight = Math.max(0, commandRow.getBoundingClientRect().height || 0); if (options.immediate || duration === 0 || Math.abs(targetHeight - fromHeight) < 0.5) { commandRow.style.height = ""; commandRow.style.overflow = ""; return; } if (typeof commandRow.animate !== "function") { let transition; transition = runMeasuredCssTransition( commandRow, { height: `${fromHeight}px` }, { height: `${targetHeight}px` }, duration, () => { if (commandAnimations.get(commandRow) !== transition) return; commandAnimations.delete(commandRow); commandRow.style.height = ""; commandRow.style.overflow = ""; }, ); if (transition) commandAnimations.set(commandRow, transition); return; } commandRow.style.height = `${fromHeight}px`; const animation = commandRow.animate([ { height: `${fromHeight}px` }, { height: `${targetHeight}px` }, ], { duration, easing: "cubic-bezier(.33,1,.68,1)", fill: "forwards" }); commandAnimations.set(commandRow, animation); animation.addEventListener("finish", () => { if (commandAnimations.get(commandRow) !== animation) return; commandAnimations.delete(commandRow); commandRow.style.height = ""; commandRow.style.overflow = ""; animation.cancel(); }, { once: true }); animation.addEventListener("cancel", () => { if (commandAnimations.get(commandRow) === animation) commandAnimations.delete(commandRow); }, { once: true }); } function updateScrollToBottom(output = $("output")) { const button = $("scrollToBottom"); if (!button || !output) return; const distance = Math.max(0, output.scrollHeight - output.scrollTop - output.clientHeight); state.outputDistanceFromBottom = distance; const visible = distance > 24; const working = state.turnStartedAt !== null || state.turnStatus === "active" || state.turnStatus === "waiting"; button.dataset.visible = String(visible); button.dataset.working = String(working); button.setAttribute("aria-label", t(working ? "正在工作,回到最新消息" : "回到最新消息")); button.setAttribute("aria-hidden", String(!visible)); button.tabIndex = visible ? 0 : -1; } function scrollOutput(output, force = false) { if (!output) return; if (force || shouldFollowOutput(output)) { if (typeof output.scrollTo === "function") output.scrollTo({ top: output.scrollHeight, behavior: "auto" }); else output.scrollTop = output.scrollHeight; } updateScrollToBottom(output); } function updateScrollPadding() { const output = $("output"); const panel = document.querySelector(".chat-panel"); const composer = $("messageForm"); if (!output || !panel || !composer) return; const outputRect = output.getBoundingClientRect(); const composerRect = composer.getBoundingClientRect(); // The composer is an overlay in the official panel. Reserve only the // portion that actually covers the scroll viewport, plus a small gap. const overlap = Math.max(0, Math.ceil(outputRect.bottom - composerRect.top)); const reserve = Math.max(72, overlap + 16); output.style.setProperty("--thread-scroll-padding-bottom", `${reserve}px`); panel.style.setProperty("--thread-scroll-padding-bottom", `${reserve}px`); updateScrollToBottom(output); } function timelineVisibleBounds(output) { if (!output) return null; const outputRect = output.getBoundingClientRect(); const composer = $("messageForm"); const composerRect = composer?.getBoundingClientRect?.(); const top = outputRect.top + 8; const composerTop = composerRect && Number.isFinite(composerRect.top) ? composerRect.top - 10 : outputRect.bottom - 8; const bottom = Math.min(outputRect.bottom - 8, composerTop); return { top, bottom: Math.max(top, bottom) }; } // Keep an expanding activity row inside the portion of the transcript that // is actually readable above the composer. This runs across the measured // height animation because one layout pass is not enough when streamed // command output arrives at the same time. function ensureTimelineVisible(target) { const element = target?.nodeType === 1 ? target : null; const output = element?.closest?.(".chat-scroll") || $("output"); if (!element || !output || !element.isConnected || !output.isConnected) return; const bounds = timelineVisibleBounds(output); if (!bounds) return; const rect = element.getBoundingClientRect(); let delta = 0; if (rect.top < bounds.top) delta = rect.top - bounds.top; else if (rect.bottom > bounds.bottom) delta = rect.bottom - bounds.bottom; if (!Number.isFinite(delta) || Math.abs(delta) < 0.25) return; const maxScroll = Math.max(0, output.scrollHeight - output.clientHeight); output.scrollTop = Math.max(0, Math.min(maxScroll, output.scrollTop + delta)); updateScrollToBottom(output); } function scheduleTimelineReveal(target, duration = 220) { const element = target?.nodeType === 1 ? target : null; if (!element) return; if (typeof state.timelineRevealCancel === "function") state.timelineRevealCancel(); const deadline = motionClock() + motionDuration(duration) + 90; let frame = null; let timer = null; let disposed = false; const tick = () => { if (disposed) return; frame = null; ensureTimelineVisible(element); if (motionClock() < deadline) frame = scheduleFrame(tick); }; const cancel = () => { if (disposed) return; disposed = true; cancelFrame(frame); frame = null; if (timer !== null) clearTimeout(timer); timer = null; if (state.timelineRevealCancel === cancel) state.timelineRevealCancel = null; }; state.timelineRevealCancel = cancel; tick(); timer = setTimeout(cancel, Math.max(180, motionDuration(duration) + 130)); } function comparableText(value) { return String(value ?? "").replace(/\s+/g, " ").trim(); } function hasRenderedMessage(text, turnId, role) { const target = comparableText(text); if (!target) return false; const output = $("output"); if (!output) return false; return [...output.querySelectorAll(`.message.${role}`)].some((article) => { if (turnId && article.dataset.turnId && article.dataset.turnId !== String(turnId)) return false; return comparableText(article.dataset.rawText) === target; }); } function hasRenderedCompletedMessage(text, turnId, role) { const target = comparableText(text); if (!target) return false; const output = $("output"); if (!output) return false; return [...output.querySelectorAll(`.message.${role}`)].some((article) => { if (article.classList.contains("streaming")) return false; if (turnId && article.dataset.turnId && article.dataset.turnId !== String(turnId)) return false; const raw = comparableText(article.dataset.rawText); return raw === target || raw.includes(target) || target.includes(raw); }); } function appendInlineMarkdown(parent, source) { const pattern = /(\[[^\]]+\]\(https?:\/\/[^)\s]+\)|`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|~~[^~\n]+~~|\*[^*\n]+\*|_[^_\n]+_)/g; let cursor = 0; const appendText = (value) => { const parts = String(value).split("\n"); parts.forEach((part, index) => { if (part) parent.append(document.createTextNode(part)); if (index < parts.length - 1) parent.append(document.createElement("br")); }); }; for (const match of String(source).matchAll(pattern)) { if (match.index > cursor) appendText(String(source).slice(cursor, match.index)); const token = match[0]; if (token.startsWith("[") && token.endsWith(")")) { const split = token.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/); if (split) { const link = document.createElement("a"); link.href = split[2]; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = split[1]; parent.append(link); } else appendText(token); } else if (token.startsWith("`") && token.endsWith("`")) { const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code); } else if (token.startsWith("**") || token.startsWith("__")) { const strong = document.createElement("strong"); strong.textContent = token.slice(2, -2); parent.append(strong); } else if (token.startsWith("~~")) { const deleted = document.createElement("del"); deleted.textContent = token.slice(2, -2); parent.append(deleted); } else if (token.startsWith("*") || token.startsWith("_")) { const emphasis = document.createElement("em"); emphasis.textContent = token.slice(1, -1); parent.append(emphasis); } else appendText(token); cursor = match.index + token.length; } if (cursor < String(source).length) appendText(String(source).slice(cursor)); } function tableCells(line) { let value = String(line || "").trim(); if (value.startsWith("|")) value = value.slice(1); if (value.endsWith("|")) value = value.slice(0, -1); return value.split("|").map((cell) => cell.trim()); } function isTableDivider(line) { const cells = tableCells(line); return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); } function appendTable(container, headerLine, bodyLines) { const table = document.createElement("table"); const thead = document.createElement("thead"); const header = document.createElement("tr"); for (const cell of tableCells(headerLine)) { const th = document.createElement("th"); appendInlineMarkdown(th, cell); header.append(th); } thead.append(header); table.append(thead); const tbody = document.createElement("tbody"); for (const line of bodyLines) { const row = document.createElement("tr"); for (const cell of tableCells(line)) { const td = document.createElement("td"); appendInlineMarkdown(td, cell); row.append(td); } tbody.append(row); } table.append(tbody); container.append(table); } /** * Render the small, safe Markdown subset used by Codex messages. The * official webview uses a full Markdown/ProseMirror pipeline; the relay * intentionally keeps this browser-side renderer dependency-free and never * assigns untrusted text to innerHTML. */ function renderMarkdown(container, source) { container.replaceChildren(); const lines = String(source ?? "").replace(/\r\n?/g, "\n").split("\n"); let index = 0; const addParagraph = (paragraph) => { if (!paragraph.length) return; const element = document.createElement("p"); appendInlineMarkdown(element, paragraph.join("\n")); container.append(element); }; while (index < lines.length) { const line = lines[index]; if (!line.trim()) { index += 1; continue; } const fence = line.match(/^\s*```\s*([\w.+-]*)\s*$/); if (fence) { index += 1; const codeLines = []; while (index < lines.length && !/^\s*```\s*$/.test(lines[index])) codeLines.push(lines[index++]); if (index < lines.length) index += 1; const pre = document.createElement("pre"); const code = document.createElement("code"); if (fence[1]) code.dataset.language = fence[1]; code.textContent = codeLines.join("\n"); pre.append(code); container.append(pre); continue; } if (index + 1 < lines.length && line.includes("|") && isTableDivider(lines[index + 1])) { const body = []; index += 2; while (index < lines.length && lines[index].trim() && lines[index].includes("|")) body.push(lines[index++]); appendTable(container, line, body); continue; } if (/^\s*(?:---+|___+|\*\*\*+)\s*$/.test(line)) { container.append(document.createElement("hr")); index += 1; continue; } const heading = line.match(/^\s*(#{1,3})\s+(.+?)\s*#*$/); if (heading) { const element = document.createElement(`h${heading[1].length}`); appendInlineMarkdown(element, heading[2]); container.append(element); index += 1; continue; } if (/^\s*>\s?/.test(line)) { const quote = document.createElement("blockquote"); while (index < lines.length && /^\s*>\s?/.test(lines[index])) { const paragraph = document.createElement("p"); appendInlineMarkdown(paragraph, lines[index].replace(/^\s*>\s?/, "")); quote.append(paragraph); index += 1; } container.append(quote); continue; } const list = line.match(/^\s*([-*+]|\d+[.)])\s+(.+)$/); if (list) { const ordered = /^\d/.test(list[1]); const listElement = document.createElement(ordered ? "ol" : "ul"); while (index < lines.length) { const item = lines[index].match(/^\s*([-*+]|\d+[.)])\s+(.+)$/); if (!item || /^\d/.test(item[1]) !== ordered) break; const li = document.createElement("li"); const task = item[2].match(/^\[([ xX])\]\s+(.+)$/); if (task) { li.className = "task-list-item"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = task[1].toLowerCase() === "x"; checkbox.disabled = true; checkbox.setAttribute("aria-label", t(checkbox.checked ? "已完成" : "未完成")); li.append(checkbox); appendInlineMarkdown(li, task[2]); } else appendInlineMarkdown(li, item[2]); listElement.append(li); index += 1; } container.append(listElement); continue; } const paragraph = [line]; index += 1; while (index < lines.length && lines[index].trim() && !/^\s*```/.test(lines[index]) && !/^\s*(#{1,3})\s+/.test(lines[index]) && !/^\s*>\s?/.test(lines[index]) && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[index])) { paragraph.push(lines[index++]); } addParagraph(paragraph); } } function renderMessageBody(body, text, role, tone, kind) { const value = String(text ?? ""); body.dataset.rawText = value; // User-authored prompts use the same safe Markdown subset as assistant // messages. Tool/status rows stay literal so command output cannot be // mistaken for formatted content. const markdownRole = role === "assistant" || role === "user" || kind === "reasoning" || kind === "plan" || kind === "subagent" || kind === "commentary"; body.classList.toggle("markdown-body", markdownRole && tone !== "meta" && tone !== "error" && kind !== "tool"); body.classList.toggle("diff-body", kind === "edit"); if (kind === "edit") { body.replaceChildren(); const output = document.createElement("pre"); output.className = "diff-output"; for (const line of value.replace(/\r\n?/g, "\n").split("\n")) { const row = document.createElement("span"); row.className = line.startsWith("+") && !line.startsWith("+++") ? "diff-line added" : line.startsWith("-") && !line.startsWith("---") ? "diff-line removed" : line.startsWith("@@") || line.startsWith("diff ") || line.startsWith("[") ? "diff-line context" : "diff-line"; row.textContent = line || " "; output.append(row); } body.append(output); } else if (kind === "tool" || kind === "read") body.textContent = value; else if (markdownRole && tone !== "meta" && tone !== "error") renderMarkdown(body, value); else body.textContent = value; } function createTerminalIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.className.baseVal = "activity-summary-icon terminal-icon"; svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); const frame = document.createElementNS("http://www.w3.org/2000/svg", "rect"); frame.setAttribute("x", "2.25"); frame.setAttribute("y", "2.75"); frame.setAttribute("width", "11.5"); frame.setAttribute("height", "10.5"); frame.setAttribute("rx", "1.4"); const prompt = document.createElementNS("http://www.w3.org/2000/svg", "path"); prompt.setAttribute("d", "m4.5 6 2 2-2 2"); const cursor = document.createElementNS("http://www.w3.org/2000/svg", "path"); cursor.setAttribute("d", "M8.5 10h2.5"); svg.append(frame, prompt, cursor); return svg; } function createSubagentIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.className.baseVal = "activity-summary-icon subagent-summary-icon"; svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); // The installed Codex webview uses the filled blossom mark for both // sub-agent activity rows and the composer agent rail. Keep the path // inline so the standalone relay page does not depend on VS Code's URI // resolver or on an extension-owned asset path. const mark = document.createElementNS("http://www.w3.org/2000/svg", "path"); mark.setAttribute("fill", "currentColor"); mark.setAttribute("d", "M13.795 23.856q-1.188 0-2.256-.448a6.1 6.1 0 0 1-1.9-1.247 5.8 5.8 0 0 1-1.875.306 5.8 5.8 0 0 1-2.944-.777 6.1 6.1 0 0 1-2.184-2.12q-.807-1.34-.808-2.99 0-.682.19-1.482a6.3 6.3 0 0 1-1.472-2.002 5.76 5.76 0 0 1 .024-4.85q.546-1.177 1.52-2.024a5.5 5.5 0 0 1 2.303-1.2A5.55 5.55 0 0 1 5.485 2.62 6.06 6.06 0 0 1 7.575.925 5.85 5.85 0 0 1 10.21.313q1.187 0 2.255.447a6.1 6.1 0 0 1 1.9 1.248 5.8 5.8 0 0 1 1.875-.306q1.59 0 2.944.776a5.9 5.9 0 0 1 2.16 2.12q.832 1.34.832 2.99 0 .682-.19 1.483a6.2 6.2 0 0 1 1.472 2.024q.522 1.13.522 2.378 0 1.272-.546 2.449a6.1 6.1 0 0 1-1.543 2.048 5.45 5.45 0 0 1-2.28 1.177 5.4 5.4 0 0 1-1.115 2.402 5.8 5.8 0 0 1-2.066 1.695 5.85 5.85 0 0 1-2.635.612M7.93 20.913q1.188 0 2.066-.495l4.463-2.542a.52.52 0 0 0 .238-.448v-2.024L8.95 18.676a.97.97 0 0 1-1.044 0L3.419 16.11a.7.7 0 0 1-.024.165v.282q0 1.201.57 2.213.594.99 1.639 1.554 1.044.59 2.326.589m.238-3.838q.143.07.26.07a.46.46 0 0 0 .238-.07l1.781-1.012-5.722-3.296q-.522-.306-.522-.918v-5.11a4.27 4.27 0 0 0-1.9 1.602 4.13 4.13 0 0 0-.712 2.354q0 1.155.594 2.213.593 1.06 1.543 1.601zm5.627 5.227q1.258 0 2.279-.565a4.25 4.25 0 0 0 1.614-1.554q.594-.99.594-2.213v-5.085q0-.283-.237-.424l-1.805-1.036v6.568q0 .613-.522.919l-4.487 2.566q1.163.825 2.564.824m.902-8.617v-3.202l-2.683-1.507-2.707 1.507v3.202l2.707 1.507zm-6.933-7.51q0-.612.522-.918l4.488-2.567a4.34 4.34 0 0 0-2.564-.824q-1.26 0-2.28.565a4.25 4.25 0 0 0-1.614 1.554q-.57.99-.57 2.213v5.062q0 .283.237.447l1.781 1.036zm12.061 11.253a4.13 4.13 0 0 0 1.876-1.6 4.2 4.2 0 0 0 .712-2.355q0-1.154-.593-2.213-.594-1.06-1.544-1.6l-4.44-2.543q-.142-.095-.26-.071a.46.46 0 0 0-.238.07l-1.78.99 5.745 3.319q.26.141.38.377a.9.9 0 0 1 .142.518zm-4.772-11.96q.522-.33 1.045 0l4.51 2.614v-.424q0-1.13-.57-2.142a4.1 4.1 0 0 0-1.59-1.648q-1.02-.613-2.374-.613-1.187 0-2.066.495L9.545 6.292a.52.52 0 0 0-.238.448v2.025z"); svg.append(mark); return svg; } function createReadIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.className.baseVal = "activity-summary-icon read-summary-icon"; svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "M2.5 4.5h4l1.2 1.4h5.8v6.2c0 .8-.5 1.4-1.3 1.4H3.8c-.8 0-1.3-.6-1.3-1.4V4.5Z"); const top = document.createElementNS("http://www.w3.org/2000/svg", "path"); top.setAttribute("d", "M2.5 6h11"); svg.append(path, top); return svg; } // The official sub-agent activity renderer keeps the agent name in a // bordered chip and places the lifecycle text beside it. Keeping those two // nodes separate also lets a running row update only its status without // replacing the blossom icon or the accessible label. function setSubagentSummary(summary, name, statusText = "") { if (!summary) return; let chip = summary.querySelector(".subagent-summary-chip"); let label = chip?.querySelector(".subagent-summary-label"); let status = summary.querySelector(".subagent-summary-status"); if (!chip || !label || !status) { chip = document.createElement("span"); chip.className = "subagent-summary-chip"; const icon = createSubagentIcon(); label = document.createElement("span"); label.className = "subagent-summary-label"; chip.append(icon, label); status = document.createElement("span"); status.className = "subagent-summary-status"; summary.replaceChildren(chip, status); } label.textContent = String(name || t("子代理")); status.textContent = statusText ? t(statusText) : ""; summary.setAttribute("aria-label", [label.textContent, status.textContent].filter(Boolean).join(" ")); } function setActivitySummary(summary, text, kind = "") { if (!summary) return; if (kind === "subagent") { const value = String(text || ""); const match = value.match(/^(.*?)(?:\s+(已开始工作|已完成|失败|已中断|等待中|处理中|Started working|Completed|Failed|Interrupted|Waiting|Working))$/); setSubagentSummary(summary, match ? match[1] : value || t("子代理"), match ? t(match[2]) : ""); return; } if (kind !== "tool" && kind !== "read" && kind !== "subagent") { summary.textContent = String(text || ""); return; } let icon = summary.querySelector(".activity-summary-icon"); let label = summary.querySelector(".activity-summary-label"); if (!icon || !label) { icon = kind === "subagent" ? createSubagentIcon() : kind === "read" ? createReadIcon() : createTerminalIcon(); label = document.createElement("span"); label.className = "activity-summary-label"; summary.replaceChildren(icon, label); } label.textContent = String(text || ""); } function stripShellQuotes(value) { let text = String(value || "").trim(); let changed = true; while (changed) { changed = false; if (text.startsWith("$'") && text.endsWith("'")) { text = text.slice(2, -1).replace(/\\'/g, "'"); changed = true; } else if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) { const quoted = text.startsWith('"'); text = text.slice(1, -1); if (quoted) text = text.replace(/\\"/g, '"'); changed = true; } } return text.trim(); } // The official command renderer hides the shell bootstrap used by the IPC // runner (`/bin/zsh -lc '…'`) and shows the actual user command instead. function terminalCommandText(value) { const command = stripShellQuotes(value); const match = command.match(/^(?:.*[/\\])?(?:bash|cmd(?:\.exe)?|fish|powershell(?:\.exe)?|pwsh(?:\.exe)?|sh|zsh)\s+-lc\s+([\s\S]+)$/i); return match ? stripShellQuotes(match[1]) : command; } function addMessageActions(article, enabled = true) { // Tool/reasoning rows have their own disclosure controls and should not // grow a second action rail. Editing an earlier turn is intentionally not // exposed until the relay can perform the official branch/edit operation. if (!enabled || (!article.classList.contains("user") && !article.classList.contains("assistant"))) return null; if (article.classList.contains("assistant") && article.classList.contains("streaming")) return null; const actions = document.createElement("div"); actions.className = "message-actions"; const copy = document.createElement("button"); copy.type = "button"; copy.className = "message-action"; copy.title = t("复制消息"); copy.setAttribute("aria-label", t("复制消息")); const setCopyIcon = (copied = false) => { copy.replaceChildren(); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); if (copied) { const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3"); svg.append(path); } else { const back = document.createElementNS("http://www.w3.org/2000/svg", "path"); back.setAttribute("d", "M5.5 5.5V4c0-.8.7-1.5 1.5-1.5h5c.8 0 1.5.7 1.5 1.5v5c0 .8-.7 1.5-1.5 1.5h-1.5"); const front = document.createElementNS("http://www.w3.org/2000/svg", "rect"); front.setAttribute("x", "2.5"); front.setAttribute("y", "5.5"); front.setAttribute("width", "7.5"); front.setAttribute("height", "7.5"); front.setAttribute("rx", "1.2"); svg.append(back, front); } copy.append(svg); }; setCopyIcon(); copy.addEventListener("click", async () => { const value = article.dataset.rawText || ""; try { await navigator.clipboard?.writeText(value); setCopyIcon(true); setTimeout(() => { setCopyIcon(false); }, 1_200); } catch { copy.textContent = "!"; } }); actions.append(copy); article.append(actions); return actions; } function appendMessage(text, role = "assistant", tone = "text", meta = "", options = {}) { if (text === undefined || text === null) return null; const output = $("output"); const follow = shouldFollowOutput(output); const article = document.createElement("article"); article.className = `message ${role}${tone && tone !== "text" ? ` ${tone}` : ""}`; if (options.kind) article.dataset.kind = options.kind; if (options.status) article.dataset.status = String(options.status); if (options.turnId) article.dataset.turnId = String(options.turnId); if (options.structuredKey) article.dataset.structuredKey = String(options.structuredKey); if (options.activityKey) article.dataset.activityKey = String(options.activityKey); if (options.itemId !== undefined && options.itemId !== null) article.dataset.itemId = String(options.itemId); if (options.itemType) article.dataset.itemType = String(options.itemType); if (options.agentThreadId) article.dataset.agentThreadId = String(options.agentThreadId); if (options.timestamp) { const parsedTimestamp = timestampMs(options.timestamp); if (parsedTimestamp !== null) article.dataset.timestamp = String(parsedTimestamp); } article.dataset.rawText = String(text); const content = document.createElement("div"); content.className = "message-content"; let body = document.createElement("div"); body.className = "message-body"; let details = null; const initiallyOpen = options.open !== false; if (options.collapsible) { details = document.createElement("details"); details.className = `message-details ${options.kind || ""}`; const summary = document.createElement("summary"); const summaryText = options.summary || options.label || t("详情"); if (options.kind === "tool" || options.kind === "read" || options.kind === "subagent") { setActivitySummary(summary, summaryText, options.kind); if (options.command) summary.title = terminalCommandText(options.command); } else { summary.textContent = summaryText; if (options.command && !options.summary) { // Translate only the renderer-owned label. The command is host data // and must remain byte-for-byte unchanged in every locale. summary.textContent = `${t(options.label || "命令")} · ${String(options.command)}`; summary.title = String(options.command); } } body.classList.add("details-body"); details.append(summary, body); content.append(details); } else content.append(body); renderMessageBody(body, text, role, tone, options.kind); article.append(content); const actions = addMessageActions(article, options.showActions !== false); const metaParts = []; if (meta) metaParts.push(String(meta)); if (options.showTimestamp === true && options.timestamp) { const timestamp = formatMessageTime(options.timestamp); if (timestamp) metaParts.push(timestamp); } if (options.showDuration === true && options.durationMs !== undefined && options.durationMs !== null) { const duration = formatDuration(options.durationMs); if (duration) metaParts.push(uiWithRaw("用时 ", "Worked for ", duration)); } if (metaParts.length) { const stamp = document.createElement("div"); stamp.className = "message-meta"; stamp.textContent = metaParts.join(" · "); if (actions) actions.prepend(stamp); else content.append(stamp); } output.append(article); if (details) installDisclosure(details, initiallyOpen); if (follow) scrollOutput(output, true); return { article, content: body, wrapper: content }; } function appendDateSeparator(timestamp, options = {}) { const parsedTimestamp = timestampMs(timestamp); const role = String(options.role || "user"); // The official timestamp projection treats an item without a usable time // as an adjacency break. Do not carry the previous role/time across an // untimestamped item, otherwise a later assistant message can inherit an // unrelated 10-minute/1-hour gap. if (parsedTimestamp === null) { state.lastRenderedDateKey = ""; state.lastRenderedTimestamp = null; state.lastRenderedRole = ""; if (role === "user") state.hasRenderedUser = true; return; } const previousTimestamp = state.lastRenderedTimestamp; const previousRole = state.lastRenderedRole; const hour = 60 * 60 * 1000; const tenMinutes = 10 * 60 * 1000; const gap = previousTimestamp === null ? null : parsedTimestamp - previousTimestamp; // This mirrors the official timestamps projection: a first/next user turn // is separated only after a substantial pause, while consecutive assistant // entries can be separated after a shorter gap. const threshold = previousRole === "assistant" ? role === "user" ? hour : tenMinutes : Infinity; const firstUserIsOld = !state.hasRenderedUser && role === "user" && Date.now() - parsedTimestamp > hour; // The official local composer always gives the first user message a // centered date/time anchor, even when that message was sent today. It is // also the visual boundary that separates a freshly attached history from // the input composer, so do not hide it merely because the turn is recent. const firstUserTurn = !state.hasRenderedUser && role === "user"; const show = options.force === true || options.breaksPreviousAdjacency === true || firstUserIsOld || firstUserTurn || (gap !== null && gap > 0 && previousRole === "assistant" && gap > threshold); if (show && state.lastDateSeparatorTimestamp !== parsedTimestamp) { const label = formatMessageDate(parsedTimestamp); if (label) { const separator = document.createElement("div"); separator.className = "date-separator"; separator.setAttribute("role", "separator"); separator.setAttribute("aria-label", label); const time = document.createElement("time"); time.dateTime = new Date(parsedTimestamp).toISOString(); const splitAt = label.lastIndexOf(" "); if (splitAt > 0 && splitAt < label.length - 1) { const dateLabel = document.createElement("span"); dateLabel.className = "date-label"; dateLabel.textContent = label.slice(0, splitAt); const timeLabel = document.createElement("span"); timeLabel.className = "date-time"; timeLabel.textContent = label.slice(splitAt + 1); time.append(dateLabel, " ", timeLabel); } else time.textContent = label; separator.append(time); $("output").append(separator); state.lastDateSeparatorTimestamp = parsedTimestamp; } } state.lastRenderedDateKey = messageDateKey(parsedTimestamp); state.lastRenderedTimestamp = parsedTimestamp; state.lastRenderedRole = role; if (role === "user") state.hasRenderedUser = true; } function turnDividerLabel(status, durationMs) { const duration = elapsedDuration(durationMs); const normalized = normalizeActivityStatus(status, "completed"); if (normalized === "interrupted") return duration ? uiWithRaw("你在 ", "You stopped after ", duration, " 后停止了", "") : uiText("你停止了工作", "You stopped working"); if (normalized === "failed") return duration ? uiWithRaw("执行失败 · ", "Action failed · ", duration) : uiText("执行失败", "Action failed"); if (normalized === "inProgress") { const visible = elapsedDuration(durationMs); return visible ? uiWithRaw("用时 ", "Worked for ", visible) : uiText("正在处理", "Working"); } return duration ? uiWithRaw("用时 ", "Worked for ", duration) : uiText("已完成", "Completed"); } // A worked-for disclosure only has meaning when the turn owns at least one // concrete activity row. Keep this cleanup centralized so a transient // status-only row cannot leave an empty divider behind after it is retired. function removeEmptyTurnDivider(turnId) { const key = String(turnId || ""); if (!key) return; const output = $("output"); if (!output) return; const hasActivity = [...output.querySelectorAll(".message.activity")] .some((entry) => entry.dataset.turnId === key); if (hasActivity) return; const divider = state.turnDividers.get(key) || [...output.querySelectorAll(".turn-divider")] .find((entry) => entry.dataset.turnId === key) || null; if (divider?.parentNode) divider.remove(); state.turnDividers.delete(key); } function retireActivity(activity) { if (!activity) return; const key = String(activity.key || ""); const turnId = String(activity.turnId || ""); if (key && state.activities.get(key) === activity) state.activities.delete(key); if (key) state.commandDisclosure.delete(key); if (state.liveActivityKey === key) state.liveActivityKey = null; if (state.activeAssistantActivityKey === key) { state.activeAssistantBody = null; state.activeAssistantStream = null; state.activeAssistantText = ""; state.activeAssistantActivityKey = null; } if (activity.article?.parentNode) activity.article.remove(); removeEmptyTurnDivider(turnId); stopActivityTimerIfIdle(); updateScrollToBottom($("output")); } function retireStatusOnlyActivities(turnId = "") { const key = String(turnId || ""); for (const activity of [...state.activities.values()]) { if (activity.statusOnly !== true || activity.concrete === true) continue; if (key && activity.turnId && activity.turnId !== key) continue; retireActivity(activity); } } function appendTurnDivider(turnId, status = "completed", durationMs, beforeArticle = null, options = {}) { const key = String(turnId || `anonymous-${state.activitySequence}`); const output = $("output"); const autoPosition = beforeArticle === null; const turnEntries = [...output.querySelectorAll(".message")] .filter((entry) => entry.dataset.turnId === key); // The official worked-for disclosure owns the activity portion of a turn. // A final assistant answer remains visible below the disclosure; commentary // and concrete work rows are the entries that collapse underneath it. const firstTurnActivity = turnEntries.find((entry) => entry.classList.contains("activity")) || null; // Do not manufacture an empty worked-for row for a final-only turn. This // can happen when completion metadata arrives before any item lifecycle // event, or when a transient status row has just been retired. if (!firstTurnActivity) { removeEmptyTurnDivider(key); return null; } const firstTurnContent = firstTurnActivity || turnEntries.find((entry) => !entry.classList.contains("user")) || null; if (firstTurnContent) beforeArticle = firstTurnContent; else if (autoPosition) beforeArticle = turnEntries.at(-1) || null; let divider = state.turnDividers.get(key); if (!divider) { divider = [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) || null; } if (!divider) { divider = document.createElement("div"); divider.className = "turn-divider"; divider.dataset.turnId = key; const button = document.createElement("button"); button.type = "button"; button.className = "turn-divider-toggle"; const initialStatus = normalizeActivityStatus(status, "completed"); const rememberedExpansion = state.turnExpansion.get(key); const initialExpanded = rememberedExpansion !== undefined ? rememberedExpansion : options.defaultExpanded === true || initialStatus === "inProgress"; if (rememberedExpansion !== undefined || options.defaultExpanded === true) { button.dataset.userToggled = rememberedExpansion !== undefined ? "true" : "false"; } button.setAttribute("aria-expanded", String(initialExpanded)); const label = document.createElement("span"); label.className = "turn-divider-label"; const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); icon.setAttribute("viewBox", "0 0 16 16"); icon.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "m6 3 5 5-5 5"); icon.append(path); button.append(label, icon); const rule = document.createElement("div"); rule.className = "turn-divider-rule"; divider.append(button, rule); button.addEventListener("click", () => { const expanded = button.getAttribute("aria-expanded") === "true"; const next = !expanded; // Hydration may choose an expanded latest turn for parity with the // official panel. Once the reader explicitly toggles it, preserve // that choice across subsequent structured snapshots. button.dataset.userToggled = "true"; state.turnExpansion.set(key, next); button.setAttribute("aria-expanded", String(next)); setTurnActivityVisibility(key, next); if (next) { const firstVisibleEntry = [...output.querySelectorAll(".message.activity")] .filter((entry) => entry.dataset.turnId === key) .find((entry) => !entry.classList.contains("turn-collapsed")); if (firstVisibleEntry) scheduleTimelineReveal(firstVisibleEntry, 280); } }); if (beforeArticle && beforeArticle.parentNode === output) output.insertBefore(divider, beforeArticle); else output.append(divider); state.turnDividers.set(key, divider); } else if (beforeArticle === false && divider.parentNode === output && output.lastElementChild !== divider) { // An additional activity can arrive after an already-created live // divider. Explicit `false` means place the divider at the current tail. output.append(divider); } else if (beforeArticle && beforeArticle.parentNode === output && divider !== beforeArticle) { // Completion can arrive after the final assistant article. Move the // existing disclosure instead of creating a second one at the tail. output.insertBefore(divider, beforeArticle); } const label = divider.querySelector(".turn-divider-label"); if (durationMs !== null && durationMs !== undefined && finiteNumber(durationMs) !== null) { divider.dataset.durationMs = String(Math.max(0, Number(durationMs))); } const storedDuration = finiteNumber(durationMs, divider.dataset.durationMs); const liveDuration = durationMs === null || durationMs === undefined ? key === state.turnId && state.turnWorkStartedAt !== null ? Math.max(0, Date.now() - state.turnWorkStartedAt) : storedDuration : storedDuration; if (label) label.textContent = turnDividerLabel(status, liveDuration); const normalizedStatus = normalizeActivityStatus(status, "completed"); divider.dataset.status = normalizedStatus; const toggle = divider.querySelector(".turn-divider-toggle"); if (options.defaultExpanded === true && toggle?.dataset.userToggled !== "true") { toggle.dataset.defaultExpanded = "true"; } if (normalizedStatus === "inProgress" && toggle?.dataset.userToggled !== "true") { toggle.setAttribute("aria-expanded", "true"); } const rememberedExpansion = state.turnExpansion.get(key); // The latest completed turn is expanded on first render in the official // panel. A remembered click always wins, including an explicit collapse. const expanded = rememberedExpansion !== undefined ? rememberedExpansion : toggle?.dataset.userToggled === "true" ? toggle.getAttribute("aria-expanded") === "true" : normalizedStatus === "inProgress" || toggle?.dataset.defaultExpanded === "true"; setTurnActivityVisibility(key, expanded); return divider; } function appendCompletedTurnDivider(turnId, status = "completed", durationMs) { const key = String(turnId || ""); if (!key) return null; const output = $("output"); if (!output) return null; const hasTurnMessage = [...output.querySelectorAll(".message")] .some((article) => article.dataset.turnId === key); if (!hasTurnMessage) return null; const hasTurnActivity = [...output.querySelectorAll(".message.activity")] .some((article) => article.dataset.turnId === key); // A final assistant article by itself is not a worked-for group. The // official UI shows its duration in the assistant block without an empty // disclosure row above it. if (!hasTurnActivity) return null; const firstContent = [...output.querySelectorAll(".message")] .find((entry) => entry.dataset.turnId === key && entry.classList.contains("activity")) || [...output.querySelectorAll(".message")] .find((entry) => entry.dataset.turnId === key && !entry.classList.contains("user")) || null; return appendTurnDivider(key, status, durationMs, firstContent); } // Structured snapshots can append bookkeeping or collaboration items after // the final assistant item. Reconcile the outer worked-for boundary after // hydration so every activity row remains in the same disclosure group. function reconcileTurnDividers() { const output = $("output"); if (!output) return; const turnIds = new Set( [...output.querySelectorAll(".message.activity")] .map((article) => article.dataset.turnId) .filter(Boolean), ); for (const key of turnIds) { const activities = [...output.querySelectorAll(".message.activity")] .filter((article) => article.dataset.turnId === key); if (!activities.length) continue; let divider = state.turnDividers.get(key) || [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) || null; const finalAssistant = [...output.querySelectorAll(".message")] .filter((article) => article.dataset.turnId === key && !article.classList.contains("activity") && article.dataset.kind === "assistant") .at(-1) || null; const activeTurn = key === state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus); if (!divider) { divider = appendTurnDivider(key, activeTurn ? "inProgress" : "completed", state.workedDurationMs, activities[0]); } else { if (activities[0].parentNode === output && divider.parentNode === output && divider !== activities[0]) { output.insertBefore(divider, activities[0]); } const toggle = divider.querySelector(".turn-divider-toggle"); if (toggle && toggle.dataset.userToggled !== "true") { const rememberedExpansion = state.turnExpansion.get(key); toggle.setAttribute("aria-expanded", String(rememberedExpansion !== undefined ? rememberedExpansion : activeTurn)); } const status = activeTurn ? "inProgress" : normalizeActivityStatus(divider.dataset.status, "completed"); divider.dataset.status = status; setTurnActivityVisibility(key, toggle?.getAttribute("aria-expanded") === "true"); } // Keep a divider before the activity group even when a final answer was // rendered first. The answer itself intentionally stays outside it. if (divider && activities[0].parentNode === output && divider.nextSibling !== activities[0]) { output.insertBefore(divider, activities[0]); } if (finalAssistant && divider) divider.dataset.hasFinalAssistant = "true"; if (finalAssistant) { // Keep trailing bookkeeping/collaboration rows inside the same outer // group. Moving only rows that currently follow the final response // preserves the chronological order of the already-correct prefix. const trailing = activities.filter((activity) => ( activity.compareDocumentPosition(finalAssistant) & Node.DOCUMENT_POSITION_PRECEDING )); for (const activity of trailing) output.insertBefore(activity, finalAssistant); // The moves above can carry the first activity past the existing // disclosure. Re-anchor it after every move so the worked-for button // always remains the first node in the activity group. if (activities[0].parentNode === output && divider.parentNode === output) { output.insertBefore(divider, activities[0]); } } } } function expandLatestTurnActivity() { const output = $("output"); if (!output) return; const dividers = [...output.querySelectorAll(".turn-divider")]; const divider = dividers.at(-1); // The latest worked-for group is expanded after a completed snapshot so // the reader can see the activity stream immediately. Older groups stay // compact, and an explicit user toggle always wins. if (!divider) return; const toggle = divider.querySelector(".turn-divider-toggle"); if (!toggle || toggle.dataset.userToggled === "true") return; const key = divider.dataset.turnId || ""; if (!key || ![...output.querySelectorAll(".message.activity")].some((entry) => entry.dataset.turnId === key)) return; const rememberedExpansion = state.turnExpansion.get(key); const shouldExpand = rememberedExpansion !== undefined ? rememberedExpansion : divider.dataset.status === "inProgress" || divider === dividers.at(-1); if (!shouldExpand) return; if (toggle.getAttribute("aria-expanded") === "true") return; toggle.setAttribute("aria-expanded", "true"); setTurnActivityVisibility(key, true); } function setTurnActivityVisibility(turnId, expanded) { const key = String(turnId || ""); if (!key) return; const output = $("output"); if (!output) return; const divider = state.turnDividers.get(key) || [...output.querySelectorAll(".turn-divider")].find((entry) => entry.dataset.turnId === key) || null; // Keep the final assistant response and its timestamp/actions mounted. // Only rows explicitly marked as activity participate in the outer turn // disclosure, matching the official conversation-blocks split. const activities = [...output.querySelectorAll(".message.activity")] .filter((entry) => entry.dataset.turnId === key); if (!activities.length) return; const knownState = activities.some((activity) => activity.dataset.turnExpanded !== undefined); if (!knownState && !expanded) { for (const activity of activities) { activity.dataset.turnExpanded = "false"; animateActivityArticle(activity, false, { immediate: true }); } return; } if (divider || activities[0]) preserveTimelineAnchor(divider || activities[0], 280); for (const activity of activities) { const previous = activity.dataset.turnExpanded === "true"; activity.dataset.turnExpanded = String(Boolean(expanded)); // The outer worked-for disclosure controls every non-user entry. Each // nested command/reasoning disclosure keeps its own state, matching the // official panel where opening a completed turn does not open every // terminal body at once. animateActivityArticle(activity, expanded, { immediate: !knownState && !previous, fromHeight: expanded && !previous ? 0 : undefined, preserve: false, }); } if (expanded) revealTerminalOutputs(key); } function revealTerminalOutputs(turnId) { const key = String(turnId || ""); if (!key) return; requestAnimationFrame(() => { const output = $("output"); if (!output) return; for (const activity of output.querySelectorAll(".message.activity")) { if (activity.dataset.turnId !== key) continue; const terminal = activity.querySelector(".terminal-output"); if (!terminal || terminal.scrollHeight <= terminal.clientHeight) continue; // Hidden turn sections have no layout while hydrated. Once revealed, // show the newest terminal lines just like the official reverse list. terminal.scrollTop = terminal.scrollHeight; updateTerminalOutputFade(terminal); } }); } function ensureLiveTurnDivider(turnId) { const key = String(turnId || ""); if (!key) return null; const output = $("output"); const entries = [...output.querySelectorAll(".message")] .filter((entry) => entry.dataset.turnId === key); const firstContent = entries.find((entry) => entry.classList.contains("activity")) || entries.find((entry) => !entry.classList.contains("user")) || null; if (firstContent) return appendTurnDivider(key, "inProgress", null, firstContent); return appendTurnDivider(key, "inProgress", null, false); } const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value)); function finiteNumber(...values) { for (const value of values) { if (value === null || value === undefined || value === "" || typeof value === "boolean") continue; const number = Number(value); if (Number.isFinite(number)) return number; } return null; } function timestampMs(...values) { let value = null; for (const candidate of values) { if (candidate === null || candidate === undefined || candidate === "" || typeof candidate === "boolean") continue; if (typeof candidate === "string" && candidate.trim() && !/^\d+(?:\.\d+)?$/.test(candidate.trim())) { const parsed = Date.parse(candidate); if (Number.isFinite(parsed)) { value = parsed; break; } } const numeric = Number(candidate); if (Number.isFinite(numeric)) { value = numeric; break; } } if (value === null || value <= 0) return null; // Accept ISO epoch seconds from older followers as well as the current // app-server's millisecond fields. return value < 100_000_000_000 ? value * 1_000 : value; } function workedDurationFor(value, fallback = null) { const source = isRecord(value) ? value : {}; const nestedTurn = isRecord(source.turn) ? source.turn : {}; const nestedStatus = isRecord(source.status) ? source.status : {}; const explicit = finiteNumber( source.workedDurationMs, source.workDurationMs, source.workedForMs, source.turnWorkedDurationMs, source.worked_for_ms, nestedTurn.workedDurationMs, nestedTurn.workDurationMs, nestedTurn.workedForMs, nestedStatus.workedDurationMs, nestedStatus.workDurationMs, ); if (explicit !== null) return Math.max(0, explicit); const started = timestampMs( source.firstTurnWorkItemStartedAtMs, source.workStartedAtMs, source.turnWorkStartedAtMs, nestedTurn.firstTurnWorkItemStartedAtMs, nestedTurn.workStartedAtMs, nestedTurn.turnStartedAtMs, ); const completed = timestampMs( source.finalAssistantStartedAtMs, source.workCompletedAtMs, nestedTurn.finalAssistantStartedAtMs, nestedTurn.workCompletedAtMs, source.completedAtMs, ); if (started !== null && completed !== null) return Math.max(0, completed - started); return finiteNumber(fallback); } function formatDuration(value) { const duration = finiteNumber(value); if (duration === null || duration < 0) return ""; if (uiLocale() === "en-US") { if (duration < 1_000) return `${Math.round(duration)}ms`; const totalSeconds = Math.floor(duration / 1_000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (!minutes) return `${seconds}s`; return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`; } if (duration < 1_000) return `${Math.round(duration)}毫秒`; const totalSeconds = Math.floor(duration / 1_000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (!minutes) return `${seconds}秒`; // The Chinese locale in the official panel uses the long form for one // minute and the compact form for longer durations. const minuteLabel = minutes === 1 ? "1分钟" : `${minutes}分`; return seconds ? `${minuteLabel}${seconds}秒` : minuteLabel; } // Working indicators in the official panel stay textual until a full // second has elapsed. This avoids the visually noisy "0ms"/"1ms" state on // every newly-created activity row while preserving precise durations once // an operation is complete. function elapsedDuration(value) { const duration = finiteNumber(value); return duration !== null && duration >= 1_000 ? formatDuration(duration) : ""; } function formatMessageTime(value) { const timestamp = timestampMs(value); if (timestamp === null) return ""; try { return new Intl.DateTimeFormat(uiLocale(), { hour: "2-digit", minute: "2-digit" }).format(new Date(timestamp)); } catch { return ""; } } function formatMessageDate(value, nowValue = Date.now()) { const timestamp = timestampMs(value); if (timestamp === null) return ""; try { const date = new Date(timestamp); const now = new Date(nowValue); // Match the official timestamp separator: compare calendar dates while // avoiding DST changes in the local timezone. const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); const nowDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); const dayDifference = Math.max(0, Math.round((nowDay - dateDay) / 86_400_000)); const locale = uiLocale(); const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(date); if (dayDifference <= 1) { try { const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-Math.max(dayDifference, 0), "day"); return `${relative} ${time}`; } catch { return `${t(dayDifference === 1 ? "昨天" : "今天")} ${time}`; } } if (dayDifference <= 7 && dayDifference > 0) { const weekday = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date); return `${weekday} ${time}`; } const datePart = dayDifference <= 365 ? new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", weekday: "short" }).format(date) : new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric" }).format(date); return `${datePart} ${time}`; } catch { return ""; } } function messageDateKey(value) { const timestamp = timestampMs(value); if (timestamp === null) return ""; const date = new Date(timestamp); return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } function normalizeActivityStatus(value, fallback = "inProgress") { const normalized = String(value ?? fallback).replace(/[\s_-]+/g, "").toLowerCase(); if (["inprogress", "running", "started", "active", "pending"].includes(normalized)) return "inProgress"; if (["completed", "complete", "success", "succeeded", "done"].includes(normalized)) return "completed"; if (["failed", "failure", "error"].includes(normalized)) return "failed"; if (["declined", "denied", "rejected"].includes(normalized)) return "declined"; if (["interrupted", "cancelled", "canceled", "aborted"].includes(normalized)) return "interrupted"; return fallback; } function activityStatusLabel(status) { if (status === "inProgress") return "进行中"; if (status === "completed") return "已完成"; if (status === "failed") return "失败"; if (status === "declined") return "已拒绝"; if (status === "interrupted") return "已中断"; return String(status || ""); } const isRunningActivity = (activity) => activity && activity.status === "inProgress"; function eventParams(payload) { if (!isRecord(payload)) return {}; return isRecord(payload.params) ? payload.params : payload; } function eventThreadId(payload) { const params = eventParams(payload); return payload?.threadId || params.threadId || params.thread?.id || params.turn?.threadId || ""; } function eventTurnId(payload) { const params = eventParams(payload); return payload?.turnId || params.turnId || params.turn?.id || params.item?.turnId || ""; } function itemFromPayload(payload) { const params = eventParams(payload); if (isRecord(params.item)) return params.item; if (isRecord(payload?.item)) return payload.item; return isRecord(params) && (params.type || params.kind) ? params : {}; } function normalizedItemType(item) { return String(item?.type || item?.kind || "").replace(/[\s/_.-]+/g, "").toLowerCase(); } function activityKindForItem(item) { const type = normalizedItemType(item); if (isReadActivity(item)) return "read"; if (!type) return ""; if (type.includes("subagent") || type.includes("collabagent")) return "subagent"; if (type.includes("filechange") || type.includes("patch") || type.includes("edit")) return "edit"; if (type.includes("reasoning") || type.includes("contextcompaction")) return "reasoning"; if (type.includes("plan") || type.includes("reviewmode")) return "plan"; if (type.includes("command") || type.includes("exec") || type.includes("process")) return "tool"; if (type.includes("tool") || type.includes("mcp") || type.includes("websearch") || type.includes("imageview")) return "tool"; if (type.includes("usermessage")) return "user"; if (type.includes("agentmessage") || type.includes("assistantmessage")) return "assistant"; return ""; } function activityLabelForItem(item, kind) { const type = normalizedItemType(item); if (kind === "subagent" || type.includes("subagent") || type.includes("collabagent")) { return firstString(item.displayName, item.agentPath, item.action) || "子代理"; } if (type.includes("contextcompaction")) return "整理上下文"; if (type.includes("websearch")) return "搜索"; if (type.includes("imageview")) return "查看图像"; if (type.includes("mcp")) return "MCP 工具"; if (type.includes("dynamictool")) return "工具"; if (kind === "edit") return "编辑文件"; if (kind === "read") return "读取文件"; if (kind === "plan") return "计划"; if (kind === "reasoning") return "思考"; if (kind === "commentary") return "工作说明"; if (kind === "tool") return "运行命令"; return "执行步骤"; } function historyActivitySummary(item, kind, status, duration) { const elapsed = duration ? ` · ${duration}` : ""; if (kind === "subagent") { const name = firstString(item.displayName, item.agentPath, item.agentThreadId ? `thread ${item.agentThreadId}` : "") || t("子代理"); const uiStatus = firstString(item.displayStatus, item.activityKind); if (status === "inProgress" || uiStatus === "active" || uiStatus === "updated") return `${name} ${t("已开始工作")}`; if (status === "failed") return `${name} ${t("失败")}`; if (status === "interrupted") return `${name} ${t("已中断")}`; return `${name} ${t("已完成")}`; } if (kind === "tool" || kind === "read") { const command = terminalCommandText(commandText(item)); const actionSource = String(item.label || "工具"); const action = t(actionSource); const path = firstString(...readPathList(item)); if (kind === "read" && status === "inProgress") return readSummaryLabel(item, status); if (kind === "read" && !command) { return readSummaryLabel(item, status); } if (kind === "read" && status !== "inProgress") { // Parsed read/search actions are coalesced by the official renderer // into one compact exploration row rather than a path plus a second // timed command row. if (command) return status === "failed" ? t("读取文件运行命令失败") : status === "interrupted" ? t("已停止读取文件运行命令") : t("已读取文件运行了命令"); return readSummaryLabel(item, status); } if (status === "inProgress") return command ? uiWithRaw("正在运行 ", "Running ", command) : uiLocale() === "en-US" ? `Running ${action}` : `正在${action}`; if (status === "failed") return command ? `${uiWithRaw("命令运行失败 · ", "Command failed · ", command)}${elapsed}` : `${action}${uiText("失败", " failed")}${elapsed}`; if (status === "interrupted") return command ? `${uiWithRaw("已停止 ", "Stopped ", command)}${elapsed}` : `${uiText("已停止", "Stopped ")}${action}${elapsed}`; if (command) return duration ? `${uiWithRaw("已在 ", "Ran ", command, " 内运行 ", " in ")}${duration}` : uiWithRaw("已运行 ", "Ran ", command); return uiLocale() === "en-US" ? `Ran ${action}${elapsed}` : `已${action}${elapsed}`; } if (kind === "edit") return status === "inProgress" ? t("正在编辑文件") : `${t("编辑了文件")}${elapsed}`; if (kind === "reasoning") return status === "inProgress" ? t("正在思考") : duration ? uiWithRaw("已思考 ", "Thought for ", duration) : t("已完成思考"); if (kind === "plan") return status === "inProgress" ? t("正在制定计划") : `${t("已完成计划")}${elapsed}`; if (kind === "commentary") { if (status === "inProgress") return t("正在处理"); return t("工作说明"); } return ""; } function textFromValue(value, depth = 0) { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { const parts = value.map((entry) => textFromValue(entry, depth + 1)).filter(Boolean); return parts.length ? parts.join("\n") : ""; } if (!isRecord(value) || depth > 3) return ""; for (const key of ["text", "value", "output", "stdout", "stderr", "delta", "summary", "message"]) { const text = textFromValue(value[key], depth + 1); if (text) return text; } return ""; } function displayValue(value) { const text = textFromValue(value); if (text) return text; if (value === undefined || value === null) return ""; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function commandText(item) { if (Array.isArray(item.command)) return item.command.map(String).join(" "); if (typeof item.command === "string") return item.command; if (typeof item.commandLine === "string") return item.commandLine; if (Array.isArray(item.commandActions)) { return item.commandActions .map((action) => isRecord(action) ? action.command || action.description || "" : "") .filter(Boolean) .join("\n"); } return ""; } function fileChangesText(changes) { if (!Array.isArray(changes)) return displayValue(changes); return changes.map((change) => { if (!isRecord(change)) return displayValue(change); const path = change.path || change.file || change.filePath || change.name || "文件"; const kind = change.kind || change.type || change.status || ""; const diff = displayValue(change.diff || change.patch || change.output || change.text); const heading = `${kind ? `[${kind}] ` : ""}${path}`; return diff ? `${heading}\n${diff}` : heading; }).filter(Boolean).join("\n\n"); } function planText(value) { const plan = Array.isArray(value) ? value : value?.plan || value?.steps; if (!Array.isArray(plan)) return displayValue(value?.text || value); return plan.map((entry) => { if (!isRecord(entry)) return `- ${displayValue(entry)}`; const status = normalizeActivityStatus(entry.status, "pending"); const marker = status === "completed" ? "[x]" : status === "inProgress" ? "[~]" : "[ ]"; const step = entry.step || entry.text || entry.title || entry.description || "步骤"; return `${marker} ${step}`; }).join("\n"); } function activityHeader(item, kind) { // Terminal activities render command/cwd as separate fields below. Keep // this helper for non-terminal activity kinds and future item types. return ""; } function activityOutput(item, kind) { if (kind === "reasoning") return displayValue(item.summary || item.content || item.text); if (kind === "plan") return planText(item.plan || item.steps || item.content || item.text); if (kind === "edit") return fileChangesText(item.changes || item.files || item.diff || item.patch || item.output || item.text); if (kind === "tool" || kind === "read") { if (kind === "read") { // Paths are rendered as their own compact exploration rows. Keep only // the actual file content here; adapter summaries such as // "已读取 ..." must not be repeated inside a Shell block. let value = displayValue(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr ?? item.result); const summary = firstString(item.text, item.summary); if (summary && value === summary) return ""; if (summary && value.startsWith(`${summary}\n`)) value = value.slice(summary.length + 1); return value; } return displayValue(item.aggregatedOutput ?? item.output ?? item.stdout ?? item.stderr ?? item.result ?? item.error ?? item.text); } return displayValue(item.text || item.content || item.output); } function activityKey(payload, item, kind, explicitKey) { if (explicitKey) return explicitKey; const params = eventParams(payload); const id = item.id ?? params.itemId ?? payload?.itemId; const threadId = eventThreadId(payload) || state.threadId || "thread"; const turnId = eventTurnId(payload) || state.turnId || "turn"; if (id !== undefined && id !== null) return `${threadId}:${turnId}:${kind}:${typeof id}:${String(id)}`; state.activitySequence += 1; return `${threadId}:${turnId}:${kind}:anonymous:${state.activitySequence}`; } function ensureActivity(key, config = {}) { const kind = config.kind || "reasoning"; let existing = state.activities.get(key); if (!existing) { const requestedItemId = config.itemId === undefined || config.itemId === null ? "" : String(config.itemId); const sameTurn = (entry) => entry.kind === kind && (!config.turnId || !entry.turnId || entry.turnId === String(config.turnId)); const entries = [...state.activities.entries()].reverse(); const exact = requestedItemId ? entries.find(([, entry]) => sameTurn(entry) && entry.itemId === requestedItemId) : null; // Status snapshots may arrive before the item lifecycle notification. // When the concrete item appears, adopt the still-running anonymous row // instead of appending a second "正在思考/读取/编辑" entry. const anonymous = entries.find(([, entry]) => sameTurn(entry) && entry.anonymous && isRunningActivity(entry)); const unkeyed = !requestedItemId ? entries.find(([, entry]) => sameTurn(entry) && !entry.itemId && isRunningActivity(entry)) : null; const match = exact || anonymous || unkeyed; if (match) { const [oldKey, candidate] = match; existing = candidate; if (oldKey !== key) { state.activities.delete(oldKey); existing.key = key; existing.article.dataset.activityKey = key; if (state.liveActivityKey === oldKey) state.liveActivityKey = key; if (state.activeAssistantActivityKey === oldKey) state.activeAssistantActivityKey = key; } if (requestedItemId && existing.anonymous) { existing.itemId = requestedItemId; existing.anonymous = false; } state.activities.set(key, existing); } } if (existing) { // A lifecycle event or real output upgrades a transient status row into // a concrete activity. Once upgraded, it must survive turn completion; // status-only rows are retired when the host reports a terminal state. if (config.concrete === true) { existing.concrete = true; existing.statusOnly = false; // Keep the anonymous marker for id-less lifecycle streams so the // terminal status path can still close them when the host omits an // explicit item/completed event. An identified item is authoritative. if (config.itemId !== undefined && config.itemId !== null && String(config.itemId)) { existing.anonymous = false; } } else if (config.statusOnly === true && existing.concrete !== true) { existing.statusOnly = true; } if (config.label) existing.label = config.label; if (config.itemId !== undefined) existing.itemId = String(config.itemId); if (config.command) existing.command = kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command); if (config.filePath !== undefined) existing.filePath = String(config.filePath || ""); if (config.cwd !== undefined) existing.cwd = String(config.cwd || ""); if (config.shellName) existing.shellName = String(config.shellName); if (config.agentThreadId !== undefined) existing.agentThreadId = String(config.agentThreadId || ""); if (config.displayName !== undefined) existing.displayName = String(config.displayName || ""); if (config.objective !== undefined) existing.objective = String(config.objective || ""); if (config.activityKind !== undefined) existing.activityKind = String(config.activityKind || ""); if (config.displayStatus !== undefined) existing.displayStatus = String(config.displayStatus || ""); if (config.model !== undefined) existing.model = String(config.model || ""); if (config.action !== undefined) existing.action = String(config.action || ""); if (config.prompt !== undefined) existing.prompt = String(config.prompt || ""); if (config.senderThreadId !== undefined) existing.senderThreadId = String(config.senderThreadId || ""); if (config.receiverThreadIds !== undefined) existing.receiverThreadIds = Array.isArray(config.receiverThreadIds) ? config.receiverThreadIds.map(String) : []; if (config.agentsStates !== undefined) existing.agentsStates = isRecord(config.agentsStates) ? config.agentsStates : {}; if (config.canInteract !== undefined) existing.canInteract = config.canInteract !== false; if (config.exitCode !== undefined) existing.exitCode = finiteNumber(config.exitCode); if (config.turnId) existing.turnId = String(config.turnId); if (config.startedAt) existing.startedAt = timestampMs(config.startedAt) || existing.startedAt; if (existing.agentThreadId) existing.article.dataset.agentThreadId = existing.agentThreadId; else delete existing.article.dataset.agentThreadId; if (kind === "tool" || kind === "read" || kind === "subagent" || kind === "commentary") renderActivityText(existing); refreshActivity(existing); if (existing.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)) { ensureLiveTurnDivider(existing.turnId); } return existing; } const role = kind === "commentary" ? "assistant" : kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : "system"; const messageKind = kind === "tool" || kind === "read" ? "tool" : kind === "reasoning" ? "reasoning" : kind === "plan" ? "plan" : kind; const message = appendMessage("", role, "activity", "", { kind: messageKind, label: config.label || "执行步骤", command: config.command ? (kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command)) : "", turnId: config.turnId || "", agentThreadId: config.agentThreadId || "", // Commentary is an assistant paragraph in the official transcript, not // a nested disclosure. The outer worked-for group still owns its layout. collapsible: kind !== "commentary", showActions: false, // Keep commentary visible while a turn is open; command/read/reasoning // bodies remain independently collapsible. open: config.open === true || kind === "commentary", }); if (!message) return null; const activity = { key, kind, role, messageKind, label: config.label || "执行步骤", command: config.command ? (kind === "tool" || kind === "read" ? terminalCommandText(config.command) : String(config.command)) : "", cwd: config.cwd ? String(config.cwd) : "", shellName: config.shellName ? String(config.shellName) : "Shell", agentThreadId: config.agentThreadId ? String(config.agentThreadId) : "", displayName: config.displayName ? String(config.displayName) : "", objective: config.objective ? String(config.objective) : "", activityKind: config.activityKind ? String(config.activityKind) : "", displayStatus: config.displayStatus ? String(config.displayStatus) : "", model: config.model ? String(config.model) : "", action: config.action ? String(config.action) : "", filePath: config.filePath ? String(config.filePath) : "", prompt: config.prompt ? String(config.prompt) : "", senderThreadId: config.senderThreadId ? String(config.senderThreadId) : "", receiverThreadIds: Array.isArray(config.receiverThreadIds) ? config.receiverThreadIds.map(String) : [], agentsStates: isRecord(config.agentsStates) ? config.agentsStates : {}, canInteract: config.canInteract !== false, exitCode: config.exitCode === undefined ? null : finiteNumber(config.exitCode), itemId: config.itemId === undefined ? "" : String(config.itemId), threadId: config.threadId || state.threadId || "", turnId: config.turnId || state.turnId || "", startedAt: timestampMs(config.startedAt) || Date.now(), finishedAt: null, durationMs: null, durationExplicit: false, status: normalizeActivityStatus(config.status, "inProgress"), headerText: "", outputText: "", anonymous: Boolean(config.anonymous), concrete: config.concrete === true, statusOnly: config.statusOnly === true, article: message.article, body: message.content, wrapper: message.wrapper, details: message.article.querySelector("details"), summary: message.article.querySelector("summary"), }; activity.article.dataset.activityKey = key; activity.article.dataset.activityKind = kind; if (activity.agentThreadId) activity.article.dataset.agentThreadId = activity.agentThreadId; state.activities.set(key, activity); while (state.activities.size > 500) { const removable = [...state.activities].find(([, entry]) => !isRunningActivity(entry)); if (!removable) break; state.activities.delete(removable[0]); } renderActivityText(activity); refreshActivity(activity); if (activity.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus)) { ensureLiveTurnDivider(activity.turnId); } ensureActivityTimer(); return activity; } function activityText(activity) { if (activity.kind === "tool" || activity.kind === "read") { const command = terminalCommandText(activity.command); const commandLine = command ? `$ ${command}` : ""; return [commandLine, activity.outputText].filter(Boolean).join("\n"); } if (activity.kind === "subagent") { const name = activity.displayName || activity.label || t("子代理"); const objective = activity.objective || activity.outputText; return [name, objective].filter(Boolean).join("\n"); } if (activity.headerText && activity.outputText) return `${activity.headerText}\n\n${activity.outputText}`; return activity.headerText || activity.outputText || ""; } function normalizeSubagentActionStatus(value) { const normalized = String(value || "").replace(/[\s_-]+/g, "").toLowerCase(); if (["pendinginit", "pending", "waiting"].includes(normalized)) return "waiting"; if (["running", "working", "active", "started", "interacted", "updated", "inprogress"].includes(normalized)) return "working"; if (["completed", "complete", "done", "interrupted", "shutdown"].includes(normalized)) return "done"; if (["errored", "error", "failed", "notfound"].includes(normalized)) return "failed"; return "waiting"; } function renderSubagentBody(activity) { const body = activity?.body; if (!body) return; body.replaceChildren(); body.classList.remove("terminal-body", "diff-body"); body.classList.add("subagent-body"); const prompt = firstString(activity.prompt, activity.objective, activity.outputText); if (prompt) { const promptNode = document.createElement("div"); promptNode.className = "subagent-prompt markdown-body"; renderMarkdown(promptNode, prompt); body.append(promptNode); } if (activity.model || activity.action) { const meta = document.createElement("div"); meta.className = "subagent-action-meta"; if (activity.action) { const action = document.createElement("span"); action.textContent = activity.action === "spawnAgent" ? t("启动子代理") : activity.action === "sendInput" ? t("发送输入") : activity.action === "resumeAgent" ? t("恢复子代理") : activity.action === "closeAgent" ? t("关闭子代理") : activity.action; meta.append(action); } if (activity.model) { const model = document.createElement("span"); model.className = "subagent-model"; model.textContent = activity.model; meta.append(model); } body.append(meta); } const states = isRecord(activity.agentsStates) ? activity.agentsStates : {}; const receiverIds = Array.isArray(activity.receiverThreadIds) ? activity.receiverThreadIds : []; const ids = [...new Set([...receiverIds, ...Object.keys(states)])].filter(Boolean); if (!ids.length) return; const rows = document.createElement("div"); rows.className = "subagent-action-rows"; for (const threadId of ids) { const raw = isRecord(states[threadId]) ? states[threadId] : {}; const status = normalizeSubagentActionStatus(raw.status); const row = document.createElement("div"); row.className = "subagent-action-row"; row.dataset.status = status; const icon = document.createElement("span"); icon.className = "subagent-action-icon"; icon.setAttribute("aria-hidden", "true"); const label = document.createElement("span"); label.className = "subagent-action-label"; label.textContent = threadId === activity.agentThreadId ? firstString(activity.displayName, threadId) : `thread ${threadId}`; const statusNode = document.createElement("span"); statusNode.className = "subagent-action-status"; statusNode.textContent = subagentStatusLabel(status); row.append(icon, label, statusNode); const message = firstString(raw.message, raw.statusMessage); if (message) { const note = document.createElement("div"); note.className = "subagent-action-note"; note.textContent = message; row.append(note); } rows.append(row); } body.append(rows); } function terminalOutputText(activity) { const value = String(activity?.outputText || ""); // A few older bridge payloads included an exit-code suffix in the output // string. Strip only that exact synthetic line; real command output stays // untouched. return normalizeTerminalOutput(value.replace(/\n?exit code:\s*-?\d+\s*$/i, "")); } function normalizeTerminalOutput(value) { // Commands often use carriage returns/backspaces for progress updates and // ANSI SGR sequences for color. Resolve the control characters before the // lightweight renderer turns the result into safe DOM nodes. const stripped = String(value || "") .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") .replace(/\x1b\[(?![0-9;]*m)[0-?]*[ -/]*[@-~]/g, ""); return stripped.replace(/\r\n/g, "\n").split("\n").map((line) => { const cells = []; let cursor = 0; for (const character of line) { if (character === "\r") { cursor = 0; continue; } if (character === "\b") { cursor = Math.max(0, cursor - 1); continue; } cells[cursor] = character; cursor += 1; } return cells.join(""); }).join("\n"); } function renderTerminalOutput(parent, value) { parent.replaceChildren(); const text = String(value || ""); const sgr = /\x1b\[([0-9;]*)m/g; let cursor = 0; const style = { fg: "", bg: "", bold: false, dim: false, italic: false, underline: false, strike: false }; const appendSegment = (segment) => { if (!segment) return; const classes = []; if (style.fg) classes.push(`ansi-${style.fg}-fg`); if (style.bg) classes.push(`ansi-${style.bg}-bg`); if (style.bold) classes.push("ansi-bold"); if (style.dim) classes.push("ansi-dim"); if (style.italic) classes.push("ansi-italic"); if (style.underline) classes.push("ansi-underline"); if (style.strike) classes.push("ansi-strikethrough"); if (!classes.length) parent.append(document.createTextNode(segment)); else { const span = document.createElement("span"); span.className = classes.join(" "); span.textContent = segment; parent.append(span); } }; const applySgr = (codes) => { const values = codes.length ? codes : [0]; for (const code of values) { if (code === 0) Object.assign(style, { fg: "", bg: "", bold: false, dim: false, italic: false, underline: false, strike: false }); else if (code === 1) style.bold = true; else if (code === 2) style.dim = true; else if (code === 3) style.italic = true; else if (code === 4) style.underline = true; else if (code === 9) style.strike = true; else if (code === 22) { style.bold = false; style.dim = false; } else if (code === 23) style.italic = false; else if (code === 24) style.underline = false; else if (code === 29) style.strike = false; else if (code === 39) style.fg = ""; else if (code === 49) style.bg = ""; else if (code >= 30 && code <= 37) style.fg = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"][code - 30]; else if (code >= 90 && code <= 97) style.fg = ["bright-black", "bright-red", "bright-green", "bright-yellow", "bright-blue", "bright-magenta", "bright-cyan", "bright-white"][code - 90]; else if (code >= 40 && code <= 47) style.bg = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"][code - 40]; else if (code >= 100 && code <= 107) style.bg = ["bright-black", "bright-red", "bright-green", "bright-yellow", "bright-blue", "bright-magenta", "bright-cyan", "bright-white"][code - 100]; } }; for (const match of text.matchAll(sgr)) { appendSegment(text.slice(cursor, match.index)); applySgr(match[1] ? match[1].split(";").map((part) => Number(part) || 0) : [0]); cursor = match.index + match[0].length; } appendSegment(text.slice(cursor)); } function updateTerminalOutputFade(output) { if (!output) return; const overflow = output.scrollHeight - output.clientHeight; output.dataset.fadeTop = String(output.scrollTop > 1); output.dataset.fadeBottom = String(overflow - output.scrollTop > 1); } function terminalStatusText(activity) { if (!activity) return ""; // File-read rows do not have a process exit code. The official renderer // closes them with the read summary, not a misleading "unknown" status. if (activity.kind === "read" && (activity.exitCode === null || activity.exitCode === undefined)) return ""; if (activity.status === "inProgress") return ""; if (activity.status === "interrupted") return t("已停止"); if (activity.status === "failed" || activity.status === "declined") { return activity.exitCode === null || activity.exitCode === undefined ? t("退出码 未知") : t(`退出码 ${activity.exitCode}`); } if (activity.status === "completed") { if (activity.exitCode === 0) return t("成功"); if (activity.exitCode !== null && activity.exitCode !== undefined) return t(`退出码 ${activity.exitCode}`); return t("退出码 未知"); } return ""; } function appendTerminalCheckIcon(parent) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.className.baseVal = "terminal-status-icon"; svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3"); svg.append(path); parent.append(svg); } function setTerminalActionIcon(button, kind = "copy") { if (!button) return; button.replaceChildren(); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); if (kind === "check") { const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", "m3.5 8.2 2.7 2.7 6.3-6.3"); svg.append(path); } else if (kind === "collapse" || kind === "expand") { const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", kind === "collapse" ? "m5 6 3 3 3-3" : "m5 10 3-3 3 3"); svg.append(path); } else { const back = document.createElementNS("http://www.w3.org/2000/svg", "path"); back.setAttribute("d", "M5.5 5.5V4c0-.8.7-1.5 1.5-1.5h5c.8 0 1.5.7 1.5 1.5v5c0 .8-.7 1.5-1.5 1.5h-1.5"); const front = document.createElementNS("http://www.w3.org/2000/svg", "rect"); front.setAttribute("x", "2.5"); front.setAttribute("y", "5.5"); front.setAttribute("width", "7.5"); front.setAttribute("height", "7.5"); front.setAttribute("rx", "1.2"); svg.append(back, front); } button.append(svg); } function createTerminalAction(kind, title) { const button = document.createElement("button"); button.type = "button"; button.className = "terminal-action"; button.title = t(title); button.setAttribute("aria-label", t(title)); setTerminalActionIcon(button, kind); return button; } async function copyTerminalValue(value, button) { try { await navigator.clipboard?.writeText(String(value || "")); setTerminalActionIcon(button, "check"); button.dataset.copied = "true"; setTimeout(() => { if (!button.isConnected) return; setTerminalActionIcon(button, "copy"); button.dataset.copied = "false"; }, 1_500); } catch { button.dataset.copied = "false"; } } function createReadPathIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.className.baseVal = "read-path-icon"; svg.setAttribute("viewBox", "0 0 16 16"); svg.setAttribute("aria-hidden", "true"); const folder = document.createElementNS("http://www.w3.org/2000/svg", "path"); folder.setAttribute("d", "M2.5 4.5h3l1.2 1.4h6.8v6.2a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1z"); const top = document.createElementNS("http://www.w3.org/2000/svg", "path"); top.setAttribute("d", "M2.5 4.5v-1h3l1.1 1h5.9"); svg.append(folder, top); return svg; } function renderReadBody(activity) { const body = activity?.body; if (!body) return; body.replaceChildren(); body.classList.remove("terminal-body", "diff-body", "markdown-body"); body.classList.add("read-body"); const paths = String(activity.filePath || "").split("\n").map((value) => value.trim()).filter(Boolean); if (paths.length) { const list = document.createElement("div"); list.className = "read-path-list"; for (const path of paths) { const row = document.createElement("div"); row.className = "read-path-row"; row.append(createReadPathIcon()); const value = document.createElement("span"); value.textContent = path; value.title = path; row.append(value); list.append(row); } body.append(list); } const output = terminalOutputText(activity); if (output) { const outputBlock = document.createElement("pre"); outputBlock.className = "read-output"; outputBlock.textContent = output; body.append(outputBlock); } if (!paths.length && !output) { const empty = document.createElement("span"); empty.className = "read-empty"; empty.textContent = t(activity.status === "inProgress" ? "正在读取文件" : "读取完成"); body.append(empty); } } function renderTerminalBody(activity) { const body = activity?.body; if (!body) return; if (activity.kind === "read" && !terminalCommandText(activity.command)) { renderReadBody(activity); return; } const previousOutput = body.querySelector(".terminal-output"); const previousScrollTop = previousOutput?.scrollTop || 0; const previousScrollLeft = previousOutput?.scrollLeft || 0; const previousAtBottom = previousOutput ? previousOutput.scrollHeight - previousOutput.scrollTop - previousOutput.clientHeight <= 2 : true; body.replaceChildren(); body.classList.add("terminal-body"); body.classList.remove("markdown-body", "diff-body"); const shell = document.createElement("div"); shell.className = "terminal-shell"; const command = terminalCommandText(activity.command); const output = terminalOutputText(activity); const shellHeader = document.createElement("div"); shellHeader.className = "terminal-shell-header"; const shellLabel = document.createElement("div"); shellLabel.className = "terminal-shell-label"; shellLabel.textContent = activity.shellName || "Shell"; if (activity.cwd) shellLabel.title = `cwd\\n${activity.cwd}`; // The official embedded shell uses a lightweight label row. The parent // activity disclosure owns collapse; command and output each expose their // own copy action on hover. shellHeader.append(shellLabel); shell.append(shellHeader); if (activity.kind === "read" && activity.filePath && !command) { const pathRow = document.createElement("div"); pathRow.className = "terminal-file-path"; const paths = activity.filePath.split("\n").filter(Boolean); pathRow.textContent = paths.length > 1 ? `${paths[0]} (+${paths.length - 1})` : paths[0]; pathRow.title = activity.filePath; shell.append(pathRow); } if (command) { const commandRow = document.createElement("div"); commandRow.className = "terminal-command-line"; const prompt = document.createElement("span"); prompt.className = "terminal-prompt"; prompt.textContent = "$"; const commandCode = document.createElement("code"); commandCode.textContent = command; const commandChevron = document.createElementNS("http://www.w3.org/2000/svg", "svg"); commandChevron.classList.add("terminal-command-chevron"); commandChevron.setAttribute("viewBox", "0 0 16 16"); commandChevron.setAttribute("aria-hidden", "true"); const commandChevronPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); commandChevronPath.setAttribute("d", "m4.5 6 3.5 3.5L11.5 6"); commandChevron.append(commandChevronPath); const commandExpanded = activity.commandExpanded === true || state.commandDisclosure.get(activity.key) === true; commandRow.dataset.expanded = String(commandExpanded); commandRow.setAttribute("role", "button"); commandRow.setAttribute("tabindex", "0"); commandRow.setAttribute("aria-expanded", String(commandExpanded)); commandRow.setAttribute("aria-label", `$ ${command}`); const toggleCommand = (event) => { if (event.target.closest(".terminal-action")) return; event.preventDefault(); const expanded = commandRow.dataset.expanded === "true"; const next = !expanded; activity.commandExpanded = next; if (activity.key) state.commandDisclosure.set(activity.key, next); preserveTimelineAnchor(commandRow, 210); animateCommandRow(commandRow, next); if (next) scheduleTimelineReveal(commandRow, 190); }; commandRow.addEventListener("click", toggleCommand); commandRow.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") toggleCommand(event); }); const copyCommand = createTerminalAction("copy", "复制命令"); copyCommand.classList.add("terminal-command-action"); copyCommand.addEventListener("click", (event) => { event.stopPropagation(); copyTerminalValue(command, copyCommand); }); commandRow.append(prompt, commandCode, commandChevron, copyCommand); shell.append(commandRow); } const outputWrap = document.createElement("div"); outputWrap.className = "terminal-output-wrap"; if (output) { const outputBlock = document.createElement("pre"); outputBlock.className = "terminal-output"; const outputContent = document.createElement("div"); outputContent.className = "terminal-output-content"; renderTerminalOutput(outputContent, output); outputBlock.append(outputContent); // Activity deltas rebuild the lightweight DOM. Preserve the reader's // position, while still following the tail when they were already at // the bottom of the terminal stream. outputBlock.scrollLeft = previousScrollLeft; requestAnimationFrame(() => { if (previousAtBottom) outputBlock.scrollTop = outputBlock.scrollHeight; else outputBlock.scrollTop = previousScrollTop; outputBlock.scrollLeft = previousScrollLeft; updateTerminalOutputFade(outputBlock); }); outputBlock.addEventListener("scroll", () => updateTerminalOutputFade(outputBlock), { passive: true }); const copyOutput = createTerminalAction("copy", "复制输出"); copyOutput.classList.add("terminal-output-action"); copyOutput.addEventListener("click", (event) => { event.stopPropagation(); copyTerminalValue(output, copyOutput); }); outputWrap.append(outputBlock, copyOutput); } else if (activity.status !== "inProgress") { const outputBlock = document.createElement("pre"); outputBlock.className = "terminal-output terminal-output-empty"; const outputContent = document.createElement("div"); outputContent.className = "terminal-output-content terminal-no-output"; outputContent.textContent = t("无输出"); outputBlock.append(outputContent); outputWrap.append(outputBlock); } shell.append(outputWrap); const statusText = terminalStatusText(activity); const footer = document.createElement("div"); footer.className = "terminal-footer"; footer.dataset.status = activity.status; if (statusText) { const status = document.createElement("span"); status.className = "terminal-status"; if (activity.status === "completed" && activity.exitCode === 0) appendTerminalCheckIcon(status); status.append(document.createTextNode(statusText)); footer.append(status); } shell.append(footer); body.append(shell); } function renderActivityText(activity) { const text = activityText(activity); const visibleText = text || t(isRunningActivity(activity) ? "等待输出…" : activityStatusLabel(activity.status)); if (activity.kind === "tool" || activity.kind === "read") renderTerminalBody(activity); else if (activity.kind === "subagent") renderSubagentBody(activity); else renderMessageBody(activity.body, visibleText, activity.role, "activity", activity.messageKind); activity.article.dataset.rawText = text; } function setActivityContent(activity, header, output, append = false) { if (!activity) return; if (header !== undefined && header !== null && String(header)) activity.headerText = String(header); if (output !== undefined && output !== null) { activity.outputText = append ? `${activity.outputText}${String(output)}` : String(output); } renderActivityText(activity); } function activityElapsed(activity) { if (activity.durationMs !== null) return Math.max(0, activity.durationMs); if (!activity.startedAt) return null; // A completed legacy row without an end marker has an unknown duration; // showing the current wall clock makes old work appear to keep running. if (!isRunningActivity(activity) && !activity.finishedAt) return null; const end = activity.finishedAt || Date.now(); return Math.max(0, end - activity.startedAt); } function refreshActivity(activity) { if (!activity?.summary) return; const elapsed = activityElapsed(activity); // The official activity rows only show an item duration when the owner // supplied one. A timestamp interval inferred while hydrating history is // useful for the turn clock, but should not become a misleading per-row // wall-clock label. const duration = activity.durationExplicit === true ? elapsedDuration(elapsed) : ""; const label = t(activity.label || "执行步骤"); const setSummary = (value) => setActivitySummary(activity.summary, value, activity.kind); // Recompute the summary in the active locale. Dynamic command/path/name // values are appended as raw strings by historyActivitySummary(). if (activity.kind === "subagent") { const name = activity.displayName || activity.label || t("子代理"); const statusText = activity.status === "inProgress" ? t("已开始工作") : activity.status === "completed" ? t("已完成") : activity.status === "failed" ? t("失败") : activity.status === "interrupted" ? t("已中断") : ""; setSubagentSummary(activity.summary, name, statusText); } else { const localizedSummary = historyActivitySummary(activity, activity.kind, activity.status, duration); if (localizedSummary) setSummary(localizedSummary); else if (activity.status === "inProgress") setSummary(label); else if (activity.status === "failed") setSummary(`${label} · ${t("失败")}`); else if (activity.status === "interrupted") setSummary(`${label} · ${t("已中断")}`); else setSummary(label); } activity.article.dataset.status = activity.status; } function finishActivity(activity, status = "completed", durationMs, finishedAt) { if (!activity) return; // `updateLiveActivity` may create a short-lived row before the host emits // an item lifecycle event. It is useful while the turn is live, but the // official transcript does not retain a blank "思考/编辑/读取" item once // the turn ends. A concrete lifecycle row (or a row that received real // output) has already cleared `statusOnly` and follows the normal path. if (activity.statusOnly === true && activity.concrete !== true) { retireActivity(activity); return; } activity.status = normalizeActivityStatus(status, "completed"); activity.finishedAt = timestampMs(finishedAt) || Date.now(); const explicitDuration = finiteNumber(durationMs); activity.durationExplicit = explicitDuration !== null; const inferredDuration = activity.startedAt === null ? null : Math.max(0, activity.finishedAt - activity.startedAt); activity.durationMs = explicitDuration === null ? inferredDuration : Math.max(0, explicitDuration); activity.article.classList.remove("streaming"); renderActivityText(activity); refreshActivity(activity); if (activity.details && (activity.kind === "reasoning" || activity.kind === "plan" || activity.kind === "subagent")) { setDetailsExpanded(activity.details, false); } if (state.activeAssistantActivityKey === activity.key) { state.activeAssistantBody = null; state.activeAssistantStream = null; state.activeAssistantText = ""; state.activeAssistantActivityKey = null; } stopActivityTimerIfIdle(); } function latestRunningActivity(kind, context = {}) { const itemId = context.itemId === undefined || context.itemId === null ? "" : String(context.itemId); const turnId = context.turnId || state.turnId || ""; const activities = [...state.activities.values()].reverse(); if (itemId) { const exact = activities.find((activity) => activity.itemId === itemId && isRunningActivity(activity)); if (exact) return exact; } const acceptedKinds = kind === "reasoning" ? new Set(["reasoning", "plan"]) : new Set([kind]); return activities.find((activity) => isRunningActivity(activity) && acceptedKinds.has(activity.kind) && (!turnId || !activity.turnId || activity.turnId === turnId)) || null; } function appendActivityChunk(activity, text) { if (!activity || !text) return; // A stream chunk is evidence of a real work item, even when the host // omitted its item id. It should not be mistaken for a status-only row at // turn completion. activity.concrete = true; activity.statusOnly = false; activity.status = "inProgress"; setActivityContent(activity, undefined, text, true); activity.article.classList.add("streaming"); if ((activity.kind === "reasoning" || activity.kind === "plan" || activity.kind === "commentary") && activity.details) { // Reasoning is the one activity the official transcript expands while // it is streaming; completed reasoning collapses again in finishActivity. setDetailsExpanded(activity.details, true); } refreshActivity(activity); ensureActivityTimer(); } function handleItemLifecycle(phase, payload) { const params = eventParams(payload); const item = itemFromPayload(payload); const kind = activityKindForItem(item); if (!kind || kind === "user" || kind === "assistant") return false; const itemId = item.id ?? params.itemId ?? payload?.itemId; const duration = finiteNumber(item.durationMs, params.durationMs); const finishedAt = timestampMs(item.completedAtMs, item.finishedAtMs, params.completedAtMs, params.emittedAtMs); const startedAt = timestampMs(item.startedAtMs, item.startedAt, params.startedAtMs) || (duration !== null && finishedAt ? finishedAt - duration : null); const fallbackStatus = phase === "completed" ? "completed" : "inProgress"; const activity = ensureActivity(activityKey(payload, item, kind), { kind, label: activityLabelForItem(item, kind), command: kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) : "", filePath: kind === "read" ? readPathList(item).join("\n") : "", cwd: (kind === "tool" || kind === "read") && typeof item.cwd === "string" ? item.cwd : "", shellName: (kind === "tool" || kind === "read") && typeof item.shellName === "string" ? item.shellName : "Shell", agentThreadId: firstString(item.agentThreadId, item.childThreadId, item.threadId), displayName: firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath), objective: firstString(item.objective, item.prompt, item.statusMessage, item.message), activityKind: firstString(item.activityKind, item.kind), displayStatus: firstString(item.displayStatus, item.status), model: firstString(item.model, item.modelId), action: firstString(item.action, item.tool), prompt: item.prompt === null ? "" : firstString(item.prompt), senderThreadId: firstString(item.senderThreadId), receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds.map(String) : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : [], agentsStates: isRecord(item.agentsStates) ? item.agentsStates : {}, canInteract: item.canInteract !== false, exitCode: kind === "tool" || kind === "read" ? finiteNumber(item.exitCode, item.exit_code) : undefined, itemId, threadId: eventThreadId(payload), turnId: eventTurnId(payload), startedAt, status: normalizeActivityStatus(item.status, fallbackStatus), concrete: true, anonymous: itemId === undefined || itemId === null, }); if (!activity) return true; if (kind === "tool" || kind === "read") { const command = terminalCommandText(commandText(item)); if (command) activity.command = command; if (kind === "read") activity.filePath = readPathList(item).join("\n") || activity.filePath; if (typeof item.cwd === "string") activity.cwd = item.cwd; if (typeof item.shellName === "string" && item.shellName) activity.shellName = item.shellName; const exitCode = finiteNumber(item.exitCode, item.exit_code); if (exitCode !== null) activity.exitCode = exitCode; } if (kind === "subagent") { activity.agentThreadId = firstString(item.agentThreadId, item.childThreadId, item.threadId, activity.agentThreadId); activity.displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, activity.displayName); activity.objective = firstString(item.objective, item.prompt, item.statusMessage, item.message, activity.objective); activity.activityKind = firstString(item.activityKind, item.kind, activity.activityKind); activity.displayStatus = firstString(item.displayStatus, item.status, activity.displayStatus); activity.model = firstString(item.model, item.modelId, activity.model); activity.action = firstString(item.action, item.tool, activity.action); activity.prompt = item.prompt === null ? "" : firstString(item.prompt, activity.prompt); activity.senderThreadId = firstString(item.senderThreadId, activity.senderThreadId); if (Array.isArray(item.receiverThreadIds)) activity.receiverThreadIds = item.receiverThreadIds.map(String); else if (Array.isArray(item.receiverThreads)) activity.receiverThreadIds = item.receiverThreads.map(String); if (isRecord(item.agentsStates)) activity.agentsStates = item.agentsStates; if (item.canInteract !== undefined) activity.canInteract = item.canInteract !== false; if (activity.agentThreadId) activity.article.dataset.agentThreadId = activity.agentThreadId; else delete activity.article.dataset.agentThreadId; } const header = activityHeader(item, kind); const output = activityOutput(item, kind); if (header || output) setActivityContent(activity, header, output); if (phase === "completed") { const status = normalizeActivityStatus(item.status, item.error ? "failed" : "completed"); finishActivity(activity, status, duration, finishedAt); } else { activity.status = normalizeActivityStatus(item.status, "inProgress"); refreshActivity(activity); } return true; } function handlePlanUpdate(payload) { const params = eventParams(payload); const steps = Array.isArray(params.plan) ? params.plan : Array.isArray(params.steps) ? params.steps : null; const text = planText(steps || params.text || params); if (!text) return false; const threadId = eventThreadId(payload) || state.threadId || "thread"; const turnId = eventTurnId(payload) || state.turnId || "turn"; const activity = ensureActivity(`plan:${threadId}:${turnId}`, { kind: "plan", label: "计划", threadId, turnId, status: "inProgress", concrete: true, }); setActivityContent(activity, "", text); const statuses = (steps || []).map((step) => normalizeActivityStatus(step?.status, "pending")); if (statuses.length && statuses.every((status) => status === "completed")) finishActivity(activity, "completed"); else refreshActivity(activity); return true; } function handleDiffUpdate(payload) { const params = eventParams(payload); const diff = fileChangesText(params.changes || params.diff || params.patch || params.delta || params.text || params.output); if (!diff) return false; const threadId = eventThreadId(payload) || state.threadId || "thread"; const turnId = eventTurnId(payload) || state.turnId || "turn"; const activity = ensureActivity(`diff:${threadId}:${turnId}`, { kind: "edit", label: "文件变更", threadId, turnId, status: "inProgress", concrete: true, }); setActivityContent(activity, "", diff); refreshActivity(activity); return true; } function finishActivitiesForTurn(turnId, status) { for (const activity of state.activities.values()) { if (!isRunningActivity(activity)) continue; if (turnId && activity.turnId && activity.turnId !== turnId) continue; finishActivity(activity, status); } } function turnStatusLabel(status) { if (status === "waiting") return t("等待授权"); if (status === "generating") return t("正在生成"); if (status === "interrupted") return t("已中断"); if (status === "failed") return t("失败"); if (status === "completed") return t("已完成"); return t("正在工作"); } function statusActivityLabel(activity, flags = []) { const normalized = String(activity || "").replace(/[\s-]+/g, "_").toLowerCase(); if (normalized === "waiting_approval" || normalized === "waiting_for_approval" || flags.some((flag) => /approval|permission/.test(String(flag)))) return t("等待授权"); if (normalized === "waiting_input" || normalized === "waiting_for_user_input" || flags.some((flag) => /user.?input/.test(String(flag)))) return t("正在等待你的回答"); if (normalized === "thinking" || normalized === "reasoning") return t("正在思考"); if (normalized === "editing" || normalized === "edit") return t("正在编辑文件"); if (normalized === "reading" || normalized === "reading_file" || normalized === "file_read") return t("正在读取文件"); if (normalized === "running" || normalized === "running_command" || normalized === "tool") return t("正在运行命令"); if (normalized === "searching" || normalized === "searching_web") return t("正在搜索网页"); if (normalized === "responding" || normalized === "generating") return t("正在生成"); if (normalized === "failed") return t("执行失败"); if (normalized === "interrupted") return t("已中断"); if (normalized === "completed") return t("已完成"); return normalized && normalized !== "idle" ? t("处理中") : ""; } function updateLiveActivity(activity, startedAt, durationMs, flags = [], turnId = "") { const element = $("liveActivity"); if (!element) return; const normalized = String(activity || "idle").replace(/[\s-]+/g, "_").toLowerCase(); const terminal = ["idle", "ready", "completed", "failed", "interrupted", "cancelled", "canceled"].includes(normalized); state.currentActivity = normalized; state.currentActivityStartedAt = timestampMs(startedAt) || state.currentActivityStartedAt; state.currentActivityDurationMs = finiteNumber(durationMs); state.currentActivityTurnId = turnId || state.currentActivityTurnId || ""; if (terminal) { const live = state.liveActivityKey ? state.activities.get(state.liveActivityKey) : null; // Concrete lifecycle rows are finalized by item/turn completion events. // A status transition may only close an anonymous streaming fallback; // closing a concrete row here can race the owner snapshot and erase its // command/edit label before the real completion payload arrives. if (live?.anonymous && isRunningActivity(live)) { finishActivity(live, normalized === "failed" ? "failed" : normalized === "interrupted" ? "interrupted" : "completed", durationMs); } state.liveActivityKey = null; element.hidden = true; element.dataset.activity = normalized; element.dataset.active = "false"; return; } const label = statusActivityLabel(normalized, flags); if (!label) { element.hidden = true; return; } // The live status node remains available to assistive technology, while // the visible transcript is the source of truth for work-in-progress rows. element.hidden = false; element.dataset.activity = normalized; element.dataset.active = "true"; const labelElement = element.querySelector(".activity-label"); const elapsedElement = element.querySelector(".activity-elapsed"); const activeTranscript = latestRunningActivity( normalized === "thinking" || normalized === "reasoning" ? "reasoning" : normalized === "editing" || normalized === "edit" ? "edit" : normalized === "reading" || normalized === "reading_file" || normalized === "file_read" ? "read" : normalized === "searching" || normalized === "searching_web" ? "tool" : normalized === "running" || normalized === "running_command" || normalized === "tool" ? "tool" : "", { turnId: turnId || state.turnId || "" }, ); let visibleLabel = label; if (activeTranscript) { if (activeTranscript.kind === "tool" && activeTranscript.command) { visibleLabel = uiWithRaw("正在运行 ", "Running ", terminalCommandText(activeTranscript.command)); } else if (activeTranscript.kind === "edit") visibleLabel = t("正在编辑文件"); else if (activeTranscript.kind === "read") visibleLabel = t("正在读取文件"); else if (activeTranscript.kind === "reasoning") visibleLabel = t("正在思考"); else if (activeTranscript.label) visibleLabel = activeTranscript.label; } if (labelElement) labelElement.textContent = visibleLabel; const elapsed = state.currentActivityStartedAt === null ? state.currentActivityDurationMs : Math.max(0, Date.now() - state.currentActivityStartedAt); if (elapsedElement) elapsedElement.textContent = elapsedDuration(elapsed); // Status snapshots are projections of the current turn, not work items. // Bind to an existing concrete lifecycle row when possible. For the few // builds that publish a status before its item, the fallback below creates // a transient, explicitly `statusOnly` row that is removed at completion; // waiting approvals and ordinary status ticks never become history items. const effectiveTurnId = turnId || state.turnId || ""; const transcriptKind = normalized === "thinking" || normalized === "reasoning" ? "reasoning" : normalized === "editing" || normalized === "edit" ? "edit" : normalized === "reading" || normalized === "reading_file" || normalized === "file_read" ? "read" : normalized === "running" || normalized === "running_command" || normalized === "tool" ? "tool" : normalized === "searching" || normalized === "searching_web" ? "tool" : ""; let transcript = transcriptKind ? latestRunningActivity(transcriptKind, { turnId: effectiveTurnId }) : null; if (!transcript && effectiveTurnId && (transcriptKind === "reasoning" || transcriptKind === "edit" || transcriptKind === "read")) { // A few official builds publish the turn activity before the first // reasoning/diff item. Materialize one stable anonymous row so the // reader sees "正在思考"/"正在编辑文件" immediately; a later concrete // lifecycle item is merged into the same visual stream by kind/turn. const key = `status:${state.threadId || "thread"}:${effectiveTurnId}:${transcriptKind}`; transcript = ensureActivity(key, { kind: transcriptKind, label: transcriptKind === "reasoning" ? "思考" : transcriptKind === "edit" ? "编辑文件" : "读取文件", threadId: state.threadId, turnId: effectiveTurnId, startedAt: state.currentActivityStartedAt || state.turnStartedAt, status: "inProgress", anonymous: true, statusOnly: true, open: false, }); } if (transcript) { state.liveActivityKey = transcript.key; // Keep the concrete item label (command, tool name, or edit summary) // supplied by its lifecycle event. The global status must not overwrite // it with a generic "正在运行命令"/"正在思考" label. transcript.status = "inProgress"; if (state.currentActivityStartedAt && !transcript.startedAt) transcript.startedAt = state.currentActivityStartedAt; refreshActivity(transcript); if (effectiveTurnId) ensureLiveTurnDivider(effectiveTurnId); } else { state.liveActivityKey = null; } } function refreshTurnClock() { if (state.turnStartedAt === null) return; const elapsed = Math.max(0, Date.now() - state.turnStartedAt); const workElapsed = state.turnWorkStartedAt === null ? elapsed : Math.max(0, Date.now() - state.turnWorkStartedAt); const activity = state.currentActivity && state.currentActivity !== "idle" ? statusActivityLabel(state.currentActivity) : turnStatusLabel(state.turnStatus); const visibleElapsed = elapsedDuration(workElapsed); setConversationStatus(visibleElapsed ? `${activity} · ${visibleElapsed}` : activity, state.turnStatus === "waiting" ? "warning" : "active"); const divider = state.turnDividers.get(state.turnId) || [...$("output").querySelectorAll(".turn-divider")] .find((entry) => entry.dataset.turnId === state.turnId); if (divider?.dataset.status === "inProgress") { const label = divider.querySelector(".turn-divider-label"); if (label) label.textContent = turnDividerLabel("inProgress", workElapsed); } updateLiveActivity(state.currentActivity || "running", state.currentActivityStartedAt || state.turnStartedAt, null, [], state.currentActivityTurnId || state.turnId); } function startTurnClock(turnId, startedAt, elapsedMs) { const nextTurnId = turnId || state.turnId || ""; const explicitStartedAt = timestampMs(startedAt); const explicitElapsed = finiteNumber(elapsedMs); if (nextTurnId && state.turnId && nextTurnId !== state.turnId) { state.turnStartedAt = null; state.turnWorkStartedAt = null; state.finalAssistantStartedAt = null; state.workedDurationMs = null; state.currentActivityStartedAt = null; state.currentActivityDurationMs = null; state.currentActivity = "running"; } if (nextTurnId) state.turnId = nextTurnId; if (state.turnStartedAt === null) { state.turnStartedAt = explicitStartedAt || (explicitElapsed !== null ? Date.now() - Math.max(0, explicitElapsed) : Date.now()); } state.turnStatus = "active"; state.lastTurnDurationMs = null; state.lastWorkedDurationMs = null; if (state.turnWorkStartedAt === null) state.turnWorkStartedAt = state.turnStartedAt; const output = $("output"); if (output && [...output.querySelectorAll(".message.activity")] .some((article) => article.dataset.turnId === nextTurnId)) ensureLiveTurnDivider(nextTurnId); refreshTurnClock(); ensureActivityTimer(); } function stopTurnClock(status = "completed", durationMs, finishedAt, workedDurationMs) { // Some completion envelopes do not carry an item lifecycle event and do // not leave `liveActivityKey` pointing at the transient status row. Sweep // those rows here as a final safety net before clearing the turn clock. retireStatusOnlyActivities(state.turnId); const end = timestampMs(finishedAt) || Date.now(); const explicitDuration = finiteNumber(durationMs); const elapsed = explicitDuration !== null ? Math.max(0, explicitDuration) : state.turnStartedAt === null ? null : Math.max(0, end - state.turnStartedAt); // A hydrated terminal snapshot can arrive immediately after metadata. In // that case the metadata duration is authoritative even though the // terminal status envelope does not repeat it. const authoritativeWorkedDuration = finiteNumber( workedDurationMs, state.workedDurationMs, state.lastWorkedDurationMs, ); const worked = workedDurationFor({ workedDurationMs: authoritativeWorkedDuration, firstTurnWorkItemStartedAtMs: state.turnWorkStartedAt, finalAssistantStartedAtMs: state.finalAssistantStartedAt, completedAtMs: end, }, state.turnWorkStartedAt === null ? elapsed : Math.max(0, end - state.turnWorkStartedAt)); state.turnStartedAt = null; state.turnWorkStartedAt = null; state.turnStatus = status; state.lastTurnDurationMs = elapsed; state.lastWorkedDurationMs = worked; state.workedDurationMs = worked; const duration = elapsedDuration(worked ?? elapsed); setConversationStatus(`${turnStatusLabel(status)}${duration ? ` · ${duration}` : ""}`, status === "completed" ? "ready" : "warning"); state.currentActivity = status; state.currentActivityStartedAt = null; state.currentActivityDurationMs = worked ?? elapsed; updateLiveActivity(status, null, worked ?? elapsed, [], state.turnId); stopActivityTimerIfIdle(); } function turnStatusFromValue(value, fallback = "active") { const normalized = normalizeActivityStatus(value, fallback); if (normalized === "inProgress") return "active"; if (normalized === "declined" || normalized === "interrupted") return "interrupted"; return normalized; } function applyStatusSnapshot(payload, options = {}) { const status = isRecord(payload?.status) ? payload.status : {}; const metadata = isRecord(payload?.metadata) ? payload.metadata : {}; const rawTurnStatus = payload?.turnStatus ?? status.turnStatus ?? metadata.turnStatus ?? payload?.state; const activity = String(payload?.activity ?? status.activity ?? metadata.activity ?? "").toLowerCase(); const flags = [ ...(Array.isArray(payload?.activeFlags) ? payload.activeFlags : []), ...(Array.isArray(status.activeFlags) ? status.activeFlags : []), ...(Array.isArray(metadata.activeFlags) ? metadata.activeFlags : []), ].map((flag) => String(flag).toLowerCase()); const turnId = payload?.turnId || state.turnId || ""; const projectedWorkedDuration = workedDurationFor(payload, workedDurationFor(status, workedDurationFor(metadata, null))); const projectedWorkStart = timestampMs( payload?.firstTurnWorkItemStartedAtMs, payload?.workStartedAtMs, status.firstTurnWorkItemStartedAtMs, status.workStartedAtMs, metadata.firstTurnWorkItemStartedAtMs, metadata.workStartedAtMs, ); const projectedFinalAssistantStart = timestampMs( payload?.finalAssistantStartedAtMs, status.finalAssistantStartedAtMs, metadata.finalAssistantStartedAtMs, ); if (projectedWorkStart !== null) state.turnWorkStartedAt = projectedWorkStart; if (projectedFinalAssistantStart !== null) state.finalAssistantStartedAt = projectedFinalAssistantStart; if (projectedWorkedDuration !== null) state.workedDurationMs = projectedWorkedDuration; const rawNormalized = String(rawTurnStatus || "").replace(/[\s-]+/g, "_").toLowerCase(); const normalized = turnStatusFromValue(rawTurnStatus || (turnId ? "active" : "idle"), turnId ? "active" : "idle"); const terminal = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"].includes(rawNormalized) || ["completed", "failed", "interrupted"].includes(normalized); const effectiveActivity = activity || (flags.some((flag) => /approval|permission/.test(flag)) ? "waiting_approval" : turnId ? "running" : normalized); const explicitlyActive = !terminal && (Boolean(turnId) || ["active", "running", "working", "inprogress", "thinking", "reasoning", "editing", "edit", "reading", "readingfile", "fileread", "searching", "responding", "generating"].includes(activity.replace(/[\s_-]+/g, "")) || normalized === "active"); if (explicitlyActive) { startTurnClock( turnId, payload?.startedAtMs ?? status.startedAtMs ?? metadata.startedAtMs, payload?.elapsedMs ?? status.elapsedMs ?? metadata.elapsedMs, ); state.turnStatus = flags.some((flag) => /approval|permission|input|waiting/.test(flag)) ? "waiting" : "active"; state.currentActivity = effectiveActivity; updateLiveActivity(effectiveActivity, payload?.startedAtMs ?? status.startedAtMs ?? metadata.startedAtMs ?? state.turnStartedAt, payload?.durationMs ?? status.durationMs ?? metadata.durationMs, flags, turnId); refreshTurnClock(); return; } if (options.allowTerminal === false) return; const duration = payload?.durationMs ?? status.durationMs ?? metadata.durationMs; if (["completed", "interrupted", "failed"].includes(normalized) || terminal) { stopTurnClock(normalized, duration, payload?.completedAtMs ?? status.completedAtMs ?? metadata.completedAtMs, projectedWorkedDuration); } else if (state.turnStartedAt === null && options.showIdle !== false) setConversationStatus("ready"); } function snapshotStatusProjection(snapshot = {}, appState = {}) { const snapshotRecord = isRecord(snapshot) ? snapshot : {}; const stateRecord = isRecord(appState) ? appState : {}; const statusRecord = [ snapshotRecord.executionStatus, snapshotRecord.status, stateRecord.executionStatus, stateRecord.status, ].find(isRecord) || {}; const rawTurnStatus = firstDefined( snapshotRecord.turnStatus, statusRecord.turnStatus, statusRecord.status, stateRecord.turnStatus, typeof stateRecord.status === "string" ? stateRecord.status : undefined, ); const rawActivity = firstString( snapshotRecord.activity, statusRecord.activity, stateRecord.activity, ).toLowerCase(); const rawNormalized = String(rawTurnStatus || "").replace(/[\s-]+/g, "_").toLowerCase(); const fallbackStatus = rawActivity === "completed" || rawActivity === "complete" || rawActivity === "done" ? "completed" : rawActivity === "failed" || rawActivity === "error" ? "failed" : rawActivity === "interrupted" || rawActivity === "cancelled" || rawActivity === "canceled" ? "interrupted" : snapshotRecord.turnId || stateRecord.activeTurnId ? "active" : "idle"; const normalized = turnStatusFromValue(rawTurnStatus ?? fallbackStatus, fallbackStatus); const terminal = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"].includes(rawNormalized) || ["completed", "failed", "interrupted"].includes(normalized) || ["completed", "failed", "interrupted"].includes(rawActivity); const activeActivity = ["active", "running", "working", "inprogress", "thinking", "reasoning", "editing", "edit", "reading", "reading_file", "file_read", "searching", "searching_web", "responding", "generating"].includes(rawActivity.replace(/[\s-]+/g, "_")); const explicitTurnId = snapshotRecord.turnId !== undefined ? snapshotRecord.turnId : stateRecord.activeTurnId; return { normalized, terminal, hasActiveTurn: Boolean(explicitTurnId) || activeActivity || normalized === "active" || normalized === "waiting", durationMs: finiteNumber( snapshotRecord.durationMs, snapshotRecord.elapsedMs, statusRecord.durationMs, statusRecord.elapsedMs, stateRecord.durationMs, stateRecord.elapsedMs, ), workedDurationMs: workedDurationFor(snapshotRecord, workedDurationFor(statusRecord, workedDurationFor(stateRecord, null))), firstTurnWorkItemStartedAtMs: timestampMs( snapshotRecord.firstTurnWorkItemStartedAtMs, snapshotRecord.workStartedAtMs, statusRecord.firstTurnWorkItemStartedAtMs, statusRecord.workStartedAtMs, stateRecord.firstTurnWorkItemStartedAtMs, stateRecord.workStartedAtMs, ), finalAssistantStartedAtMs: timestampMs( snapshotRecord.finalAssistantStartedAtMs, statusRecord.finalAssistantStartedAtMs, stateRecord.finalAssistantStartedAtMs, ), completedAtMs: timestampMs(snapshotRecord.completedAtMs, statusRecord.completedAtMs, stateRecord.completedAtMs), }; } // A late authoritative snapshot can arrive after replayed lifecycle events. // Once it says there is no active turn, clear only the transient projection; // hydrated history remains intact and a locally queued user message is kept. function reconcileSnapshotTerminalState(snapshot = {}, appState = {}) { const projection = snapshotStatusProjection(snapshot, appState); if (!projection.terminal || projection.hasActiveTurn || state.pendingUserText) return; const hadTransientTurn = Boolean( state.turnId || state.turnStartedAt !== null || state.currentActivity === "active" || state.currentActivity === "running" || state.currentActivity === "working" || state.currentActivity === "thinking" || state.currentActivity === "editing" || state.currentActivity === "generating" || [...state.activities.values()].some(isRunningActivity), ); if (!hadTransientTurn) return; const finishedTurnId = state.turnId; finishAssistantStream(); finishActivitiesForTurn(finishedTurnId, projection.normalized); stopTurnClock(projection.normalized, projection.durationMs, projection.completedAtMs, projection.workedDurationMs); state.turnId = ""; state.currentActivityTurnId = ""; state.currentActivityStartedAt = null; state.currentActivityDurationMs = projection.durationMs; updateIds(); } function appendFileChangeChunk(payload, text) { if (!text) return false; const params = eventParams(payload); const threadId = eventThreadId(payload) || state.threadId || "thread"; const turnId = eventTurnId(payload) || state.turnId || "turn"; const itemId = params.itemId ?? payload?.itemId; let activity = latestRunningActivity("edit", { itemId, turnId }); if (!activity) { const key = itemId === undefined ? `diff:${threadId}:${turnId}` : `${threadId}:${turnId}:edit:${typeof itemId}:${String(itemId)}`; activity = ensureActivity(key, { kind: "edit", label: "编辑文件", itemId, threadId, turnId, status: "inProgress", concrete: true, }); } appendActivityChunk(activity, text); return true; } function refreshElapsedDisplays() { refreshTurnClock(); for (const activity of state.activities.values()) if (isRunningActivity(activity)) refreshActivity(activity); refreshSubagentElapsed(); if (state.currentActivity && state.currentActivity !== "idle") { updateLiveActivity( state.currentActivity, state.currentActivityStartedAt || state.turnStartedAt, state.currentActivityDurationMs, [], state.currentActivityTurnId || state.turnId, ); } stopActivityTimerIfIdle(); } function ensureActivityTimer() { if (state.activityTimer !== null) return; state.activityTimer = setInterval(refreshElapsedDisplays, 250); } function stopActivityTimerIfIdle() { if (state.turnStartedAt !== null || [...state.activities.values()].some(isRunningActivity)) return; if (state.activityTimer !== null) clearInterval(state.activityTimer); state.activityTimer = null; } function renderEmptyOutput() { const output = $("output"); output.replaceChildren(); output.dataset.outputTail = ""; state.activeAssistantBody = null; state.activeAssistantStream = null; state.activeAssistantText = ""; state.activeAssistantActivityKey = null; state.activities.clear(); state.turnDividers.clear(); state.liveActivityKey = null; state.pendingUserArticle = null; state.lastRenderedDateKey = ""; state.lastRenderedTimestamp = null; state.lastRenderedRole = ""; state.hasRenderedUser = false; state.lastDateSeparatorTimestamp = null; state.outputDistanceFromBottom = 0; state.structuredMessages = []; stopActivityTimerIfIdle(); } function appendOutput(text, tone) { if (!text) return; const visibleText = tone === "error" || tone === "meta" ? t(text) : text; if (tone === "meta") { setConversationStatus(String(visibleText)); return; } finishAssistantStream(); const role = tone === "error" ? "error" : tone === "meta" ? "system" : "assistant"; appendMessage(visibleText, role, tone || "text", tone === "meta" ? t("状态") : ""); } function setConversationStatus(text, tone = "ready") { const value = t(text || ""); const status = $("appState"); if (status) { status.textContent = value; status.dataset.tone = tone; } const hint = $("outputHint"); if (hint && value) hint.textContent = value; } function sessionCommandMethod(value) { return String(value || "").trim().replace(/\./g, "/").toLowerCase(); } function sessionErrorMessage(value, fallback = "会话操作失败") { const source = isRecord(value) ? value : {}; const error = isRecord(source.error) ? source.error : {}; const code = firstString(source.code, error.code).toLowerCase(); const message = value instanceof Error ? value.message : firstString(source.message, error.message, typeof value === "string" ? value : ""); const normalized = `${code} ${message}`.toLowerCase(); if (/app_not_ready|app-server is not ready|waiting_for_host/.test(normalized)) return "等待 VS Code 主机连接"; if (/host_unavailable|host_disconnected|vscode host is disconnected/.test(normalized)) return "VS Code 主机未连接"; if (/mode_switch_pending/.test(normalized)) return "正在切换控制模式"; if (/mode_busy|cannot switch control mode/.test(normalized)) return "当前任务或请求完成后才能切换控制模式"; if (/session_busy|turn_active|running turn|pending request/.test(normalized)) return "当前任务结束或请求处理后才能切换"; if (/timed out waiting for a snapshot|snapshot from vscode|找不到会话.*owner|no live vscode owner/.test(normalized)) { return "目标会话没有返回 VS Code 快照,请先在官方 Codex 面板打开它"; } if (/method_not_allowed/.test(normalized)) return "当前 relay 版本不支持此会话操作,请重启 relay"; return message || fallback; } function setSessionSwitchingVisual(switching) { const active = Boolean(switching); const panel = document.querySelector(".chat-panel"); if (panel) panel.dataset.sessionSwitching = String(active); const output = $("output"); if (output) output.setAttribute("aria-busy", String(active)); updateIds(); renderRequests(); } function sessionSwitchTargetTitle(threadId) { const id = String(threadId || ""); if (!id || !Array.isArray(state.sessions)) return ""; const entry = state.sessions.find((candidate) => sessionEntryId(candidate) === id); return entry ? sessionEntryTitle(entry) : ""; } function beginSessionSwitchContext(threadId, title = "") { const targetThreadId = String(threadId || ""); if (state.sessionSwitchContext) { if (!targetThreadId || state.sessionSwitchContext.targetThreadId === targetThreadId) { return state.sessionSwitchContext; } // A newer VS Code navigation can supersede an in-flight target. Retain // the original fallback, but reset both completion gates for the new // target so an acknowledgement/snapshot from the older route cannot // unlock the composer. state.sessionSwitchContext.targetThreadId = targetThreadId; state.sessionSwitchContext.targetTitle = String(title || sessionSwitchTargetTitle(targetThreadId) || ""); state.sessionSwitchContext.targetSnapshotReady = false; state.sessionSwitchContext.selectedAckReady = false; return state.sessionSwitchContext; } const titleNode = $("threadTitle"); const previousThreadId = String(state.threadId || state.syncedThreadId || ""); state.sessionSwitchContext = { previousThreadId, previousTitle: titleNode?.textContent || "Codex", targetThreadId, targetTitle: String(title || sessionSwitchTargetTitle(targetThreadId) || ""), targetSnapshotReady: false, selectedAckReady: false, }; return state.sessionSwitchContext; } function finishSessionSwitchContext() { state.sessionSwitchContext = null; setSessionSwitchingVisual(false); } function restoreSessionSwitchContext() { const context = state.sessionSwitchContext; if (!context) { state.sessionSwitching = false; state.sessionSelectCommandId = ""; setSessionSwitchingVisual(false); return; } const titleNode = $("threadTitle"); if (titleNode && context.previousTitle) titleNode.textContent = context.previousTitle; if (context.previousThreadId) { state.threadId = context.previousThreadId; state.sessionSelectedThreadId = context.previousThreadId; } else { state.sessionSelectedThreadId = ""; } state.sessionSwitching = false; state.sessionSelectCommandId = ""; finishSessionSwitchContext(); } function failSessionSwitch(value, fallback = "会话切换失败") { // A target projection may arrive before the adapter's final owner check. // A later failure must still restore the old routing context; treating the // early snapshot as success strands the browser on an unconfirmed target. restoreSessionSwitchContext(); return sessionErrorMessage(value, fallback); } function finishSessionSwitchIfReady() { const context = state.sessionSwitchContext; if (!context || !context.targetSnapshotReady || !context.selectedAckReady) return false; const targetThreadId = String(context.targetThreadId || ""); if (!targetThreadId || state.syncedThreadId !== targetThreadId) return false; const titleNode = $("threadTitle"); if (context.targetTitle && titleNode) titleNode.textContent = context.targetTitle; state.sessionSelectedThreadId = ""; state.sessionSwitching = false; state.sessionSelectCommandId = ""; finishSessionSwitchContext(); syncSessionActive(targetThreadId); return true; } function sessionEntryId(entry) { if (!isRecord(entry)) return ""; const thread = isRecord(entry.thread) ? entry.thread : {}; return firstString(entry.threadId, entry.conversationId, entry.conversation_id, entry.id, thread.threadId, thread.conversationId, thread.conversation_id, thread.id); } function sessionEntryTitle(entry) { if (!isRecord(entry)) return ""; const thread = isRecord(entry.thread) ? entry.thread : {}; return firstString(entry.title, entry.name, entry.preview, entry.firstUserMessage, entry.first_user_message, entry.threadTitle, entry.thread_name, thread.title, thread.name, thread.preview, thread.thread_name); } function sessionEntryCwd(entry) { if (!isRecord(entry)) return ""; const thread = isRecord(entry.thread) ? entry.thread : {}; return firstString(entry.cwd, entry.workspace, entry.workspacePath, entry.workspace_path, thread.cwd, thread.workspace, thread.workspacePath, thread.workspace_path); } function sessionEntryUpdatedAt(entry) { if (!isRecord(entry)) return null; const thread = isRecord(entry.thread) ? entry.thread : {}; // App-server history is ordered by recency_at. Older relay versions only // expose updatedAt, so keep those fields as a compatibility fallback. return timestampMs( entry.recencyAtMs, entry.recencyAt, entry.recency_at_ms, entry.recency_at, thread.recencyAtMs, thread.recencyAt, thread.recency_at_ms, thread.recency_at, entry.updatedAtMs, entry.updatedAt, entry.lastUpdatedAtMs, entry.lastUpdatedAt, entry.updated_at_ms, entry.updated_at, entry.last_updated_at, entry.mtime, entry.modifiedAt, thread.updatedAtMs, thread.updatedAt, thread.updated_at_ms, thread.updated_at, ); } function sessionEntryStatus(entry) { if (!isRecord(entry)) return { kind: "idle", label: "", active: false, attention: false, unread: false }; const thread = isRecord(entry.thread) ? entry.thread : {}; const nestedStatus = isRecord(entry.status) ? entry.status : {}; const nestedExecutionStatus = isRecord(entry.executionStatus) ? entry.executionStatus : {}; const nestedThreadStatus = isRecord(thread.status) ? thread.status : {}; let rawStatus = firstString( entry.activity, entry.activityStatus, entry.status, entry.executionStatus, entry.turnStatus, entry.threadRuntimeStatus, entry.thread_runtime_status, entry.runtimeStatus, entry.lastTurnStatus, entry.last_turn_status, entry.phase, entry.state, nestedStatus.activity, nestedStatus.type, nestedStatus.status, nestedStatus.kind, nestedExecutionStatus.activity, nestedExecutionStatus.type, nestedExecutionStatus.status, nestedExecutionStatus.kind, nestedThreadStatus.activity, nestedThreadStatus.type, nestedThreadStatus.status, nestedThreadStatus.kind, thread.threadRuntimeStatus, thread.thread_runtime_status, thread.turnStatus, thread.lastTurnStatus, thread.state, ).replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); if ((!rawStatus || rawStatus === "idle") && entry.active) { rawStatus = firstString( state.currentActivity !== "idle" ? state.currentActivity : "", state.turnStatus, state.turnId ? "working" : "", ).replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); } const unread = [ entry.hasUnreadTurn, entry.has_unread_turn, entry.unread, entry.isUnread, entry.needsAttention, entry.needs_attention, thread.hasUnreadTurn, thread.has_unread_turn, ].some((value) => value === true || value === 1 || ["true", "1", "yes"].includes(String(value || "").toLowerCase())); let kind = "idle"; if (entry.isApproval === true || /approval|permission|request_approval|awaiting_authorization|needs_authorization|requires_action/.test(rawStatus)) kind = "approval"; else if (entry.isWaiting === true || /needs?_?input|waiting_for_input|pending_input|pending|queued/.test(rawStatus)) kind = "waiting"; else if (entry.isEditing === true || /edit|apply_patch|file_change|writing/.test(rawStatus)) kind = "editing"; else if (entry.isThinking === true || /think|reason/.test(rawStatus)) kind = "thinking"; else if (entry.isWorking === true || entry.isRunning === true || /run|stream|working|in_progress|active|busy|generat/.test(rawStatus)) kind = "working"; else if (/error|fail|cancel|interrupt/.test(rawStatus)) kind = "error"; else if (unread) kind = "unread"; const labels = { approval: "等待授权", waiting: "等待输入", editing: "编辑中", thinking: "思考中", working: "进行中", error: "异常", unread: "未读", idle: "", }; return { kind, label: labels[kind] || "", active: ["approval", "waiting", "editing", "thinking", "working"].includes(kind), attention: ["approval", "waiting", "error", "unread"].includes(kind) || unread, unread, }; } function sessionEntrySearchText(entry) { if (!isRecord(entry)) return ""; const thread = isRecord(entry.thread) ? entry.thread : {}; const status = sessionEntryStatus(entry); return [ sessionEntryTitle(entry), sessionEntryCwd(entry), sessionEntryId(entry), status.label, entry.mode, entry.source, entry.threadSource, thread.mode, ].filter((value) => typeof value === "string" && value.trim()).join(" ").toLowerCase(); } function sessionOptionDomId(threadId) { try { return `session-option-${encodeURIComponent(String(threadId)).replace(/%/g, "_")}`; } catch { return `session-option-${String(threadId).replace(/[^a-z0-9_-]/gi, "_")}`; } } function sessionEntryIsActive(entry, activeId = "") { if (!isRecord(entry)) return false; const id = sessionEntryId(entry); return id === activeId || entry.active === true || entry.active === 1 || ["true", "1", "yes"].includes(String(entry.active || "").toLowerCase()); } function sessionEntryIsAvailable(entry) { return isRecord(entry) && entry.available !== false && entry.canAttach !== false && entry.attachable !== false; } function sessionIsSelectable(entry, activeId, canSwitch) { if (!isRecord(entry)) return false; const id = sessionEntryId(entry); const available = sessionEntryIsAvailable(entry); const current = sessionEntryIsActive(entry, activeId); return Boolean(id && available && !current && canSwitch && !state.sessionSwitching); } function filteredSessionEntries() { const source = Array.isArray(state.sessions) ? state.sessions .filter(isRecord) .filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry)) .map((entry, index) => ({ entry, index })) : []; source.sort((left, right) => { const rightTime = sessionEntryUpdatedAt(right.entry) ?? 0; const leftTime = sessionEntryUpdatedAt(left.entry) ?? 0; return rightTime - leftTime || left.index - right.index; }); const ordered = source.map(({ entry }) => entry); const query = String(state.sessionSearch || "").trim().toLowerCase(); return query ? ordered.filter((entry) => sessionEntrySearchText(entry).includes(query)) : ordered; } function sessionPathLabel(value) { const text = String(value || "").trim(); if (!text) return t("本地会话"); const parts = text.split(/[\\/]+/).filter(Boolean); return parts.length > 1 ? `${t("工作区")} · ${parts[parts.length - 1]}` : text; } function sessionTimeLabel(value) { const timestamp = timestampMs(value); if (timestamp === null) return ""; try { const date = new Date(timestamp); const now = new Date(); const day = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()); const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); const difference = Math.round((today - day) / 86_400_000); const locale = uiLocale(); const time = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(date); if (difference === 0) return time; if (difference === 1) return `${t("昨天")} ${time}`; if (difference > 1 && difference < 7) return `${new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date)} ${time}`; return `${new Intl.DateTimeFormat(locale, { month: "numeric", day: "numeric" }).format(date)} ${time}`; } catch { return ""; } } function renderSessionPicker() { const list = $("sessionList"); const status = $("sessionPickerStatus"); const picker = $("sessionPicker"); if (!list || !status || !picker) return; renderControlMode(); const activeId = String(state.threadId || state.syncedThreadId || ""); const canSwitch = sessionControlAllowed("sessionSelect") && state.appReady && state.ws?.readyState === WebSocket.OPEN && !state.turnId && state.requests.size === 0; const allSessions = Array.isArray(state.sessions) ? state.sessions.filter(isRecord).filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry)) : []; const sessions = filteredSessionEntries(); const query = String(state.sessionSearch || "").trim(); const selectableIds = sessions .filter((entry) => sessionIsSelectable(entry, activeId, canSwitch)) .map((entry) => sessionEntryId(entry)); if (!selectableIds.includes(state.sessionFocusedId)) { state.sessionFocusedId = selectableIds[0] || ""; } const searchInput = $("sessionSearchInput"); const searchClear = $("sessionSearchClear"); if (searchInput) { if (searchInput.value !== state.sessionSearch) searchInput.value = state.sessionSearch; searchInput.setAttribute("aria-expanded", String(state.sessionPickerOpen)); searchInput.setAttribute("aria-activedescendant", state.sessionFocusedId ? sessionOptionDomId(state.sessionFocusedId) : ""); } if (searchClear) searchClear.hidden = !query; list.replaceChildren(); picker.dataset.switching = String(state.sessionSwitching); list.setAttribute("aria-activedescendant", state.sessionFocusedId ? sessionOptionDomId(state.sessionFocusedId) : ""); status.dataset.tone = state.sessionListError ? "warning" : ""; if (state.sessionListLoading && !sessions.length) status.textContent = t("正在读取会话…"); else if (state.sessionSwitching) { const targetTitle = state.sessionSwitchContext?.targetTitle; status.textContent = targetTitle ? uiLocale() === "en-US" ? `Switching to “${targetTitle}”...` : `正在切换到「${targetTitle}」…` : t("正在切换会话…"); } else if (state.sessionListError) status.textContent = t(state.sessionListError); else if (!canSwitch && sessions.length > 1) status.textContent = t("当前任务结束或请求处理后才能切换"); else if (query) status.textContent = uiLocale() === "en-US" ? `${sessions.length}/${allSessions.length} conversations` : `${sessions.length}/${allSessions.length} 个会话`; else status.textContent = sessions.length ? (uiLocale() === "en-US" ? `${sessions.length} conversations` : `${sessions.length} 个会话`) : ""; if (!sessions.length && !state.sessionListLoading) { const empty = document.createElement("div"); empty.className = "session-list-empty"; const listError = String(state.sessionListError || ""); empty.textContent = listError === "等待 VS Code 主机连接" ? t("等待 VS Code 伴随扩展连接") : listError === "VS Code 主机未连接" ? t("VS Code 伴随扩展未连接") : listError === "等待 relay 连接" ? t("等待 relay 连接") : listError ? t("无法读取会话") : query ? t("没有匹配的会话") : t(state.attachMode ? "没有可附加的会话" : "没有可控制的会话"); list.append(empty); return; } for (const raw of sessions) { if (!isRecord(raw)) continue; const id = sessionEntryId(raw); if (!id) continue; const title = sessionEntryTitle(raw) || `${t("会话")} ${id.slice(0, 8)}`; const cwd = sessionEntryCwd(raw); const updated = sessionEntryUpdatedAt(raw); const current = sessionEntryIsActive(raw, activeId); const available = sessionEntryIsAvailable(raw); const statusInfo = sessionEntryStatus(raw); const switchingTarget = state.sessionSwitching && id === state.sessionSelectedThreadId; const selectable = sessionIsSelectable(raw, activeId, canSwitch); const option = document.createElement("button"); option.type = "button"; option.className = "session-option"; option.id = sessionOptionDomId(id); option.dataset.available = String(available); option.dataset.threadId = id; option.dataset.status = statusInfo.kind; option.dataset.unread = String(statusInfo.unread); option.dataset.switching = String(switchingTarget); option.dataset.focused = String(id === state.sessionFocusedId); option.setAttribute("role", "option"); option.setAttribute("aria-selected", String(current)); option.setAttribute("aria-disabled", String(!selectable)); option.disabled = !selectable; const titleNode = document.createElement("span"); titleNode.className = "session-option-title"; titleNode.textContent = title; titleNode.title = title; const timeNode = document.createElement("span"); timeNode.className = "session-option-time"; timeNode.textContent = sessionTimeLabel(updated); const metaNode = document.createElement("span"); metaNode.className = "session-option-meta"; metaNode.textContent = sessionPathLabel(cwd); metaNode.title = cwd || title; const stateNode = document.createElement("span"); stateNode.className = "session-option-state"; const stateLabels = []; if (switchingTarget) stateLabels.push(t("正在切换")); else if (!available) stateLabels.push(t("未打开")); else if (current) stateLabels.push(t("当前")); else if (!statusInfo.label) stateLabels.push(t("可切换")); if (available && statusInfo.label && (!current || statusInfo.active || statusInfo.attention)) stateLabels.push(t(statusInfo.label)); const stateDot = document.createElement("span"); stateDot.className = "session-status-dot"; stateDot.setAttribute("aria-hidden", "true"); stateNode.append(stateDot, document.createTextNode(stateLabels.join(" · "))); option.append(titleNode, timeNode, metaNode, stateNode); option.setAttribute("aria-label", `${title}, ${stateLabels.join(", ") || t("会话")}`); option.addEventListener("mouseenter", () => { if (option.disabled) return; state.sessionFocusedId = id; for (const peer of list.querySelectorAll(".session-option")) peer.dataset.focused = String(peer.dataset.threadId === id); list.setAttribute("aria-activedescendant", sessionOptionDomId(id)); }); if (!option.disabled) option.addEventListener("click", () => { state.sessionFocusedId = id; selectSession(id, title); }); list.append(option); } } function sessionPickerOptions() { return [...document.querySelectorAll("#sessionList .session-option")] .filter((option) => !option.disabled && option.dataset.threadId); } function setSessionFocus(threadId, { scroll = true } = {}) { const id = String(threadId || ""); state.sessionFocusedId = id; const list = $("sessionList"); if (!list) return; const options = list.querySelectorAll(".session-option"); for (const option of options) option.dataset.focused = String(option.dataset.threadId === id); list.setAttribute("aria-activedescendant", id ? sessionOptionDomId(id) : ""); $("sessionSearchInput")?.setAttribute("aria-activedescendant", id ? sessionOptionDomId(id) : ""); if (scroll) { const target = [...list.querySelectorAll(".session-option")] .find((option) => option.dataset.threadId === id); target?.scrollIntoView?.({ block: "nearest" }); } } function moveSessionFocus(delta) { const options = sessionPickerOptions(); if (!options.length) return false; let index = options.findIndex((option) => option.dataset.threadId === state.sessionFocusedId); if (index < 0) index = delta >= 0 ? -1 : 0; index = (index + delta + options.length) % options.length; setSessionFocus(options[index].dataset.threadId); return true; } function activateFocusedSession() { const id = state.sessionFocusedId; if (!id) return false; const entry = (Array.isArray(state.sessions) ? state.sessions : []) .find((candidate) => sessionEntryId(candidate) === id); if (!entry) return false; const activeId = String(state.threadId || state.syncedThreadId || ""); const canSwitch = !state.turnId && state.requests.size === 0; if (!sessionIsSelectable(entry, activeId, canSwitch)) return false; selectSession(id, sessionEntryTitle(entry)); return true; } function handleSessionPickerKeydown(event) { if (!state.sessionPickerOpen) return; if (event.key === "ArrowDown") { if (moveSessionFocus(1)) event.preventDefault(); return; } if (event.key === "ArrowUp") { if (moveSessionFocus(-1)) event.preventDefault(); return; } if (event.key === "Home") { const options = sessionPickerOptions(); if (options.length) { setSessionFocus(options[0].dataset.threadId); event.preventDefault(); } return; } if (event.key === "End") { const options = sessionPickerOptions(); if (options.length) { setSessionFocus(options[options.length - 1].dataset.threadId); event.preventDefault(); } return; } if (event.key === "Enter") { if (activateFocusedSession()) event.preventDefault(); return; } if (event.key === "Escape") { event.preventDefault(); setSessionPicker(false); } } function setSessionPicker(open) { const picker = $("sessionPicker"); const button = $("sessionPickerButton"); if (!picker || !button) return; const next = Boolean(open) && sessionControlAllowed("sessionList") && !state.modeSwitching; picker.hidden = !next; state.sessionPickerOpen = next; button.setAttribute("aria-expanded", String(next)); if (next) { state.sessionSearch = ""; state.sessionFocusedId = ""; $("panelMenu").hidden = true; $("detailsPopover").hidden = true; renderSessionPicker(); requestSessionList(); scheduleFrame(() => { if (state.sessionPickerOpen) $("sessionSearchInput")?.focus(); }); } else { state.sessionSearch = ""; state.sessionFocusedId = ""; const input = $("sessionSearchInput"); if (input) { input.value = ""; input.setAttribute("aria-expanded", "false"); input.setAttribute("aria-activedescendant", ""); } const clear = $("sessionSearchClear"); if (clear) clear.hidden = true; if (picker.contains(document.activeElement)) button.focus(); } } function openSessionHistory() { if (!sessionControlAllowed("sessionList")) return; setSessionPicker(true); } function requestControlMode(value) { const mode = normalizeControlMode(value); if (!mode || mode === state.controlMode || controlModeChangeBlocked()) return; closePopovers(); state.modeSwitching = true; state.requestedControlMode = mode; state.modeRequestEpoch = state.modeEpoch; setConversationStatus("正在切换控制模式", "active"); updateIds(); try { state.modeCommandId = command("control/mode/set", { mode }); } catch (error) { clearControlModeRequest(); setConversationStatus(error?.message || "控制模式切换失败", "warning"); updateIds(); } } function requestNewSession() { closePopovers(); if (!sessionControlAllowed("sessionCreate")) { setConversationStatus("同步模式下会话管理由 VS Code 控制", "warning"); return; } if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { setConversationStatus("等待 relay 连接", "warning"); return; } if (!state.appReady) { setConversationStatus("等待 VS Code 主机连接", "warning"); return; } if (state.role !== "operator" && state.role !== "owner" && state.role !== "host") { setConversationStatus("当前角色不能创建会话", "warning"); return; } if (state.newSessionCommandId) return; try { state.newSessionCommandId = command("session/new", {}); setConversationStatus("正在创建新会话", "active"); updateIds(); } catch (error) { state.newSessionCommandId = ""; setConversationStatus(sessionErrorMessage(error, "无法创建新会话"), "warning"); updateIds(); } } function requestSessionList() { if (!sessionControlAllowed("sessionList")) return; if (state.sessionListLoading) { renderSessionPicker(); return; } if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { state.sessionListError = "等待 relay 连接"; renderSessionPicker(); return; } if (!state.appReady) { state.sessionListError = "等待 VS Code 主机连接"; renderSessionPicker(); return; } state.sessionListLoading = true; state.sessionListError = ""; renderSessionPicker(); try { state.sessionListCommandId = command("session/list", {}); } catch (error) { state.sessionListLoading = false; state.sessionListError = sessionErrorMessage(error, "无法读取会话"); renderSessionPicker(); } } function selectSession(threadId, title = "") { const id = String(threadId || "").trim(); if (!sessionControlAllowed("sessionSelect")) return; if (!id || state.sessionSwitching || id === state.threadId) return; if (state.turnId || state.requests.size) { state.sessionListError = "当前任务仍在运行或等待授权,暂不能切换"; renderSessionPicker(); return; } const context = beginSessionSwitchContext(id, title); state.sessionSwitching = true; state.sessionListError = ""; setSessionSwitchingVisual(true); renderSessionPicker(); try { state.sessionSelectCommandId = command("session/select", { threadId: id }); setConversationStatus( context.targetTitle ? uiLocale() === "en-US" ? `Switching to “${context.targetTitle}”` : `正在切换到「${context.targetTitle}」` : t("正在切换会话"), "active", ); } catch (error) { restoreSessionSwitchContext(); state.sessionListError = sessionErrorMessage(error, "会话切换失败"); renderSessionPicker(); } } function applySessionListResult(result) { const body = isRecord(result) ? result : {}; const source = Array.isArray(result) ? result : Array.isArray(body.sessions) ? body.sessions : Array.isArray(body.threads) ? body.threads : []; const activeId = firstString(body.activeThreadId, body.threadId); state.sessions = source .filter((entry) => isRecord(entry)) .filter((entry) => !state.attachMode || sessionEntryIsAvailable(entry)) .map((entry) => ({ ...entry })); if (activeId) state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === activeId || entry.active === true })); state.sessionListLoading = false; state.sessionListCommandId = ""; state.sessionListError = ""; renderSessionPicker(); } function applySessionSelectResult(result) { const body = isRecord(result) ? result : {}; const selected = firstString(body.threadId, body.activeThreadId); state.sessionSelectedThreadId = selected; if (selected && state.sessions.length) { state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === selected })); } // A command result confirms only that the command completed. The switch // itself stays fenced until both the sequenced `session.selected` event // and the target's authoritative projection have independently arrived. // This also keeps automatic VS Code navigation and picker navigation on // the same completion path. if (state.sessionSwitchContext) { state.sessionSwitching = true; setSessionSwitchingVisual(true); const context = state.sessionSwitchContext; setConversationStatus( context.targetSnapshotReady ? "正在确认会话" : "正在加载会话", "active", ); } else { state.sessionSwitching = false; state.sessionSelectCommandId = ""; finishSessionSwitchContext(); } renderSessionPicker(); if (!state.sessionSwitching) { setSessionPicker(false); setConversationStatus("会话已切换", "ready"); } requestRefresh(); } function syncSessionActive(threadId) { const activeId = String(threadId || ""); if (!activeId || !Array.isArray(state.sessions) || !state.sessions.length) return; state.sessions = state.sessions.map((entry) => ({ ...entry, active: sessionEntryId(entry) === activeId, })); renderSessionPicker(); } function messageText(item) { if (!isRecord(item)) return ""; const direct = [item.text, item.content, item.message, item.summary, item.output] .find((value) => typeof value === "string" && value.length); if (direct) return direct; if (Array.isArray(item.content)) { return item.content.map((part) => { if (typeof part === "string") return part; if (!isRecord(part)) return ""; return part.text || part.content || part.value || ""; }).filter(Boolean).join("\n"); } // Official collaboration records are often state-only projections: they // carry an agent id/name and lifecycle kind, but no user-facing `text`. // Keep those rows in the transcript so the sub-agent disclosure and panel // can be hydrated from the same snapshot. if (historyKind(item) === "subagent") { const agent = isRecord(item.agent) ? item.agent : {}; const name = firstString( item.displayName, item.agentNickname, item.agentName, item.agentPath, agent.displayName, agent.name, item.agentThreadId ? `thread ${item.agentThreadId}` : "子代理", ); const objective = firstString( item.objective, item.prompt, item.statusMessage, item.description, agent.objective, agent.prompt, ); const action = firstString(item.action, item.tool, item.activityKind, agent.action); const rawStatus = firstString(item.displayStatus, item.status, item.state, agent.status); const normalizedStatus = normalizeSubagentStatus(rawStatus); const statusText = subagentStatusLabel(normalizedStatus); if (objective && name) return `${name}:${objective}`; if (action && name) return `${name} · ${action}`; if (name) return `${name} · ${statusText}`; return `子代理 · ${statusText}`; } if (isReadActivity(item)) { return firstString(...readPathList(item), commandText(item), "读取文件"); } if (/(?:command|exec|process|tool)/i.test(String(item.type || item.kind || ""))) { return firstString(commandText(item), "工具输出"); } const fallback = [item.reasoning, item.plan, item.steps, item.diff, item.patch, item.description] .map((value) => displayValue(value)) .find((value) => value); if (fallback) return fallback; return ""; } function historyKind(item) { const sourceKind = String(item?.kind || item?.type || "assistant").toLowerCase(); if (item?.uiType === "subagent-activity" || item?.uiType === "multi-agent-action" || sourceKind.includes("subagent") || sourceKind.includes("collabagent")) return "subagent"; if (item?.role === "user" || sourceKind.includes("user")) return "user"; if (isReadActivity(item)) return "read"; if (sourceKind.includes("edit") || sourceKind.includes("filechange") || sourceKind.includes("patch")) return "edit"; if (sourceKind.includes("tool") || sourceKind.includes("command") || sourceKind.includes("exec") || sourceKind.includes("process") || sourceKind.includes("websearch") || sourceKind.includes("mcp") || sourceKind.includes("imageview") || sourceKind.includes("generatedimage") || sourceKind.includes("dynamictool") || sourceKind.includes("permissionrequest") || sourceKind.includes("userinput")) return "tool"; if (sourceKind.includes("reasoning") || sourceKind.includes("contextcompaction") || sourceKind.includes("approvalreview")) return "reasoning"; if (sourceKind.includes("plan") || sourceKind.includes("todo")) return "plan"; return "assistant"; } function normalizedAssistantPhase(item) { return String(item?.phase || item?.messagePhase || "") .replace(/[\s-]+/g, "_") .toLowerCase(); } // Agent commentary is a work item in the official transcript. It belongs // inside the worked-for disclosure, while the final answer stays outside it. // Older snapshots omit `phase`, so use the last item in the turn as the // fallback final answer boundary. function isAssistantCommentary(item, index, turnKey, turnLastIndex) { if (historyKind(item) !== "assistant") return false; const phase = normalizedAssistantPhase(item); if (phase === "final_answer" || phase === "finalanswer") return false; if (phase === "commentary" || phase === "analysis" || phase === "reasoning" || phase === "thinking") return true; return turnLastIndex instanceof Map && turnLastIndex.get(turnKey) !== index; } function structuredTurnFinalAssistantIndexes(messages) { const result = new Map(); const explicit = new Set(); if (!Array.isArray(messages)) return result; messages.forEach((item, index) => { if (!isRecord(item) || historyKind(item) !== "assistant") return; const key = structuredMessageTurn(item, index); const phase = normalizedAssistantPhase(item); if (phase === "final_answer" || phase === "finalanswer") { result.set(key, index); explicit.add(key); } else if (!explicit.has(key)) { // A bookkeeping/sub-agent item can follow the answer in newer // snapshots. Choose the last assistant message, not the last item. result.set(key, index); } }); return result; } function structuredDisplayKind(item, index, turnKey, turnLastIndex) { const kind = historyKind(item); return kind === "assistant" && isAssistantCommentary(item, index, turnKey, turnLastIndex) ? "commentary" : kind; } function isCollapsibleKind(kind) { return kind === "tool" || kind === "read" || kind === "edit" || kind === "reasoning" || kind === "plan" || kind === "subagent" || kind === "commentary"; } function isReadActivity(item) { if (!isRecord(item)) return false; const type = normalizedItemType(item); const semantic = String(item.activityKind || item.uiType || item.operation || item.commandType || "") .replace(/[\s_./-]+/g, "") .toLowerCase(); const parsed = item.parsedCmd || item.parsedCommand || item.parsedCommandType; const parsedType = typeof parsed === "string" ? parsed : isRecord(parsed) ? firstString(parsed.type, parsed.kind, parsed.operation) : ""; const parsedNormalized = String(parsedType).replace(/[\s_./-]+/g, "").toLowerCase(); return semantic.includes("fileread") || semantic.includes("readfile") || semantic === "read" || type.includes("fileread") || type.includes("readfile") || type === "read" || parsedNormalized === "read" || parsedNormalized === "fileread"; } function readPathList(item) { if (!isRecord(item)) return []; const paths = []; const visit = (value) => { if (typeof value === "string" && value.trim()) paths.push(value.trim()); else if (Array.isArray(value)) value.forEach(visit); else if (isRecord(value)) visit(value.path ?? value.file ?? value.filePath ?? value.name); }; [item.path, item.file, item.filePath, item.filename, item.name, item.readPath, item.readPaths, item.files, item.paths].forEach(visit); return [...new Set(paths)]; } function readSummaryLabel(item, status, duration = "") { const paths = [...new Set(readPathList(item).flatMap((value) => String(value).split(/\r?\n/).map((part) => part.trim()).filter(Boolean)))]; const suffix = duration ? ` · ${duration}` : ""; if (status === "inProgress") return paths.length === 1 ? uiWithRaw("正在读取 ", "Reading ", paths[0]) : t("正在读取文件"); if (status === "failed") return paths.length === 1 ? `${uiWithRaw("读取失败 · ", "Failed to read ", paths[0])}${suffix}` : `${t("读取文件失败")}${suffix}`; if (status === "interrupted") return paths.length === 1 ? `${uiWithRaw("已停止读取 ", "Stopped reading ", paths[0])}${suffix}` : `${t("已停止读取文件")}${suffix}`; if (paths.length === 1) return `${uiWithRaw("已读取 ", "Read ", paths[0])}${suffix}`; if (paths.length > 1) { const count = String(paths.length); return `${uiText("已读取这些内容 · ", "Read these items · ")}${count}${uiText(" 个文件", " files")}${suffix}`; } return `${t("已读取文件")}${suffix}`; } function structuredMessageKey(item, index) { const id = item?.id ?? item?.itemId; if (id !== undefined && id !== null && String(id)) return `id:${String(id)}`; const turn = item?.turnId ? String(item.turnId) : "item"; return `turn:${turn}:${historyKind(item)}:${index}`; } function structuredMessageRole(item, kind) { return item.role === "user" || kind === "user" ? "user" : item.role === "tool" || kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : item.role === "reasoning" || kind === "reasoning" || kind === "plan" ? "system" : item.role === "error" ? "error" : "assistant"; } function structuredMessageStatus(item) { if (item.status !== undefined && item.status !== null && item.status !== "") { return normalizeActivityStatus(item.status, String(item.status)); } if (item.completed === true) return "completed"; if (item.completed === false) return "inProgress"; if (item.turnStatus !== undefined && item.turnStatus !== null && item.turnStatus !== "") { return normalizeActivityStatus(item.turnStatus, String(item.turnStatus)); } return ""; } function explicitStructuredTurnId(item) { if (!isRecord(item)) return ""; const nestedTurn = isRecord(item.turn) ? item.turn : {}; const value = [ item.turnId, item.conversationTurnId, item.conversation_turn_id, item.turn_id, nestedTurn.id, nestedTurn.turnId, ].find((candidate) => candidate !== undefined && candidate !== null && String(candidate).trim()); return value === undefined ? "" : String(value); } function deriveStructuredTurnKeys(messages) { if (!Array.isArray(messages)) return new Map(); const keys = new Map(); let anonymousNumber = 0; let currentKey = ""; let currentHasUser = false; let currentEnded = false; const makeAnonymousKey = () => `anonymous:${anonymousNumber++}`; messages.forEach((item, index) => { if (!isRecord(item)) return; const explicit = explicitStructuredTurnId(item); const kind = historyKind(item); if (explicit) { currentKey = explicit; currentHasUser = kind === "user"; currentEnded = false; keys.set(item, currentKey); return; } if (!currentKey || kind === "user" && (currentHasUser || currentEnded)) { currentKey = makeAnonymousKey(); currentHasUser = false; currentEnded = false; } if (kind === "user") currentHasUser = true; keys.set(item, currentKey); // A final-answer marker is the only reliable boundary in a legacy // projection. Do not use turnStatus here: adapters attach the terminal // turn status to every item in the turn. const phase = normalizedAssistantPhase(item); if (kind === "assistant" && (phase === "final_answer" || phase === "finalanswer")) currentEnded = true; // Keep the index in the map as a debugging fallback for primitive array // entries, while object identity remains the canonical lookup. if (index < 0) keys.set(item, currentKey); }); state.structuredTurnKeys = new WeakMap(); for (const [item, key] of keys) state.structuredTurnKeys.set(item, key); return keys; } function structuredMessageTurn(item, index) { const explicit = explicitStructuredTurnId(item); if (explicit) return explicit; if (isRecord(item)) { const derived = state.structuredTurnKeys?.get(item); if (derived) return derived; } return `item:${index}`; } function structuredActivityKey(item, kind, turnKey, index) { const itemId = item?.itemId ?? item?.id; const threadKey = item?.threadId || state.threadId || "thread"; if (itemId !== undefined && itemId !== null && String(itemId)) { return `${threadKey}:${turnKey || "turn"}:${kind}:${typeof itemId}:${String(itemId)}`; } return `structured:${structuredMessageKey(item, index)}`; } function structuredTurnWorkedDurations(messages) { const result = new Map(); const explicitKeys = new Set(); const starts = new Map(); const ends = new Map(); if (!Array.isArray(messages)) return result; deriveStructuredTurnKeys(messages); const finalAssistantIndexes = structuredTurnFinalAssistantIndexes(messages); messages.forEach((item, index) => { const key = structuredMessageTurn(item, index); const explicit = finiteNumber( item?.workedDurationMs, item?.workDurationMs, item?.workedForMs, item?.turnWorkedDurationMs, item?.workedFor?.durationMs, ); if (explicit !== null) { result.set(key, Math.max(0, explicit)); explicitKeys.add(key); } const workStart = timestampMs( item?.firstTurnWorkItemStartedAtMs, item?.workStartedAtMs, item?.turnWorkStartedAtMs, item?.turnStartedAtMs, item?.workedFor?.startedAtMs, ); if (workStart !== null && !starts.has(key)) starts.set(key, workStart); const kind = historyKind(item); if (kind !== "user") { const itemStart = timestampMs(item?.startedAtMs, item?.startedAt, item?.createdAtMs); if (itemStart !== null && kind !== "assistant") { // Activity timestamps are the closest equivalent to the official // first-turn-work-item marker in older follower snapshots. if (!starts.has(key)) starts.set(key, itemStart); } } const explicitAssistantStart = timestampMs( item?.finalAssistantStartedAtMs, item?.workedFor?.completedAtMs, ); // The official worked-for clock ends when the final assistant response // starts. Earlier assistant commentary is part of the work group and // must not extend the duration; this was the source of the recurring // three-to-five-second discrepancy in legacy snapshots. const isFinalAssistant = kind === "assistant" && finalAssistantIndexes.get(key) === index; const assistantStart = explicitAssistantStart !== null ? explicitAssistantStart : isFinalAssistant ? timestampMs(item?.startedAtMs, item?.startedAt, item?.createdAtMs) : null; if (assistantStart !== null) ends.set(key, Math.max(ends.get(key) || 0, assistantStart)); }); for (const [key, start] of starts) { if (result.has(key)) continue; const end = ends.get(key); if (end !== undefined && end >= start) result.set(key, end - start); } // Some older host snapshots keep the authoritative worked-for duration in // session metadata rather than repeating it on each projected item. Apply // it to the most recent turn only; explicit per-item values always win. const metadataDuration = finiteNumber(state.workedDurationMs, state.lastWorkedDurationMs); if (metadataDuration !== null && messages.length) { const keysInOrder = messages .filter(isRecord) .map((item, index) => structuredMessageTurn(item, index)); const target = state.turnId && keysInOrder.includes(state.turnId) ? state.turnId : keysInOrder.at(-1); // Session metadata is authoritative over timestamp inference, but an // explicit per-turn/item value from the host remains the strongest // signal. if (target && !explicitKeys.has(target)) result.set(target, Math.max(0, metadataDuration)); } // Older attach snapshots may expose the final-answer start separately // from the duration. Use it only for the corresponding most recent turn. const metadataFinal = timestampMs(state.finalAssistantStartedAt); if (metadataFinal !== null && messages.length) { const keyed = messages.filter(isRecord).map((item, index) => structuredMessageTurn(item, index)); const target = state.turnId && keyed.includes(state.turnId) ? state.turnId : keyed.at(-1); const start = target ? starts.get(target) : null; if (target && start !== undefined && !explicitKeys.has(target) && !result.has(target) && metadataFinal >= start) { result.set(target, metadataFinal - start); } } return result; } function structuredActivityTiming(messages, index, turnKey, status) { const item = Array.isArray(messages) ? messages[index] : null; if (!isRecord(item)) return { durationMs: null, finishedAt: null }; const explicitDuration = finiteNumber(item.durationMs, item.elapsedMs); const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs); const explicitFinishedAt = timestampMs(item.completedAtMs, item.completedAt, item.finishedAtMs, item.finishedAt); if (explicitDuration !== null) { return { durationMs: explicitDuration, finishedAt: explicitFinishedAt ?? (startedAt === null ? null : startedAt + explicitDuration), }; } if (explicitFinishedAt !== null && startedAt !== null) { return { durationMs: Math.max(0, explicitFinishedAt - startedAt), finishedAt: explicitFinishedAt }; } // Hydrated legacy rows sometimes expose only start times. The next item in // the same turn is the closest end marker (and matches the official // projection's item interval). Never infer an end for an explicitly live // row, and never let a later turn's timestamp inflate this activity. if (status !== "inProgress" && startedAt !== null && Array.isArray(messages)) { for (let cursor = index + 1; cursor < messages.length; cursor += 1) { const next = messages[cursor]; if (!isRecord(next)) continue; if (structuredMessageTurn(next, cursor) !== turnKey) break; const nextStartedAt = timestampMs(next.startedAtMs, next.startedAt, next.createdAtMs); if (nextStartedAt === null) continue; if (nextStartedAt >= startedAt) return { durationMs: nextStartedAt - startedAt, finishedAt: nextStartedAt, }; break; } } return { durationMs: null, finishedAt: explicitFinishedAt }; } function indexStructuredActivity(article, item, kind, turnKey, index, messages = null) { if (!article || !isCollapsibleKind(kind)) return null; const key = article.dataset.activityKey || structuredActivityKey(item, kind, turnKey, index); article.dataset.activityKey = key; article.dataset.activityKind = kind; const existing = state.activities.get(key); const turnStatus = normalizeActivityStatus(item.turnStatus, ""); const status = structuredMessageStatus(item) || (turnStatus === "inProgress" ? "inProgress" : "completed"); const timing = structuredActivityTiming(messages, index, turnKey, status); const durationMs = timing.durationMs; const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs); const activity = existing || { key, kind, role: kind === "commentary" ? "assistant" : kind === "tool" || kind === "read" || kind === "edit" || kind === "subagent" ? "tool" : "system", messageKind: kind === "tool" || kind === "read" ? "tool" : kind, label: item.label || activityLabelForItem(item, kind), command: kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) : commandText(item), filePath: kind === "read" ? readPathList(item).join("\n") : "", cwd: (kind === "tool" || kind === "read") && typeof item.cwd === "string" ? item.cwd : "", shellName: (kind === "tool" || kind === "read") && typeof item.shellName === "string" && item.shellName ? item.shellName : "Shell", agentThreadId: kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "", displayName: kind === "subagent" ? firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath) : "", objective: kind === "subagent" ? firstString(item.objective, item.prompt, item.statusMessage, item.message) : "", activityKind: kind === "subagent" ? firstString(item.activityKind, item.kind) : "", displayStatus: kind === "subagent" ? firstString(item.displayStatus, item.status) : "", model: kind === "subagent" ? firstString(item.model, item.modelId) : "", action: kind === "subagent" ? firstString(item.action, item.tool) : "", prompt: kind === "subagent" && item.prompt !== null ? firstString(item.prompt) : "", senderThreadId: kind === "subagent" ? firstString(item.senderThreadId) : "", receiverThreadIds: kind === "subagent" ? Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds.map(String) : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : [] : [], agentsStates: kind === "subagent" && isRecord(item.agentsStates) ? item.agentsStates : {}, canInteract: item.canInteract !== false, exitCode: kind === "tool" || kind === "read" ? finiteNumber(item.exitCode, item.exit_code) : null, itemId: item.itemId === undefined || item.itemId === null ? "" : String(item.itemId), threadId: item.threadId || state.threadId || "", turnId: turnKey || "", startedAt: startedAt || null, finishedAt: null, durationMs: null, durationExplicit: finiteNumber(item.durationMs, item.elapsedMs) !== null, status, headerText: "", outputText: "", anonymous: false, concrete: true, statusOnly: false, article, body: article.querySelector(".details-body") || article.querySelector(".message-body"), wrapper: article.querySelector(".message-content"), details: article.querySelector("details"), summary: article.querySelector("summary"), }; activity.key = key; activity.kind = kind; activity.messageKind = kind === "tool" || kind === "read" ? "tool" : kind; activity.itemId = item.itemId === undefined || item.itemId === null ? activity.itemId || "" : String(item.itemId); // Structured history is an authoritative concrete projection. If a live // status row occupied the same slot during reconnect, promote it here so // the next terminal update does not discard the hydrated item. activity.concrete = true; activity.statusOnly = false; activity.anonymous = false; activity.turnId = turnKey || activity.turnId || ""; activity.threadId = item.threadId || activity.threadId || state.threadId || ""; activity.label = item.label || activity.label || activityLabelForItem(item, kind); activity.command = kind === "tool" || kind === "read" ? terminalCommandText(commandText(item)) || activity.command || "" : commandText(item) || activity.command || ""; if (kind === "tool" || kind === "read") { if (kind === "read") activity.filePath = readPathList(item).join("\n") || activity.filePath; activity.cwd = typeof item.cwd === "string" ? item.cwd : activity.cwd || ""; activity.shellName = typeof item.shellName === "string" && item.shellName ? item.shellName : activity.shellName || "Shell"; activity.exitCode = item.exitCode === undefined || item.exitCode === null ? activity.exitCode ?? null : finiteNumber(item.exitCode, item.exit_code); } if (kind === "subagent") { activity.agentThreadId = firstString(item.agentThreadId, item.childThreadId, item.threadId, activity.agentThreadId); activity.displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, activity.displayName); activity.objective = firstString(item.objective, item.prompt, item.statusMessage, item.message, activity.objective); activity.activityKind = firstString(item.activityKind, item.kind, activity.activityKind); activity.displayStatus = firstString(item.displayStatus, item.status, activity.displayStatus); activity.model = firstString(item.model, item.modelId, activity.model); activity.action = firstString(item.action, item.tool, activity.action); if (item.prompt !== null && item.prompt !== undefined) activity.prompt = firstString(item.prompt, activity.prompt); activity.senderThreadId = firstString(item.senderThreadId, activity.senderThreadId); if (Array.isArray(item.receiverThreadIds)) activity.receiverThreadIds = item.receiverThreadIds.map(String); else if (Array.isArray(item.receiverThreads)) activity.receiverThreadIds = item.receiverThreads.map(String); if (isRecord(item.agentsStates)) activity.agentsStates = item.agentsStates; if (item.canInteract !== undefined) activity.canInteract = item.canInteract !== false; if (activity.agentThreadId) article.dataset.agentThreadId = activity.agentThreadId; else delete article.dataset.agentThreadId; } activity.startedAt = startedAt || activity.startedAt || null; if (durationMs !== null || !existing) activity.durationMs = durationMs; activity.durationExplicit = finiteNumber(item.durationMs, item.elapsedMs) !== null; activity.status = status; activity.finishedAt = timing.finishedAt || activity.finishedAt || null; activity.article = article; activity.body = article.querySelector(".details-body") || article.querySelector(".message-body"); activity.wrapper = article.querySelector(".message-content"); activity.details = article.querySelector("details"); activity.summary = article.querySelector("summary"); activity.outputText = kind === "tool" || kind === "read" ? activityOutput(item, kind) : messageText(item); state.activities.set(key, activity); // Hydrated rows may have been created before lifecycle metadata arrived. // Recompute the disclosure label from the normalized status/timestamps so // history and live updates cannot leave stale `0ms` or generic text behind. renderActivityText(activity); refreshActivity(activity); return activity; } function updateStructuredMessageArticle(article, item, index, turnLastIndex, turnHasActivity = new Set(), turnWorkedDurations = new Map()) { const itemText = messageText(item); if (!itemText) return false; const sourceKind = historyKind(item); const turnKey = structuredMessageTurn(item, index); const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex); const role = structuredMessageRole(item, kind); const status = structuredMessageStatus(item) || "completed"; const durationMs = item.durationMs ?? item.elapsedMs; const duration = elapsedDuration(durationMs); const body = article.querySelector(".message-body"); if (!body) return false; const details = article.querySelector("details"); const wasOpen = details ? isDetailsExpanded(details) : undefined; article.dataset.rawText = String(itemText); article.dataset.turnId = turnKey; article.dataset.kind = kind; if (item.itemId !== undefined && item.itemId !== null) article.dataset.itemId = String(item.itemId); if (item.itemType) article.dataset.itemType = String(item.itemType); const agentThreadId = kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : ""; if (agentThreadId) article.dataset.agentThreadId = agentThreadId; else delete article.dataset.agentThreadId; if (item.startedAtMs || item.completedAtMs) { const parsed = timestampMs(item.startedAtMs ?? item.completedAtMs); if (parsed !== null) article.dataset.timestamp = String(parsed); } if (status) article.dataset.status = status; const isActivity = isCollapsibleKind(kind); article.classList.toggle("streaming", status === "inProgress" && !isActivity); renderMessageBody(body, itemText, role, isActivity ? "activity" : "history", kind); if (details) { const summary = details.querySelector("summary"); const activitySummary = historyActivitySummary(item, kind, status, duration); if (summary && activitySummary) setActivitySummary(summary, activitySummary, kind); if (wasOpen !== undefined) setDetailsExpanded(details, wasOpen, { immediate: true, preserve: false }); } const finalAssistant = sourceKind === "assistant" && role === "assistant" && kind === "assistant" && (item.phase === "final_answer" || item.phase === "final-answer" || !isAssistantCommentary(item, index, turnKey, turnLastIndex)); if (finalAssistant && status !== "inProgress" && !article.classList.contains("streaming") && !article.querySelector(".message-actions")) { addMessageActions(article, true); } const terminalTurn = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"] .includes(String(item.turnStatus || "").replace(/[\s-]+/g, "_").toLowerCase()); const workedDuration = turnWorkedDurations.get(turnKey) ?? workedDurationFor(item, null); if (finalAssistant && (terminalTurn || workedDuration !== null || (durationMs !== undefined && status !== "inProgress"))) { appendTurnDivider(turnKey, item.turnStatus || status || "completed", workedDuration ?? durationMs, article); } return true; } function reconcileStructuredOutput(text, structuredMessages) { const output = $("output"); if (!output || !Array.isArray(structuredMessages) || !structuredMessages.length) return false; const articles = [...output.querySelectorAll(".message[data-structured-key]")]; if (articles.length !== structuredMessages.length) return false; const keys = structuredMessages.map((item, index) => structuredMessageKey(item, index)); if (articles.some((article, index) => article.dataset.structuredKey !== keys[index])) return false; const follow = shouldFollowOutput(output); const turnLastIndex = structuredTurnFinalAssistantIndexes(structuredMessages); const turnHasActivity = new Set(); const turnWorkedDurations = structuredTurnWorkedDurations(structuredMessages); structuredMessages.forEach((item, index) => { const turnKey = structuredMessageTurn(item, index); if (isCollapsibleKind(structuredDisplayKind(item, index, turnKey, turnLastIndex))) turnHasActivity.add(turnKey); }); for (const [index, item] of structuredMessages.entries()) { if (!updateStructuredMessageArticle(articles[index], item, index, turnLastIndex, turnHasActivity, turnWorkedDurations)) return false; const turnKey = structuredMessageTurn(item, index); const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex); if (isCollapsibleKind(kind)) { indexStructuredActivity(articles[index], item, kind, turnKey, index, structuredMessages); } } reconcileTurnDividers(); expandLatestTurnActivity(); if (typeof text === "string") output.dataset.outputTail = text; state.structuredMessages = structuredMessages.slice(); state.outputSynced = true; if (state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus) && [...output.querySelectorAll(".message.activity")].some((article) => article.dataset.turnId === state.turnId)) { ensureLiveTurnDivider(state.turnId); } if (follow) scrollOutput(output, true); updateScrollToBottom(output); return true; } function replaceOutput(text, structuredMessages) { const output = $("output"); const preserveBottom = !state.outputSynced || !output || shouldFollowOutput(output); const preservedDistanceFromBottom = output ? Math.max(0, output.scrollHeight - output.scrollTop - output.clientHeight) : 0; const pendingUserText = state.pendingUserText; const snapshotHasPendingUser = Boolean(pendingUserText && ( text.includes(`> ${pendingUserText}`) || (Array.isArray(structuredMessages) && structuredMessages.some((item) => { const kind = historyKind(item); return kind === "user" && comparableText(messageText(item)) === comparableText(pendingUserText); })) )); renderEmptyOutput(); if (typeof text === "string") output.dataset.outputTail = text; state.structuredMessages = Array.isArray(structuredMessages) ? structuredMessages.slice() : []; if (Array.isArray(structuredMessages) && structuredMessages.length) { // Older follower snapshots may omit the top-level subagents projection. // Derive the small panel model from the same collaboration items used by // the transcript so those agents remain discoverable after reconnect. const suppliedSubagents = state.subagents; const derivedSubagents = deriveSubagentsFromMessages(structuredMessages); if (derivedSubagents.length) { state.subagents = mergeSubagentProjections(derivedSubagents, suppliedSubagents); renderSubagents(); } else if (state.subagents.length) { // A complete snapshot with no collaboration items is authoritative for // the transcript. Do not carry a finished agent from the previous // thread into the new composer. state.subagents = []; renderSubagents(); } const turnLastIndex = structuredTurnFinalAssistantIndexes(structuredMessages); const turnHasActivity = new Set(); const turnWorkedDurations = structuredTurnWorkedDurations(structuredMessages); let previousTurnKey = ""; structuredMessages.forEach((item, index) => { const key = structuredMessageTurn(item, index); if (isCollapsibleKind(structuredDisplayKind(item, index, key, turnLastIndex))) turnHasActivity.add(key); }); structuredMessages.forEach((item, index) => { const itemText = messageText(item); if (!itemText) return; const sourceKind = historyKind(item); const turnKey = structuredMessageTurn(item, index); const kind = structuredDisplayKind(item, index, turnKey, turnLastIndex); const role = structuredMessageRole(item, kind); const status = structuredMessageStatus(item) || "completed"; const durationMs = item.durationMs ?? item.elapsedMs; const duration = elapsedDuration(durationMs); const timestamp = item.startedAtMs ?? item.completedAtMs; if (role === "user" || role === "assistant") { appendDateSeparator(timestamp, { role, turnId: turnKey, turnStart: turnKey !== previousTurnKey, breaksPreviousAdjacency: item.breaksPreviousAdjacency === true, }); } const finalAssistant = sourceKind === "assistant" && role === "assistant" && kind === "assistant" && (item.phase === "final_answer" || item.phase === "final-answer" || !isAssistantCommentary(item, index, turnKey, turnLastIndex)); const terminalTurn = ["completed", "complete", "done", "failed", "error", "interrupted", "cancelled", "canceled"] .includes(String(item.turnStatus || "").replace(/[\s-]+/g, "_").toLowerCase()); const workedDuration = turnWorkedDurations.get(turnKey) ?? workedDurationFor(item, null); const activitySummary = historyActivitySummary(item, kind, status, duration) || undefined; const collapsible = isCollapsibleKind(kind); const activityKey = collapsible ? structuredActivityKey(item, kind, turnKey, index) : ""; const message = appendMessage(itemText, role, collapsible ? "activity" : "history", "", { kind, status, label: item.label || (kind === "reasoning" ? "思考" : kind === "plan" ? "计划" : kind === "edit" ? "文件变更" : kind === "read" ? "读取文件" : kind === "tool" ? "工具输出" : kind === "subagent" ? "子代理" : kind === "commentary" ? "工作说明" : ""), summary: activitySummary, command: item.command || item.commandLine, turnId: turnKey, structuredKey: structuredMessageKey(item, index), activityKey, itemId: item.itemId, itemType: item.itemType, agentThreadId: kind === "subagent" ? firstString(item.agentThreadId, item.childThreadId, item.threadId) : "", timestamp, showTimestamp: role === "user" || role === "assistant", showActions: role === "user" || (role === "assistant" && finalAssistant && kind !== "commentary"), collapsible, open: kind === "commentary" || (status === "inProgress" && (kind === "reasoning" || kind === "plan")), }); if (message && collapsible) indexStructuredActivity(message.article, item, kind, turnKey, index, structuredMessages); if (finalAssistant && (terminalTurn || workedDuration !== null || (durationMs !== undefined && status !== "inProgress"))) { appendTurnDivider(turnKey, item.turnStatus || status || "completed", workedDuration ?? durationMs, message?.article || null); } previousTurnKey = turnKey; }); reconcileTurnDividers(); expandLatestTurnActivity(); } else if (text) { // The attach adapter prefixes user items with `> ` and separates items // with blank lines. Use that stable marker to recreate the two sides of // the conversation without interpreting arbitrary Markdown as HTML. const chunks = text.split(/\n{2,}/).map((chunk) => chunk.trim()).filter(Boolean); let expectUser = true; for (const chunk of chunks) { const markedUser = chunk.startsWith("> "); if (markedUser && expectUser) { appendMessage(chunk.slice(2), "user", "history", "", { kind: "user" }); expectUser = false; } else { appendMessage(chunk, "assistant", "history", "", { kind: "assistant" }); expectUser = true; } } } if (pendingUserText && !snapshotHasPendingUser) { appendDateSeparator(Date.now(), { role: "user", force: true }); appendMessage(pendingUserText, "user", "streaming", "", { kind: "user", timestamp: Date.now(), showTimestamp: true }); } state.pendingUserText = snapshotHasPendingUser ? "" : pendingUserText; state.outputSynced = true; if (state.turnId && state.turnStartedAt !== null && ["active", "waiting"].includes(state.turnStatus) && [...output.querySelectorAll(".message.activity")].some((article) => article.dataset.turnId === state.turnId)) { ensureLiveTurnDivider(state.turnId); } if (preserveBottom) scrollOutput(output, true); else if (output) { // Rebuilding a long history changes scrollHeight. Preserve the user's // distance from the bottom just like the official thread scroll layout, // so a background sync does not yank them away from the message they are // reading. output.scrollTop = Math.max(0, output.scrollHeight - output.clientHeight - preservedDistanceFromBottom); updateScrollToBottom(output); } } function applyStructuredMessagesPatch(patch) { if (!isRecord(patch) || !Array.isArray(state.structuredMessages) || !Array.isArray(patch.messages)) return null; const start = Number(patch.start); const deleteCount = Number(patch.deleteCount); if (!Number.isInteger(start) || start < 0 || start > state.structuredMessages.length || !Number.isInteger(deleteCount) || deleteCount < 0 || start + deleteCount > state.structuredMessages.length) return null; return [ ...state.structuredMessages.slice(0, start), ...patch.messages, ...state.structuredMessages.slice(start + deleteCount), ]; } function appendOutputChunk(text, stream = "codex", context = {}) { if (!text) return; const output = $("output"); const follow = shouldFollowOutput(output); const normalizedStream = String(stream || "codex").toLowerCase(); if (normalizedStream === "codex") { const userItem = text.match(/^(?:\n{2,})?> ([^\n]+)\n?$/); if (userItem) { const userText = userItem[1].trim(); const eventTurn = context.turnId || state.turnId || ""; finishAssistantStream(); appendDateSeparator(context.timestamp || Date.now(), { role: "user", turnId: eventTurn, turnStart: true }); if (!hasRenderedMessage(userText, eventTurn, "user")) { appendMessage(userText, "user", "history", "", { kind: "user", turnId: eventTurn, timestamp: context.timestamp || Date.now(), showTimestamp: true, }); } state.pendingUserText = ""; state.outputSynced = true; return; } } if (normalizedStream === "codex" && state.pendingUserText) { const marker = `> ${state.pendingUserText}`; if (text.includes(marker)) { text = text.replace(marker, "").replace(/^\n{1,2}/, ""); state.pendingUserText = ""; if (!text) return; } } const eventTurnId = context.turnId || state.turnId || ""; if (state.outputSynced && eventTurnId && normalizedStream === "codex" && hasRenderedCompletedMessage(text, eventTurnId, "assistant")) { // A host replay often sends the same output delta immediately after an // authoritative snapshot. Do not append a second assistant bubble. state.pendingUserText = ""; return; } const inferredActivity = normalizedStream === "reasoning" ? "thinking" : normalizedStream === "read" || normalizedStream === "reading" ? "reading" : normalizedStream === "stdout" || normalizedStream === "stderr" ? "running" : "generating"; if (!state.currentActivity || ["idle", "completed", "failed", "interrupted"].includes(state.currentActivity)) { state.currentActivity = inferredActivity; } if (state.turnStartedAt === null) startTurnClock(context.turnId || state.turnId, null, null); updateLiveActivity( state.currentActivity, state.currentActivityStartedAt || state.turnStartedAt, null, [], context.turnId || state.turnId, ); const activityText = statusActivityLabel(state.currentActivity) || t("正在生成"); const outputElapsed = elapsedDuration(Math.max(0, Date.now() - (state.turnStartedAt || Date.now()))); setConversationStatus(outputElapsed ? `${activityText} · ${outputElapsed}` : activityText, "active"); // Lifecycle notifications create the canonical row first. Output deltas // from app-server versions that omit itemId are attached to the newest // running row of the corresponding kind. const streamKind = normalizedStream === "reasoning" ? "reasoning" : normalizedStream === "read" || normalizedStream === "reading" ? "read" : normalizedStream === "stdout" || normalizedStream === "stderr" ? "tool" : context.kind || ""; const activity = streamKind ? latestRunningActivity(streamKind, context) : null; if (activity) { appendActivityChunk(activity, text); state.activeAssistantBody = activity.body; state.activeAssistantStream = normalizedStream; state.activeAssistantText = activity.outputText; state.activeAssistantActivityKey = activity.key; if (follow) scrollOutput(output, true); state.outputSynced = true; return; } if (state.activeAssistantStream !== normalizedStream) finishAssistantStream(); if (!state.activeAssistantBody && normalizedStream === "codex") text = text.replace(/^\n{2,}/, ""); if (!state.activeAssistantBody || !output.contains(state.activeAssistantBody)) { const role = normalizedStream === "stderr" ? "error" : normalizedStream === "stdout" || normalizedStream === "read" || normalizedStream === "reading" ? "tool" : normalizedStream === "reasoning" ? "system" : "assistant"; const label = normalizedStream === "reasoning" ? "思考" : normalizedStream === "stdout" ? "命令输出" : normalizedStream === "read" || normalizedStream === "reading" ? "读取文件" : ""; const kind = normalizedStream === "reasoning" ? "reasoning" : normalizedStream === "stdout" ? "tool" : normalizedStream === "read" || normalizedStream === "reading" || context.kind === "read" ? "read" : "assistant"; let message; if (kind === "reasoning" || kind === "tool" || kind === "read") { state.activitySequence += 1; const key = `stream:${state.threadId || "thread"}:${state.turnId || "turn"}:${normalizedStream}:${state.activitySequence}`; const streamActivity = ensureActivity(key, { kind, label: label || (kind === "tool" ? "工具输出" : kind === "read" ? "读取文件" : "思考"), threadId: state.threadId, turnId: eventTurnId || state.turnId, status: "inProgress", anonymous: true, concrete: true, }); message = streamActivity && { article: streamActivity.article, content: streamActivity.body }; state.activeAssistantActivityKey = streamActivity?.key || null; } else { appendDateSeparator(context.timestamp || Date.now(), { role: "assistant", turnId: eventTurnId, turnStart: false }); message = appendMessage("", role, "streaming", "", { kind, label, turnId: eventTurnId, timestamp: context.timestamp || Date.now(), showTimestamp: true, showActions: false, collapsible: false, }); } state.activeAssistantBody = message?.content || null; state.activeAssistantStream = normalizedStream; state.activeAssistantText = ""; } if (state.activeAssistantActivityKey) { const streamActivity = state.activities.get(state.activeAssistantActivityKey); if (streamActivity) { appendActivityChunk(streamActivity, text); state.activeAssistantText = streamActivity.outputText; if (follow) scrollOutput(output, true); state.outputSynced = true; return; } } if (state.activeAssistantBody) { state.activeAssistantText += text; const article = state.activeAssistantBody.closest(".message"); const role = article?.classList.contains("system") ? "system" : article?.classList.contains("error") ? "error" : "assistant"; const kind = article?.dataset.kind || "assistant"; renderMessageBody(state.activeAssistantBody, state.activeAssistantText, role, "streaming", kind); if (article) article.dataset.rawText = state.activeAssistantText; article?.classList.add("streaming"); } if (follow) scrollOutput(output, true); state.outputSynced = true; } function finishAssistantStream() { const article = state.activeAssistantBody?.closest(".message"); article?.classList.remove("streaming"); if (article?.classList.contains("assistant") && !article.querySelector(".message-actions")) addMessageActions(article, true); if (article?.dataset.kind === "reasoning") { const details = article.querySelector("details"); if (details) setDetailsExpanded(details, false); } const activity = state.activeAssistantActivityKey ? state.activities.get(state.activeAssistantActivityKey) : null; if (activity?.anonymous && isRunningActivity(activity)) finishActivity(activity, "completed"); state.activeAssistantBody = null; state.activeAssistantStream = null; state.activeAssistantText = ""; state.activeAssistantActivityKey = null; } function eventBelongsToCurrentTurn(payload) { if (!payload || typeof payload !== "object") return true; const threadId = eventThreadId(payload); const turnId = eventTurnId(payload); if (threadId && state.threadId && threadId !== state.threadId) return false; if (turnId && state.turnId && turnId !== state.turnId) return false; if (turnId && state.retiredTurnIds.has(turnId)) return false; return true; } function eventMessage(payload) { if (!payload || typeof payload !== "object") return "未知错误"; const candidates = [ payload.message, payload.error?.message, payload.error, payload.params?.message, payload.params?.error, payload.text, ]; const value = candidates.find((entry) => typeof entry === "string" && entry.trim()); if (value) return value; try { return JSON.stringify(payload); } catch { return "未知错误"; } } function setAttachMode(value) { state.attachMode = Boolean(value); document.body.classList.toggle("attach-mode", state.attachMode); $("sessionMode").textContent = t(state.attachMode ? "已附着 VS Code 当前 Codex 会话;输入、输出和授权都回到同一个会话。" : "当前为独立 app-server 模式。"); $("startThreadButton").textContent = t(state.attachMode ? "已附着现有会话" : "启动新 thread"); const modeLabel = $("modeLabel"); if (modeLabel) modeLabel.textContent = t(state.attachMode ? "本地模式" : "独立模式"); const popoverMode = $("popoverMode"); if (popoverMode) popoverMode.textContent = t(state.attachMode ? "本地模式" : "独立模式"); } function firstString(...values) { return values.find((value) => typeof value === "string" && value.trim())?.trim() || ""; } // Unlike firstString, settings projections need to preserve an explicit // null. Codex uses null for a model with no reasoning selector; treating it // as "missing" leaves the browser showing the previous turn's effort. function firstDefined(...values) { return values.find((value) => value !== undefined); } function modelDisplayName(model, effort) { const value = String(model || "").trim(); if (!value) return ""; const words = value .replace(/^gpt[-_]/i, "") .replace(/[-_]+/g, " ") .split(/\s+/) .filter(Boolean) .map((word) => /^\d+(?:\.\d+)*$/.test(word) ? word : `${word[0].toUpperCase()}${word.slice(1)}`); const effortValue = String(effort || "").trim(); if (effortValue && !words.some((word) => word.toLowerCase() === effortValue.toLowerCase())) { words.push(`${effortValue[0].toUpperCase()}${effortValue.slice(1)}`); } return words.join(" "); } // Keep this tiny fallback aligned with the model power choices shipped in // the installed official webview. A host that exposes `availableModels` // always wins; these entries only make the picker useful while an older // private IPC build has no model/list projection. const FALLBACK_MODELS = [ { model: "gpt-5.6-sol", displayName: "5.6 Sol", description: "通用 Codex 模型", efforts: ["low", "medium", "high", "xhigh"] }, { model: "gpt-5.6-terra", displayName: "5.6 Terra", description: "平衡速度与推理", efforts: ["low", "medium", "high", "xhigh"] }, ]; // Labels used by the shipped Work composer. The compact trigger shows the // model plus its selected reasoning preset (for example `5.6 Sol 标准`). const EFFORT_LABELS = { none: "默认", minimal: "极低", low: "轻度", medium: "标准", high: "深度", xhigh: "极高", max: "最大", ultra: "Ultra" }; // The compact Work picker is a power control. Keep the mapping deterministic // so keyboard/range input still goes through the same effort protocol used by // the advanced picker. function modelPowerOptions(model) { const efforts = Array.isArray(model?.efforts) ? model.efforts.filter(Boolean) : []; return efforts.map((effort) => ({ effort, label: t(EFFORT_LABELS[effort] || effort) })); } function normalizeModelOption(value) { if (typeof value === "string") { const fallback = FALLBACK_MODELS.find((entry) => entry.model === value); return fallback ? { ...fallback } : { model: value, displayName: modelDisplayName(value), description: "可用模型", efforts: [] }; } if (!isRecord(value)) return null; const model = firstString(value.model, value.id, value.slug, value.name); if (!model) return null; const hasSupported = Array.isArray(value.supportedReasoningEfforts) || Array.isArray(value.efforts); const supported = Array.isArray(value.supportedReasoningEfforts) ? value.supportedReasoningEfforts.map((entry) => typeof entry === "string" ? entry : firstString(entry?.reasoningEffort, entry?.effort)).filter(Boolean) : Array.isArray(value.efforts) ? value.efforts.filter((entry) => typeof entry === "string") : []; const fallback = FALLBACK_MODELS.find((entry) => entry.model === model); return { model, displayName: firstString(value.displayName, value.label, fallback?.displayName, modelDisplayName(model)), description: firstString(value.description, fallback?.description), // Unknown catalog entries must not inherit a fabricated effort list. The // official advanced picker disables the power controls until the host // reports capabilities for that model. efforts: hasSupported ? supported : fallback?.efforts || [], defaultReasoningEffort: firstString(value.defaultReasoningEffort, value.defaultEffort, fallback?.efforts?.[1]), hidden: value.hidden === true, }; } function normalizedModelOptions() { let source; if (state.availableModels.length) source = state.availableModels; else if (state.currentModel && FALLBACK_MODELS.some((entry) => entry.model === state.currentModel)) source = FALLBACK_MODELS; else if (state.currentModel) source = [state.currentModel]; else return []; const seen = new Set(); const result = []; for (const entry of source) { const option = normalizeModelOption(entry); if (!option || option.hidden || seen.has(option.model)) continue; seen.add(option.model); result.push(option); } if (state.currentModel && !seen.has(state.currentModel)) { result.unshift(normalizeModelOption(state.currentModel)); } return result; } function currentModelOption() { return normalizedModelOptions().find((entry) => entry.model === state.currentModel) || normalizedModelOptions()[0]; } function renderModelPicker() { const button = $("modelPickerButton"); const label = $("modelLabel"); const effortLabelNode = $("modelEffortLabel"); const menu = $("modelMenu"); const modelOptions = $("modelOptions"); const effortOptions = $("effortOptions"); const powerView = $("modelPowerView"); const advancedView = $("modelAdvancedView"); const advancedToggle = $("modelAdvancedToggle"); const powerSlider = $("modelPowerSlider"); const powerValue = $("modelPowerValue"); if (!button || !label || !effortLabelNode || !menu || !modelOptions || !effortOptions) return; const current = currentModelOption(); if (!current) { button.hidden = true; return; } button.hidden = false; const selectedEffort = state.currentEffort === null ? "" : state.currentEffort || current.defaultReasoningEffort || current.efforts?.[1] || current.efforts?.[0] || ""; const effortLabel = selectedEffort ? t(EFFORT_LABELS[selectedEffort] || selectedEffort) : ""; label.textContent = current.displayName || modelDisplayName(current.model); effortLabelNode.textContent = effortLabel; effortLabelNode.hidden = !effortLabel; const settingsModelValue = $("settingsModelValue"); if (settingsModelValue) settingsModelValue.textContent = [label.textContent, effortLabel].filter(Boolean).join(" ") || t("默认"); const triggerLabel = [label.textContent, effortLabel].filter(Boolean).join(" "); button.setAttribute("aria-label", uiLocale() === "en-US" ? `Current model: ${triggerLabel}; change model` : `当前模型 ${triggerLabel},切换模型`); button.setAttribute("title", uiLocale() === "en-US" ? `Change model (current: ${triggerLabel})` : `切换模型(当前 ${triggerLabel})`); modelOptions.replaceChildren(); for (const option of normalizedModelOptions()) { const item = document.createElement("button"); item.type = "button"; item.className = "model-option"; item.setAttribute("role", "option"); item.setAttribute("aria-selected", String(option.model === state.currentModel)); item.dataset.model = option.model; const name = document.createElement("span"); name.className = "model-option-name"; name.textContent = option.displayName || option.model; const check = document.createElement("span"); check.className = "model-option-check"; check.textContent = "✓"; const description = document.createElement("span"); description.className = "model-option-description"; description.textContent = option.description ? t(option.description) : option.model; item.append(name, check, description); item.addEventListener("click", () => selectModel(option.model)); modelOptions.append(item); } effortOptions.replaceChildren(); const efforts = current.efforts?.length ? [...current.efforts] : []; // Ultra is capability-gated in the official picker. Preserve it when the // host explicitly reports the current setting, but do not advertise it to // every fallback session. if (state.currentEffort === "ultra" && current.model === "gpt-5.6-sol" && !efforts.includes("ultra")) efforts.push("ultra"); const selectedMenuEffort = state.currentEffort === null ? "" : state.currentEffort || current.defaultReasoningEffort || efforts[1] || efforts[0] || ""; if (!efforts.length) { const empty = document.createElement("div"); empty.className = "effort-empty"; empty.textContent = t("此模型使用默认推理强度"); effortOptions.append(empty); } for (const effort of efforts) { const item = document.createElement("button"); item.type = "button"; item.className = "effort-option"; item.setAttribute("role", "option"); item.setAttribute("aria-selected", String(effort === selectedMenuEffort)); item.dataset.effort = effort; const name = document.createElement("span"); name.textContent = t(EFFORT_LABELS[effort] || effort); const check = document.createElement("span"); check.className = "effort-option-check"; check.textContent = "✓"; item.append(name, check); item.title = effort; item.addEventListener("click", () => selectEffort(effort)); effortOptions.append(item); } const powerOptions = modelPowerOptions(current); if (powerView && advancedView) { powerView.hidden = state.modelAdvancedOpen; advancedView.hidden = !state.modelAdvancedOpen; } if (advancedToggle) { advancedToggle.textContent = t(state.modelAdvancedOpen ? "简洁" : "高级"); advancedToggle.setAttribute("aria-label", t(state.modelAdvancedOpen ? "返回简洁模型选择" : "显示高级模型选项")); } if (powerSlider) { const hasPower = powerOptions.length > 0; powerSlider.disabled = !hasPower; powerSlider.min = "0"; powerSlider.max = String(Math.max(0, powerOptions.length - 1)); const selectedPower = powerOptions.findIndex((entry) => entry.effort === selectedMenuEffort); powerSlider.value = String(selectedPower >= 0 ? selectedPower : Math.min(1, Math.max(0, powerOptions.length - 1))); powerSlider.setAttribute("aria-valuetext", powerOptions[Number(powerSlider.value)]?.label || t("默认")); powerSlider.style.setProperty("--power-position", powerOptions.length > 1 ? `${Number(powerSlider.value) / (powerOptions.length - 1) * 100}%` : "0%"); if (powerValue) powerValue.textContent = powerOptions[Number(powerSlider.value)]?.label || t("默认"); } else if (powerValue) powerValue.textContent = ""; $("modelPicker")?.toggleAttribute("data-pending", state.modelUpdatePending); renderControlMode(); } function setModelMenu(open) { const button = $("modelPickerButton"); const menu = $("modelMenu"); if (!button || !menu) return; const next = Boolean(open) && threadSettingsAllowed() && !state.modeSwitching && !state.sessionSwitching; menu.hidden = !next; button.setAttribute("aria-expanded", String(next)); if (next) { state.modelAdvancedOpen = false; renderModelPicker(); } } function setPermissionMenu(open) { const button = $("permissionChip"); const menu = $("permissionMenu"); if (!button || !menu) return; const next = Boolean(open) && threadSettingsAllowed() && !state.modeSwitching && !state.sessionSwitching; menu.hidden = !next; button.setAttribute("aria-expanded", String(next)); if (next) renderPermissionMenu(); } function permissionSandboxLabel(value) { const label = { "read-only": "只读", "workspace-write": "工作区写入", "danger-full-access": "完全访问", }[normalizeSandboxName(value)] || String(value || "工作区写入"); return t(label); } function renderPermissionMenu() { const menu = $("permissionMenu"); const chip = $("permissionChip"); const label = $("permissionLabel"); if (!menu || !chip || !label) return; const mode = state.sandboxPolicy === "danger-full-access" && state.approvalPolicy === "never" ? "full" : state.sandboxPolicy === "read-only" ? "readonly" : state.approvalPolicy === "untrusted" || state.approvalPolicy === "never" ? "auto" : "ask"; label.textContent = t({ ask: "需要时询问", auto: "由 Codex 审批", full: "完全访问", readonly: "只读", }[mode]); const settingsPermissionValue = $("settingsPermissionValue"); if (settingsPermissionValue) settingsPermissionValue.textContent = label.textContent; chip.setAttribute("aria-label", uiLocale() === "en-US" ? `Change permissions; current: ${label.textContent}` : `修改权限,当前为${label.textContent}`); chip.setAttribute("title", uiLocale() === "en-US" ? `Change permissions (current: ${label.textContent})` : `修改权限(当前:${label.textContent})`); menu.querySelectorAll("[data-permission-mode]").forEach((item) => { item.setAttribute("aria-checked", String(item.dataset.permissionMode === mode)); }); } function selectPermissionMode(mode) { if (!threadSettingsAllowed()) { setConversationStatus("当前模式不支持修改会话设置", "warning"); return; } if (mode === "custom") { setPermissionMenu(false); setConversationStatus("自定义权限由 config.toml 管理", "ready"); return; } if (mode === "full" && !(state.sandboxPolicy === "danger-full-access" && state.approvalPolicy === "never")) { const confirm = $("permissionConfirm"); if (confirm) { confirm.hidden = false; confirm.dataset.pendingMode = mode; setPermissionMenu(false); $("permissionConfirmAccept")?.focus(); } return; } applyPermissionMode(mode); } function applyPermissionMode(mode) { if (!threadSettingsAllowed()) return; const presets = { ask: { sandboxPolicy: "workspace-write", approvalPolicy: "on-request" }, auto: { sandboxPolicy: "workspace-write", approvalPolicy: "untrusted" }, full: { sandboxPolicy: "danger-full-access", approvalPolicy: "never" }, readonly: { sandboxPolicy: "read-only", approvalPolicy: "on-request" }, }; const preset = presets[mode]; if (!preset) return; state.sandboxPolicy = preset.sandboxPolicy; state.approvalPolicy = preset.approvalPolicy; const sandboxInput = $("sandboxInput"); const approvalInput = $("approvalInput"); if (sandboxInput && [...sandboxInput.options].some((option) => option.value === state.sandboxPolicy)) sandboxInput.value = state.sandboxPolicy; if (approvalInput && [...approvalInput.options].some((option) => option.value === state.approvalPolicy)) approvalInput.value = state.approvalPolicy; renderPermissionMenu(); updateThreadSettings(undefined, undefined, { sandboxPolicy: state.sandboxPolicy, approvalPolicy: state.approvalPolicy, permissions: state.sandboxPolicy === "read-only" ? ":read-only" : state.sandboxPolicy === "danger-full-access" ? ":danger-full-access" : ":workspace", approvalsReviewer: "user", }); setPermissionMenu(false); } function selectPermissionSetting(kind, value) { if (!threadSettingsAllowed()) return; if (!value) return; if (kind === "sandbox") { state.sandboxPolicy = normalizeSandboxName(value); const input = $("sandboxInput"); if (input && [...input.options].some((option) => option.value === state.sandboxPolicy)) input.value = state.sandboxPolicy; } else { state.approvalPolicy = String(value); const input = $("approvalInput"); if (input && [...input.options].some((option) => option.value === state.approvalPolicy)) input.value = state.approvalPolicy; } renderPermissionMenu(); updateThreadSettings(undefined, undefined, { sandboxPolicy: state.sandboxPolicy, approvalPolicy: state.approvalPolicy, permissions: state.sandboxPolicy === "read-only" ? ":read-only" : state.sandboxPolicy === "danger-full-access" ? ":danger-full-access" : ":workspace", approvalsReviewer: "user", }); setPermissionMenu(false); } function normalizeUsage(value) { if (!isRecord(value)) return null; const source = isRecord(value.contextWindow) ? value.contextWindow : isRecord(value.context) ? value.context : value; const total = isRecord(value.total) ? value.total : isRecord(source.total) ? source.total : {}; const last = isRecord(value.last) ? value.last : isRecord(source.last) ? source.last : {}; const breakdownTotal = (entry) => { if (!isRecord(entry)) return null; const explicit = finiteNumber(entry.totalTokens, entry.total_tokens, entry.tokens, entry.used, entry.inputTokens); if (explicit !== null) return explicit; const parts = [ finiteNumber(entry.inputTokens, entry.input_tokens), finiteNumber(entry.cachedInputTokens, entry.cached_input_tokens), finiteNumber(entry.cacheWriteInputTokens, entry.cache_write_input_tokens), finiteNumber(entry.outputTokens, entry.output_tokens), finiteNumber(entry.reasoningOutputTokens, entry.reasoning_output_tokens), ].filter((entry) => entry !== null); return parts.length ? parts.reduce((sum, entry) => sum + entry, 0) : null; }; const used = finiteNumber( source.used, source.usedTokens, source.inputTokens, source.input_tokens, source.tokensUsed, source.totalTokens, value.usedTokens, value.inputTokens, breakdownTotal(last), breakdownTotal(total), ); const limit = finiteNumber( value.modelContextWindow, value.model_context_window, source.limit, source.max, source.maxTokens, typeof source.contextWindow === "number" ? source.contextWindow : null, value.limit, value.maxTokens, ); const remaining = finiteNumber(source.remaining, source.remainingTokens, value.remainingTokens); const percent = finiteNumber(source.percent, source.percentage, value.percent, value.percentage); if (used === null && limit === null && remaining === null && percent === null) return null; const computedPercent = percent !== null ? Math.max(0, Math.min(100, percent)) : used !== null && limit !== null && limit > 0 ? Math.max(0, Math.min(100, used / limit * 100)) : used !== null && remaining !== null && used + remaining > 0 ? used / (used + remaining) * 100 : 0; const clampedUsed = used !== null && limit !== null && limit > 0 ? Math.min(used, limit) : used; const computedRemaining = remaining !== null ? remaining : clampedUsed !== null && limit !== null ? Math.max(limit - clampedUsed, 0) : null; return { used: clampedUsed, limit, remaining: computedRemaining, percent: computedPercent, totalTokens: breakdownTotal(total), lastTokens: breakdownTotal(last), }; } function renderUsage() { const picker = $("usagePicker"); const button = $("usageButton"); const label = $("usageLabel"); const ring = $("usageRing"); const summary = $("usageSummary"); const details = $("usageDetails"); const bar = $("usageMeterBar"); if (!picker || !button || !label || !ring || !summary || !details || !bar) return; const usage = normalizeUsage(state.tokenUsage); picker.hidden = !usage; if (!usage) return; const percent = Math.round(usage.percent); label.textContent = `${percent}%`; ring.style.setProperty("--usage-percent", `${percent}%`); ring.dataset.level = percent >= 90 ? "critical" : percent >= 70 ? "warning" : "normal"; const remainingPercent = Math.max(0, 100 - percent); button.title = uiLocale() === "en-US" ? `Context used ${percent}% (${remainingPercent}% remaining)` : `上下文已使用 ${percent}%(剩余 ${remainingPercent}%)`; button.setAttribute("aria-label", button.title); summary.textContent = usage.limit !== null ? `${usage.used ?? 0} / ${usage.limit} tokens (${percent}%)` : uiLocale() === "en-US" ? `${percent}% used` : `${percent}% 已使用`; bar.style.width = `${percent}%`; details.textContent = [ usage.remaining !== null ? (uiLocale() === "en-US" ? `${usage.remaining} tokens remaining` : `剩余 ${usage.remaining} tokens`) : "", usage.used !== null ? (uiLocale() === "en-US" ? `Current context: ${usage.used} tokens` : `当前上下文 ${usage.used} tokens`) : "", usage.lastTokens !== null && usage.lastTokens !== usage.used ? (uiLocale() === "en-US" ? `Latest request: ${usage.lastTokens} tokens` : `最近请求 ${usage.lastTokens} tokens`) : "", usage.totalTokens !== null && usage.totalTokens !== usage.used ? (uiLocale() === "en-US" ? `Total: ${usage.totalTokens} tokens` : `累计 ${usage.totalTokens} tokens`) : "", ].filter(Boolean).join("\n"); } function setUsageMenu(open) { const button = $("usageButton"); const menu = $("usageMenu"); if (!button || !menu) return; const next = Boolean(open); menu.hidden = !next; button.setAttribute("aria-expanded", String(next)); } function updateThreadSettings(model, effort, extra = {}) { if (!threadSettingsAllowed() || !state.threadId || state.role !== "operator") return; const threadSettings = {}; if (model) threadSettings.model = model; if (effort !== undefined) threadSettings.effort = effort; if (isRecord(extra)) { for (const [key, value] of Object.entries(extra)) { if (value !== undefined) threadSettings[key] = value; } } if (!Object.keys(threadSettings).length) return; state.modelUpdatePending = true; if (model) state.currentModel = model; if (effort !== undefined) state.currentEffort = effort === null ? null : effort || ""; renderModelPicker(); try { command("thread/settings/update", { threadId: state.threadId, threadSettings }); } catch (error) { state.modelUpdatePending = false; appendOutput(error.message || "无法更新模型设置", "error"); renderModelPicker(); } } function currentEffortParams() { if (state.currentEffort === null) return { effort: null }; return state.currentEffort ? { effort: state.currentEffort } : {}; } function selectModel(model) { const option = normalizedModelOptions().find((entry) => entry.model === model); const effort = option?.efforts?.length ? option.efforts.includes(state.currentEffort) ? state.currentEffort : option.defaultReasoningEffort || option.efforts[1] || option.efforts[0] : null; updateThreadSettings(model, effort); setModelMenu(false); } function selectEffort(effort) { updateThreadSettings(undefined, effort); setModelMenu(false); } function selectPowerIndex(value) { const current = currentModelOption(); const options = modelPowerOptions(current); if (!options.length) return; const index = Math.max(0, Math.min(options.length - 1, Number(value) || 0)); const option = options[index]; if (!option) return; updateThreadSettings(undefined, option.effort); // The official power control remains open while keyboard arrows adjust it. state.modelAdvancedOpen = false; renderModelPicker(); } function normalizeSubagentStatus(value) { const raw = isRecord(value) ? firstString(value.status, value.type, value.state) : value; const normalized = String(raw || "").replace(/[\s_-]+/g, "").toLowerCase(); if (["running", "working", "interacted", "updated", "inprogress", "active", "started"].includes(normalized)) return "working"; if (["pending", "pendinginit", "waiting", "queued", "waitingforinput", "awaitinginstruction", "waitingforinstruction", "needsinput"].includes(normalized)) return "waiting"; if (["failed", "errored", "error", "notfound"].includes(normalized)) return "failed"; if (["completed", "complete", "done", "interrupted", "shutdown", "cancelled", "canceled"].includes(normalized)) return "done"; return normalized || "waiting"; } function subagentStatusLabel(status) { // These are the localized equivalents of the official background-agent // rows ("is working", "is awaiting instruction", "is done"). return t({ waiting: "正在等待指示", working: "正在工作", done: "已完成", failed: "失败" }[status] || status); } function subagentThreadId(entry) { return firstString(entry.threadId, entry.agentThreadId, entry.childThreadId); } function subagentElapsed(entry) { const startedAt = timestampMs(entry.startedAtMs, entry.startedAt, entry.createdAtMs, entry.createdAt); if (startedAt === null) return ""; const status = normalizeSubagentStatus(entry.status); const completedAt = timestampMs(entry.completedAtMs, entry.completedAt, entry.finishedAtMs, entry.finishedAt, entry.lastAssistantMessageAtMs); const end = status === "working" || status === "waiting" ? Date.now() : completedAt; if (end === null || end === undefined) return ""; return elapsedDuration(Math.max(0, end - startedAt)); } function focusSubagentActivity(threadId) { if (!threadId) return; const output = $("output"); const target = output ? [...output.querySelectorAll(".message[data-agent-thread-id]")] .reverse() .find((article) => article.dataset.agentThreadId === threadId) : null; if (!target) return; const turnId = target.dataset.turnId; const turnActivity = turnId ? state.turnDividers.get(turnId) : null; const turnToggle = turnActivity?.querySelector(".turn-divider-toggle"); if (turnToggle?.getAttribute("aria-expanded") === "false") { turnToggle.setAttribute("aria-expanded", "true"); setTurnActivityVisibility(turnId, true); } target.scrollIntoView({ block: "center", behavior: "smooth" }); target.classList.remove("subagent-focus"); void target.offsetWidth; target.classList.add("subagent-focus"); setTimeout(() => target.classList.remove("subagent-focus"), 1_200); } function createSubagentPanelRow(entry) { const status = normalizeSubagentStatus(entry.status); const threadId = subagentThreadId(entry); const row = document.createElement(threadId ? "button" : "div"); if (threadId) row.type = "button"; row.className = "subagent-row"; row.dataset.status = status; if (threadId) row.dataset.agentThreadId = threadId; const startedAt = timestampMs(entry.startedAtMs, entry.startedAt, entry.createdAtMs, entry.createdAt); const completedAt = timestampMs( entry.completedAtMs, entry.completedAt, entry.finishedAtMs, entry.finishedAt, entry.lastAssistantMessageAtMs, entry.recencyAtMs, entry.recencyAt, ); if (startedAt !== null) row.dataset.startedAt = String(startedAt); if (completedAt !== null) row.dataset.completedAt = String(completedAt); const icon = document.createElement("span"); icon.className = "subagent-icon"; icon.setAttribute("aria-hidden", "true"); icon.append(createSubagentIcon()); const copy = document.createElement("span"); copy.className = "subagent-copy"; const name = document.createElement("span"); name.className = "subagent-name"; name.textContent = firstString(entry.displayName, entry.agentNickname, entry.name, entry.agentPath) || t("子代理"); copy.append(name); const stateLabel = document.createElement("span"); stateLabel.className = "subagent-status"; const statusText = document.createElement("span"); statusText.className = "subagent-status-text"; statusText.textContent = subagentStatusLabel(status); stateLabel.append(statusText); const diff = isRecord(entry.diffStats) ? entry.diffStats : isRecord(entry.diff_stats) ? entry.diff_stats : null; const added = finiteNumber(diff?.linesAdded, diff?.added); const removed = finiteNumber(diff?.linesRemoved, diff?.removed); if (added !== null || removed !== null) { const diffLabel = document.createElement("span"); diffLabel.className = "subagent-diff-stats"; diffLabel.textContent = `+${Math.max(0, added || 0)} -${Math.max(0, removed || 0)}`; stateLabel.append(diffLabel); } const elapsed = document.createElement("span"); elapsed.className = "subagent-elapsed"; elapsed.textContent = subagentElapsed(entry); // The composer keeps the row compact, but exposes timing in the tooltip // and makes the elapsed node available for live updates. if (elapsed.textContent) stateLabel.append(elapsed); row.append(icon, copy, stateLabel); const objective = firstString(entry.statusMessage, entry.objective, entry.prompt, entry.role, entry.agentRole); const model = firstString(entry.spawnModel, entry.model, entry.modelId); const tooltipParts = [objective, model ? (uiLocale() === "en-US" ? `Using ${model}` : `使用 ${model}`) : "", elapsed.textContent ? (uiLocale() === "en-US" ? `Elapsed: ${elapsed.textContent}` : `已用时 ${elapsed.textContent}`) : ""].filter(Boolean); if (tooltipParts.length) row.title = `${name.textContent}: ${tooltipParts.join(" · ")}`; if (threadId) { row.title = tooltipParts.length ? `${name.textContent}: ${tooltipParts.join(" · ")}` : uiLocale() === "en-US" ? `View ${name.textContent}'s activity` : `查看 ${name.textContent} 的活动`; row.setAttribute("aria-label", `${name.textContent}, ${subagentStatusLabel(status)}`); row.addEventListener("click", () => focusSubagentActivity(threadId)); } return row; } function deriveSubagentsFromMessages(messages) { if (!Array.isArray(messages)) return []; deriveStructuredTurnKeys(messages); const entries = new Map(); let anonymousIndex = 0; const upsert = (item, threadId = "", itemIndex = -1) => { const agent = isRecord(item.agent) ? item.agent : {}; const displayName = firstString(item.displayName, item.agentNickname, item.agentName, item.agentPath, agent.displayName, agent.name); const key = threadId || displayName || firstString(item.agentPath, item.action, item.activityKind) || `anonymous-${anonymousIndex++}`; const existing = entries.get(key) || { threadId: threadId || "", displayName: null, prompt: null, objective: null, status: "working", statusMessage: null, canInteract: false }; const activityKind = firstString(item.activityKind, item.kind, agent.activityKind); const rawStatus = firstString(item.displayStatus, item.status, item.state, agent.status) || (activityKind === "completed" ? "completed" : activityKind === "interrupted" ? "interrupted" : "working"); const normalizedStatus = normalizeSubagentStatus(rawStatus); existing.threadId = existing.threadId || threadId; existing.displayName = displayName || existing.displayName; existing.agentPath = firstString(item.agentPath, agent.agentPath, existing.agentPath); existing.prompt = firstString(item.prompt, agent.prompt, existing.prompt) || null; existing.objective = firstString(item.objective, item.statusMessage, item.prompt, agent.objective, existing.objective) || null; existing.statusMessage = firstString(item.statusMessage, agent.statusMessage, existing.statusMessage) || null; existing.status = normalizedStatus; existing.canInteract = item.canInteract !== undefined ? item.canInteract !== false : existing.canInteract; const startedAt = timestampMs(item.startedAtMs, item.startedAt, item.createdAtMs, agent.startedAtMs); const completedAt = timestampMs( item.completedAtMs, item.completedAt, item.finishedAtMs, item.finishedAt, item.lastAssistantMessageAtMs, item.recencyAtMs, item.recencyAt, agent.completedAtMs, agent.lastAssistantMessageAtMs, agent.recencyAtMs, ); if (startedAt !== null) existing.startedAtMs = existing.startedAtMs ?? startedAt; if (completedAt !== null) existing.completedAtMs = completedAt; if (existing.completedAtMs === undefined && normalizedStatus === "done" && itemIndex >= 0) { const turnKey = structuredMessageTurn(item, itemIndex); for (let cursor = itemIndex + 1; cursor < messages.length; cursor += 1) { const next = messages[cursor]; if (!isRecord(next)) continue; if (structuredMessageTurn(next, cursor) !== turnKey) break; const nextStartedAt = timestampMs(next.startedAtMs, next.startedAt, next.createdAtMs); if (nextStartedAt !== null) { existing.completedAtMs = nextStartedAt; break; } } } if (item.model !== undefined || agent.model !== undefined) existing.model = firstString(item.model, agent.model, existing.model) || null; entries.set(key, existing); }; messages.forEach((item, index) => { if (!isRecord(item) || historyKind(item) !== "subagent") return; const receivers = Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds.map(String) : Array.isArray(item.receiverThreads) ? item.receiverThreads.map(String) : []; const states = isRecord(item.agentsStates) ? Object.keys(item.agentsStates) : []; const ids = [...new Set([ firstString(item.agentThreadId, item.childThreadId), ...receivers, ...states, ].filter(Boolean))]; if (!ids.length) upsert(item, "", index); else ids.forEach((id) => upsert(item, id, index)); }); return [...entries.values()]; } function subagentIdentity(entry) { if (!isRecord(entry)) return ""; return firstString( entry.threadId, entry.agentThreadId, entry.childThreadId, entry.displayName, entry.agentNickname, entry.name, entry.agentPath, ); } // A snapshot can carry a rich top-level subagent projection while the // message list only contains the compact activity item. Merge matching // identities so timestamps, model names, and interaction flags survive the // transcript re-render without carrying stale agents across threads. function mergeSubagentProjections(derived, supplied) { const rich = Array.isArray(supplied) ? supplied.filter(isRecord) : []; if (!rich.length) return derived; const byIdentity = new Map(rich .map((entry) => [subagentIdentity(entry), entry]) .filter(([identity]) => Boolean(identity))); const matched = new Set(); const merged = derived.map((entry) => { const identity = subagentIdentity(entry); const source = identity ? byIdentity.get(identity) : undefined; if (!source) return entry; matched.add(source); return { ...entry, ...source }; }); // Keep a rich top-level entry when the transcript projection is absent // (for example while a newly spawned agent has not emitted its first // activity item yet), but discard unrelated entries from an older thread. if (!merged.length) return rich; for (const source of rich) { if (matched.has(source)) continue; const identity = subagentIdentity(source); if (identity && !merged.some((entry) => subagentIdentity(entry) === identity) && normalizeSubagentStatus(source.status) !== "done") merged.push(source); } return merged; } function refreshSubagentElapsed() { for (const row of document.querySelectorAll(".subagent-row[data-started-at]")) { const startedAt = timestampMs(row.dataset.startedAt); if (startedAt === null) continue; const completedAt = timestampMs(row.dataset.completedAt); const status = normalizeSubagentStatus(row.dataset.status); const end = status === "working" || status === "waiting" ? Date.now() : completedAt; const label = row.querySelector(".subagent-elapsed"); if (label) label.textContent = end === null || end === undefined ? "" : elapsedDuration(Math.max(0, end - startedAt)); } } function renderSubagents() { const panel = $("subagentsPanel"); const list = $("subagentsList"); const count = $("subagentsCount"); if (!panel || !list || !count) return; const entries = Array.isArray(state.subagents) ? state.subagents.filter(isRecord) : []; const visibleEntries = entries.filter((entry) => firstString( entry.displayName, entry.agentNickname, entry.name, entry.agentPath, entry.threadId, entry.agentThreadId, entry.childThreadId, entry.objective, )); panel.hidden = visibleEntries.length === 0; if (!visibleEntries.length) { count.textContent = ""; if (isRecord(state.subagentsExpanded)) { state.subagentsExpanded.active = false; state.subagentsExpanded.done = false; } list.replaceChildren(); return; } const normalizedEntries = visibleEntries.map((entry) => ({ entry, status: normalizeSubagentStatus(entry.status) })); // The official composer uses one compact disclosure row. It does not // split the list into active/done sections or show per-agent wall-clock // durations; status is rendered inline beside each display name. const title = $("subagentsToggle")?.querySelector(".subagents-title"); if (title) title.textContent = uiLocale() === "en-US" ? `${normalizedEntries.length} background agents${state.subagentsCollapsed ? "" : " · @ to mention agents"}` : `${normalizedEntries.length} 个后台代理${state.subagentsCollapsed ? "" : " · @ 可标记代理"}`; count.textContent = ""; panel.dataset.collapsed = String(state.subagentsCollapsed); const toggle = $("subagentsToggle"); if (toggle) toggle.setAttribute("aria-expanded", String(!state.subagentsCollapsed)); list.setAttribute("aria-hidden", String(state.subagentsCollapsed)); list.inert = state.subagentsCollapsed; list.replaceChildren(); const rows = document.createElement("div"); rows.className = "subagent-section-rows"; for (const { entry } of normalizedEntries) rows.append(createSubagentPanelRow(entry)); list.append(rows); } function normalizeSandboxName(value) { const normalized = String(value || "") .replace(/^:+/, "") .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .replace(/[\s_]+/g, "-") .toLowerCase(); if (normalized === "dangerfullaccess" || normalized === "full-access" || normalized === "fullaccess") return "danger-full-access"; if (normalized === "workspacewrite") return "workspace-write"; if (normalized === "workspace" || normalized === "write") return "workspace-write"; if (normalized === "readonly" || normalized === "read") return "read-only"; return normalized; } function sandboxFromPermissions(value) { if (typeof value === "string") { const normalized = normalizeSandboxName(value); if (["read-only", "workspace-write", "danger-full-access"].includes(normalized)) return normalized; return ""; } if (!isRecord(value)) return ""; const explicit = firstString(value.sandboxPolicy, value.sandbox, value.mode, value.profile, value.type); if (explicit) { const normalized = normalizeSandboxName(explicit); if (["read-only", "workspace-write", "danger-full-access"].includes(normalized)) return normalized; } if (value.dangerFullAccess === true || value.fullAccess === true || value.full_access === true) return "danger-full-access"; const fileSystem = isRecord(value.fileSystem) ? value.fileSystem : isRecord(value.file_system) ? value.file_system : value; if (fileSystem.write === true || fileSystem.workspaceWrite === true || fileSystem.workspace_write === true) return "workspace-write"; if (fileSystem.readOnly === true || fileSystem.read_only === true || fileSystem.read === true) return "read-only"; return ""; } function normalizeApprovalPolicy(value) { const candidate = isRecord(value) ? firstString(value.policy, value.mode, value.type, value.approvalPolicy, value.approval_policy) : firstString(value); const normalized = String(candidate || "").replace(/[\s_]+/g, "-").toLowerCase(); return ["on-request", "never", "untrusted"].includes(normalized) ? normalized : ""; } // Model metadata belongs to the attached thread. Clear it before applying // a snapshot for a different thread so an older catalog cannot leak into a // newly selected conversation that does not expose model data. function resetSessionModelMetadata() { state.currentModel = ""; state.currentEffort = ""; state.availableModels = []; state.modelUpdatePending = false; state.tokenUsage = null; state.workedDurationMs = null; state.lastWorkedDurationMs = null; state.turnWorkStartedAt = null; state.finalAssistantStartedAt = null; state.sandboxPolicy = "workspace-write"; state.approvalPolicy = "on-request"; const modelInput = $("modelInput"); if (modelInput) modelInput.value = ""; const label = $("modelLabel"); if (label) { label.textContent = ""; label.hidden = true; } setModelMenu(false); renderModelPicker(); renderPermissionMenu(); renderUsage(); } function prepareForSessionSnapshot(threadId) { const incoming = typeof threadId === "string" ? threadId : ""; const previous = state.syncedThreadId !== null ? state.syncedThreadId : state.threadId || ""; if (previous !== incoming && (previous || incoming)) { resetSessionModelMetadata(); state.turnExpansion.clear(); } if (incoming) syncSessionActive(incoming); return previous !== incoming; } function snapshotHistoryComplete(...sources) { const seen = new Set(); const visit = (value, depth = 0) => { if (!isRecord(value) || seen.has(value) || depth > 3) return undefined; seen.add(value); if (typeof value.historyComplete === "boolean") return value.historyComplete; for (const key of ["metadata", "sessionMetadata", "state", "snapshot"]) { const nested = visit(value[key], depth + 1); if (typeof nested === "boolean") return nested; } return undefined; }; for (const source of sources) { const result = visit(source); if (typeof result === "boolean") return result; } return undefined; } function projectionTargetThreadId() { const switchingTarget = firstString(state.sessionSwitchContext?.targetThreadId); if (switchingTarget) return switchingTarget; if (state.sessionSelectedThreadId && state.sessionSelectedThreadId !== state.syncedThreadId) { return state.sessionSelectedThreadId; } return firstString(state.threadId, state.syncedThreadId); } function outputProjectionAllowed(threadId) { const incoming = String(threadId || ""); const expected = projectionTargetThreadId(); return !expected || !incoming || incoming === expected; } function hasVisibleOutputProjection() { const output = $("output"); return Boolean( state.structuredMessages.length || output?.dataset.outputTail || output?.querySelector(".message") ); } function finishSessionSnapshotCommit(threadId, authoritativeSnapshot = false) { const incoming = String(threadId || ""); if (!incoming) return; const context = state.sessionSwitchContext; if (authoritativeSnapshot && context?.targetThreadId === incoming) { context.targetSnapshotReady = true; finishSessionSwitchIfReady(); } syncSessionActive(incoming); } // Transcript, active-thread identity, and switch completion are one commit. // This prevents a metadata-only/placeholder reconnect snapshot from clearing // a fully rendered thread or completing a switch before its history arrives. function commitOutputProjection(threadId, text, structuredMessages, options = {}) { const incoming = String(threadId || ""); if (!outputProjectionAllowed(incoming)) return false; const hasContent = Boolean( (typeof text === "string" && text.length) || (Array.isArray(structuredMessages) && structuredMessages.length) ); const historyComplete = options.historyComplete; if (!hasContent && historyComplete !== true) { // All relay control snapshots have projection-shaped placeholder fields. // Until the host explicitly says history loading is complete, an empty // projection is not authoritative—even when the current DOM is empty. return false; } const changedThread = state.syncedThreadId !== null && state.syncedThreadId !== incoming; prepareForSessionSnapshot(incoming); if (changedThread) { state.outputSynced = false; state.snapshotNoticeShown = false; } replaceOutput(typeof text === "string" ? text : "", structuredMessages); state.syncedThreadId = incoming; if (incoming) state.threadId = incoming; finishSessionSnapshotCommit(incoming, options.authoritativeSnapshot === true); return true; } function applySessionMetadata(metadata, snapshotState = {}) { const meta = isRecord(metadata) ? metadata : {}; const stateSnapshot = isRecord(snapshotState) ? snapshotState : {}; const thread = isRecord(meta.thread) ? meta.thread : isRecord(stateSnapshot.thread) ? stateSnapshot.thread : {}; const settings = isRecord(meta.threadSettings) ? meta.threadSettings : isRecord(meta.latestThreadSettings) ? meta.latestThreadSettings : isRecord(meta.settings) ? meta.settings : isRecord(stateSnapshot.threadSettings) ? stateSnapshot.threadSettings : {}; const title = firstString(meta.title, meta.threadTitle, meta.name, thread.title, thread.name, thread.preview, stateSnapshot.title, stateSnapshot.threadTitle); if (title) $("threadTitle").textContent = title; const cwd = firstString(meta.cwd, settings.cwd, stateSnapshot.cwd); if (cwd) $("cwdInput").value = cwd; const modelValue = meta.latestModel ?? meta.model ?? meta.modelName ?? meta.modelId ?? settings.model ?? settings.modelName ?? stateSnapshot.latestModel ?? stateSnapshot.model ?? stateSnapshot.modelName; const model = typeof modelValue === "object" && modelValue !== null ? firstString(modelValue.name, modelValue.id, modelValue.slug) : firstString(modelValue); if (model) { $("modelInput").value = model; const modelLabel = $("modelLabel"); const effortValue = firstDefined( meta.latestReasoningEffort, meta.effort, settings.effort, stateSnapshot.latestReasoningEffort, stateSnapshot.effort, ); state.currentModel = model; if (effortValue !== undefined) { state.currentEffort = effortValue === null ? null : firstString(effortValue); } if (modelLabel) { modelLabel.textContent = modelDisplayName(model); modelLabel.hidden = false; } } else if (modelValue !== undefined) { const modelLabel = $("modelLabel"); if (modelLabel) modelLabel.hidden = true; } const modelsValue = meta.availableModels ?? meta.models ?? stateSnapshot.availableModels ?? stateSnapshot.models; if (Array.isArray(modelsValue)) state.availableModels = modelsValue.filter((entry) => typeof entry === "string" || isRecord(entry)); const subagentsValue = meta.subagents ?? stateSnapshot.subagents; if (Array.isArray(subagentsValue)) state.subagents = subagentsValue; const usageValue = meta.tokenUsage ?? meta.latestTokenUsageInfo ?? meta.contextUsage ?? meta.usage ?? stateSnapshot.tokenUsage ?? stateSnapshot.latestTokenUsageInfo ?? stateSnapshot.contextUsage ?? stateSnapshot.usage; if (usageValue !== undefined) state.tokenUsage = usageValue; const metadataWorkedDuration = finiteNumber( meta.workedDurationMs, meta.workDurationMs, meta.workedForMs, meta.workedFor?.durationMs, settings.workedDurationMs, stateSnapshot.workedDurationMs, stateSnapshot.workDurationMs, ); if (metadataWorkedDuration !== null) { state.workedDurationMs = Math.max(0, metadataWorkedDuration); state.lastWorkedDurationMs = Math.max(0, metadataWorkedDuration); } const metadataWorkStart = timestampMs( meta.firstTurnWorkItemStartedAtMs, meta.firstWorkItemStartedAtMs, meta.workStartedAtMs, meta.workedFor?.startedAtMs, settings.firstTurnWorkItemStartedAtMs, stateSnapshot.firstTurnWorkItemStartedAtMs, stateSnapshot.workStartedAtMs, ); if (metadataWorkStart !== null) state.turnWorkStartedAt = metadataWorkStart; const metadataFinalStart = timestampMs( meta.finalAssistantStartedAtMs, meta.workedFor?.completedAtMs, settings.finalAssistantStartedAtMs, stateSnapshot.finalAssistantStartedAtMs, ); if (metadataFinalStart !== null) state.finalAssistantStartedAt = metadataFinalStart; renderModelPicker(); renderSubagents(); renderUsage(); const permissionsValue = meta.permissions ?? meta.currentPermissions ?? settings.permissions ?? stateSnapshot.permissions ?? stateSnapshot.currentPermissions; const sandboxValue = meta.sandboxPolicy ?? meta.sandbox ?? settings.sandboxPolicy ?? settings.sandbox ?? stateSnapshot.sandboxPolicy ?? stateSnapshot.sandbox ?? sandboxFromPermissions(permissionsValue); const sandbox = typeof sandboxValue === "object" && sandboxValue !== null ? firstString(sandboxValue.type, sandboxValue.mode, sandboxValue.policy) : firstString(sandboxValue); if (sandbox) { const normalizedSandbox = normalizeSandboxName(sandbox); state.sandboxPolicy = normalizedSandbox; const sandboxInput = $("sandboxInput"); if (sandboxInput && [...sandboxInput.options].some((option) => option.value === normalizedSandbox)) sandboxInput.value = normalizedSandbox; const permission = $("permissionChip"); const permissionLabel = $("permissionLabel"); if (permission && permissionLabel) { permissionLabel.textContent = permissionSandboxLabel(normalizedSandbox); permission.hidden = false; } } else { const permission = $("permissionChip"); if (permission) permission.hidden = false; } const approval = normalizeApprovalPolicy( meta.approvalPolicy !== undefined ? meta.approvalPolicy : settings.approvalPolicy !== undefined ? settings.approvalPolicy : stateSnapshot.approvalPolicy, ); if (approval) { state.approvalPolicy = approval; const approvalInput = $("approvalInput"); if (approvalInput && [...approvalInput.options].some((option) => option.value === approval)) approvalInput.value = approval; } renderPermissionMenu(); const mode = firstString(meta.mode, stateSnapshot.mode); const modeLabel = $("modeLabel"); if (modeLabel && mode) modeLabel.textContent = t(/cloud|remote/i.test(mode) ? "云端模式" : "本地模式"); const popoverCwd = $("popoverCwd"); const popoverMode = $("popoverMode"); if (popoverCwd && cwd) popoverCwd.textContent = cwd; if (popoverMode && mode) popoverMode.textContent = t(/cloud|remote/i.test(mode) ? "云端模式" : "本地模式"); } const setConnection = (kind, text) => { $("connectionDot").className = `dot ${kind}`; $("connectionText").textContent = t(text); if (embeddedInAether) embedBridge.reportState(kind, { message: String(text || "") }); }; function setAuthRequired(value) { if (typeof value !== "boolean") return; state.authRequired = value; document.body.classList.toggle("local-no-auth", !value); const label = $("tokenLabel"); const input = $("tokenInput"); if (!label || !input) return; label.textContent = t(value ? "访问 token(认证模式)" : "本机连接(无需 token)"); input.placeholder = t(value ? "粘贴 relay 启动时打印的 token" : "本机模式无需填写;认证模式再填写"); input.setAttribute("aria-label", t(value ? "relay access token" : "本地连接无需 token")); } const authHeaders = () => ({ Authorization: `Bearer ${state.token}`, "Content-Type": "application/json" }); function attachPendingUserToTurn(turnId) { const key = String(turnId || ""); if (!key) return; const article = state.pendingUserArticle; if (article && article.isConnected) article.dataset.turnId = key; state.pendingUserArticle = null; } function sendFrame(frame) { if (!state.ws || state.ws.readyState !== WebSocket.OPEN) throw new Error(t("WebSocket 未连接")); state.ws.send(JSON.stringify(frame)); } function command(method, params) { const commandId = `web-${crypto.randomUUID()}`; sendFrame({ type: "command", commandId, method, params }); if (method === "turn/start" || method === "turn/steer") { const text = (params?.input || []) .map((item) => typeof item === "string" ? item : item?.text) .filter(Boolean) .join("\n"); finishAssistantStream(); state.pendingUserText = text; appendDateSeparator(Date.now(), { role: "user", turnId: state.turnId || params?.expectedTurnId || "", turnStart: true }); const userMessage = appendMessage(text || "(空消息)", "user", "text", "", { kind: "user", turnId: state.turnId || params?.expectedTurnId || "", timestamp: Date.now(), showTimestamp: true, }); if (method === "turn/start") state.pendingUserArticle = userMessage?.article || null; } else if (method === "thread/settings/update") { // Keep settings changes quiet in the transcript. The model picker has // its own pending state and the authoritative snapshot will update the // label once the official follower accepts the request. setConversationStatus("正在更新模型设置", "active"); } else if (["session/list", "session/select", "control/mode/set"].includes(sessionCommandMethod(method))) { // Session navigation is shell UI state. It must not appear as a Codex // message or alter the current turn's activity timeline. } else { appendOutput(`${method} (${commandId})`, "meta"); } return commandId; } function composerText() { const editor = $("messageInput"); if (!editor) return ""; return String(editor.innerText || editor.textContent || "") .replace(/\u00a0/g, " ") .replace(/\n{3,}/g, "\n\n") .trim(); } function clearComposer() { const editor = $("messageInput"); if (!editor) return; editor.replaceChildren(); editor.style.height = ""; resizeComposer(); updateIds(); } function resizeComposer() { const editor = $("messageInput"); if (!editor) return; editor.style.height = "auto"; const maxHeight = Math.max(40, Math.round(window.innerHeight * 0.25)); const nextHeight = Math.min(Math.max(editor.scrollHeight, 40), maxHeight); editor.style.height = `${nextHeight}px`; updateScrollPadding(); } function defaultResponse(request) { const method = request.method; // Rendering a request must never imply consent. The operator still has // to press an explicit action button. if (method === "item/commandExecution/requestApproval") return { decision: "decline" }; if (method === "item/fileChange/requestApproval") return { decision: "decline" }; if (method === "item/permissions/requestApproval") { return normalizePermissionResponse({}); } if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: { denied: { rejection: "默认拒绝,请明确允许" } } }; if (method === "item/tool/requestUserInput") { const answers = {}; for (const question of request.params?.questions || []) answers[question.id] = { answers: [""] }; return { answers }; } if (method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null }; return {}; } function normalizePermissionResponse(rawPermissions, scope, strictAutoReview) { const permissions = {}; if (rawPermissions && typeof rawPermissions === "object" && !Array.isArray(rawPermissions)) { for (const [key, value] of Object.entries(rawPermissions)) { if (value && typeof value === "object" && !Array.isArray(value)) permissions[key] = value; } } const response = { permissions, scope: scope === "session" ? "session" : "turn" }; if (typeof strictAutoReview === "boolean") response.strictAutoReview = strictAutoReview; return response; } function requestSummary(request) { const params = request.params || {}; if (Array.isArray(params.commandActions)) { const commands = params.commandActions .map((action) => action && typeof action === "object" ? action.command || action.description : "") .filter(Boolean); if (commands.length) return commands.join("\n"); } if (request.summary) return request.summary; if (Array.isArray(params.command)) return params.command.join(" "); if (params.command) return params.command; if (params.reason) return params.reason; if (Array.isArray(params.questions)) return params.questions.map((q) => q.question).join(" / "); if (params.message) return params.message; return t("需要远程确认或输入"); } function requestTitle(request) { const method = String(request.method || ""); if (method === "item/commandExecution/requestApproval" || method === "execCommandApproval") return t("允许运行命令?"); if (method === "item/fileChange/requestApproval" || method === "applyPatchApproval") return t("允许修改文件?"); if (method === "item/permissions/requestApproval") return t("需要扩大权限"); if (method === "item/tool/requestUserInput") return t("Codex 需要你的回答"); if (method === "mcpServer/elicitation/request") return t("需要外部服务确认"); return t("Codex 请求确认"); } function requestRisk(request) { const risk = String(request.risk || "medium").toLowerCase(); return t(risk === "high" ? "高风险" : risk === "low" ? "低风险" : "需确认"); } function requestCommand(request) { const params = request.params || {}; if (Array.isArray(params.commandActions)) { return params.commandActions .map((action) => action && typeof action === "object" ? action.command || action.description : "") .filter(Boolean) .join("\n"); } if (Array.isArray(params.command)) return params.command.join(" "); return typeof params.command === "string" ? params.command : ""; } function questionOptions(question) { const options = question?.options || question?.choices || question?.enum; if (!Array.isArray(options)) return []; return options.map((option) => { if (typeof option === "string") return { label: option, value: option }; if (option && typeof option === "object") { const value = option.value ?? option.id ?? option.label ?? option.name; const label = option.label ?? option.name ?? value; return { label: String(label ?? ""), value: String(value ?? "") }; } return null; }).filter((option) => option && option.value); } function renderQuestionFields(container, request) { const questions = Array.isArray(request.params?.questions) ? request.params.questions : []; container.replaceChildren(); if (!questions.length) { container.hidden = true; return; } container.hidden = false; questions.forEach((question, index) => { if (!question || typeof question !== "object") return; const field = document.createElement("label"); field.className = "request-question"; field.dataset.questionId = String(question.id ?? question.key ?? index); const prompt = document.createElement("span"); const questionPrompt = question.question ?? question.prompt ?? question.label; prompt.textContent = questionPrompt === undefined || questionPrompt === null ? t("请输入") : String(questionPrompt); field.append(prompt); const options = questionOptions(question); let control; if (options.length) { control = document.createElement("select"); options.forEach((option) => { const item = document.createElement("option"); item.value = option.value; item.textContent = option.label; control.append(item); }); } else { control = document.createElement("input"); control.type = question.secret ? "password" : "text"; control.placeholder = String(question.placeholder ?? ""); } control.className = "request-answer"; control.dataset.answerId = field.dataset.questionId; field.append(control); container.append(field); }); } function inputResponseFromCard(article) { const answers = {}; for (const field of article.querySelectorAll(".request-question")) { const id = field.dataset.questionId; const value = field.querySelector(".request-answer")?.value ?? ""; answers[id] = { answers: [value] }; } return { answers }; } function requestResponseFromCard(request, article, responseBox, action) { if (request.method === "item/tool/requestUserInput" && action === "allow") { // Prefer explicit edits in the JSON editor; otherwise collect the // first-class answer controls rendered for each question. if (responseBox.value !== article.dataset.defaultResponse) { try { const parsed = JSON.parse(responseBox.value); if (parsed && typeof parsed === "object") return parsed; } catch { return null; } } return inputResponseFromCard(article); } if (action === "allow") { const response = allowResponse(request, responseBox.value); const scope = article.querySelector(".request-scope")?.value; if (request.method === "item/permissions/requestApproval" && response && typeof response === "object" && scope) response.scope = scope; return response; } if (action === "deny") return denyResponse(request); try { const parsed = JSON.parse(responseBox.value); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return null; } } function buildRequestCard(request) { const fragment = $("requestTemplate").content.cloneNode(true); const article = fragment.querySelector(".request"); const method = String(request.method || "unknown"); const risk = requestRisk(request); article.dataset.requestKey = requestKey(request.requestId); article.dataset.risk = String(request.risk || "medium").toLowerCase(); fragment.querySelector(".request-method").textContent = requestTitle(request); fragment.querySelector(".request-id").textContent = `#${request.requestId}`; const riskNode = fragment.querySelector(".request-risk"); riskNode.textContent = risk; riskNode.dataset.risk = article.dataset.risk; fragment.querySelector(".request-summary").textContent = requestSummary(request); const commandNode = fragment.querySelector(".request-command"); const commandText = requestCommand(request); commandNode.textContent = commandText; commandNode.hidden = !commandText; fragment.querySelector(".request-json").textContent = JSON.stringify(request.params || {}, null, 2); const responseBox = fragment.querySelector(".request-response"); responseBox.value = JSON.stringify(defaultResponse(request), null, 2); article.dataset.defaultResponse = responseBox.value; const scopeWrap = fragment.querySelector(".request-scope-wrap"); if (request.method === "item/permissions/requestApproval") scopeWrap.hidden = false; renderQuestionFields(fragment.querySelector(".request-questions"), request); const allow = fragment.querySelector(".request-allow"); const deny = fragment.querySelector(".request-deny"); const send = fragment.querySelector(".request-send"); allow.textContent = t(method === "item/tool/requestUserInput" ? "提交回答" : method === "item/permissions/requestApproval" ? "允许" : "允许一次"); send.textContent = t("发送自定义响应"); allow.addEventListener("click", () => { const result = requestResponseFromCard(request, article, responseBox, "allow"); if (result) respond(request.requestId, JSON.stringify(result)); else appendOutput("自定义响应不是有效 JSON", "error"); }); deny.addEventListener("click", () => respond(request.requestId, JSON.stringify(requestResponseFromCard(request, article, responseBox, "deny")))); send.addEventListener("click", () => { const result = requestResponseFromCard(request, article, responseBox, "custom"); if (result) respond(request.requestId, JSON.stringify(result)); else appendOutput("响应不是有效 JSON", "error"); }); if (state.sessionSwitching || state.modeSwitching || state.role !== "operator" || !RESPONDABLE_METHODS.has(method) || state.responding.has(requestKey(request.requestId))) { for (const button of fragment.querySelectorAll("button")) button.disabled = true; responseBox.disabled = true; for (const control of fragment.querySelectorAll("input, select")) control.disabled = true; } return fragment; } function renderRequests() { const container = $("inlineRequests"); const legacyContainer = $("requests"); container.replaceChildren(); if (legacyContainer) legacyContainer.replaceChildren(); const requests = [...state.requests.values()]; $("requestCount").textContent = String(requests.length); $("factRequests").textContent = String(requests.length); renderControlMode(); const panel = $("requestsPanel"); if (!requests.length) { container.className = "inline-requests empty"; if (legacyContainer) { legacyContainer.className = "requests empty"; legacyContainer.textContent = t("暂无待处理请求"); } if (panel) panel.open = false; return; } container.className = "inline-requests"; for (const request of requests) { const fragment = buildRequestCard(request); container.append(fragment); } if (panel) panel.open = false; } function denyResponse(request) { if (request.method === "item/commandExecution/requestApproval" || request.method === "item/fileChange/requestApproval") return { decision: "decline" }; if (request.method === "item/permissions/requestApproval") return normalizePermissionResponse({}); if (request.method === "applyPatchApproval" || request.method === "execCommandApproval") return { decision: { denied: { rejection: "远程参与者拒绝" } } }; if (request.method === "mcpServer/elicitation/request") return { action: "decline", content: null, _meta: null }; if (request.method === "item/tool/requestUserInput") return { answers: {} }; return { decision: "decline" }; } function allowResponse(request, raw) { const method = request.method; if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { decision: "accept" }; if (method === "item/permissions/requestApproval") { if (raw === undefined) return normalizePermissionResponse(request.params?.permissions); try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? normalizePermissionResponse(parsed.permissions, parsed.scope, parsed.strictAutoReview) : normalizePermissionResponse({}); } catch { return normalizePermissionResponse(request.params?.permissions); } } if (method === "applyPatchApproval" || method === "execCommandApproval") return { decision: "approved" }; if (method === "mcpServer/elicitation/request") return { action: "accept", content: null, _meta: null }; // User-input requests need the operator's edited answers. Keep the JSON // editor as the source of truth and fail closed if it is malformed. try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : { answers: {} }; } catch { return { answers: {} }; } } function respond(requestId, raw) { if (state.sessionSwitching) return; const key = requestKey(requestId); if (state.responding.has(key)) return; let result; try { result = JSON.parse(raw); } catch { appendOutput("响应不是有效 JSON", "error"); return; } try { sendFrame({ type: "respond", requestId, result }); } catch (error) { appendOutput(error.message, "error"); return; } state.responding.add(key); renderRequests(); } function updateIds() { $("threadId").textContent = state.threadId || "-"; $("turnId").textContent = state.turnId || "-"; const popoverThread = $("popoverThread"); if (popoverThread) popoverThread.textContent = state.threadId || "-"; const hasThread = Boolean(state.threadId); const hasTurn = Boolean(state.turnId); const hasComposerText = Boolean(composerText()); const switching = Boolean(state.sessionSwitching || state.modeSwitching); document.body.classList.toggle("turn-active", hasTurn); $("startThreadButton").disabled = state.attachMode || !state.appReady || state.role !== "operator"; const newSessionButton = $("newSessionButton"); if (newSessionButton) { newSessionButton.disabled = !sessionControlAllowed("sessionCreate") || switching || !state.appReady || Boolean(state.newSessionCommandId) || !["operator", "owner", "host"].includes(String(state.role || "")); newSessionButton.setAttribute("aria-busy", String(Boolean(state.newSessionCommandId))); } $("startTurnButton").disabled = switching || !state.appReady || !hasThread || hasTurn || !hasComposerText || state.role !== "operator"; $("steerButton").disabled = switching || !state.appReady || !hasTurn || !hasComposerText || state.role !== "operator"; $("interruptButton").disabled = switching || !state.appReady || !hasTurn || state.role !== "operator"; const messageInput = $("messageInput"); if (messageInput) { messageInput.contentEditable = switching ? "false" : "true"; messageInput.setAttribute("aria-disabled", String(switching)); } for (const id of ["modelPickerButton", "permissionChip", "composerPlusButton"]) { const control = $(id); if (control) control.disabled = switching; } const send = $("startTurnButton"); if (send) { send.title = t(hasTurn ? "发送 Steer" : "发送消息"); send.setAttribute("aria-label", t(hasTurn ? "发送 Steer" : "发送消息")); } const settingsModelValue = $("settingsModelValue"); if (settingsModelValue) settingsModelValue.textContent = state.currentModel ? modelDisplayName(state.currentModel, state.currentEffort) : t("默认"); const settingsPermissionValue = $("settingsPermissionValue"); if (settingsPermissionValue) settingsPermissionValue.textContent = permissionSandboxLabel(state.sandboxPolicy); renderControlMode(); } function protocolMethodForEvent(event, payload) { if (typeof payload?.method === "string") return payload.method; if (typeof payload?.params?.method === "string") return payload.params.method; const type = String(event?.type || ""); if (type === "item.started") return "item/started"; if (type === "item.completed") return "item/completed"; if (type === "thread.started") return "thread/started"; if (type === "turn.started") return "turn/started"; if (type === "turn.completed") return "turn/completed"; if (type === "turn.plan.updated") return "turn/plan/updated"; if (type === "turn.diff.updated") return "turn/diff/updated"; return ""; } function handleProtocolNotification(method, payload, eventType = "") { const params = eventParams(payload); if (method === "item/started" || eventType === "item.started") { if (!eventBelongsToCurrentTurn(payload)) return true; handleItemLifecycle("started", params); return true; } if (method === "item/completed" || eventType === "item.completed") { if (!eventBelongsToCurrentTurn(payload)) return true; handleItemLifecycle("completed", params); return true; } if (method === "turn/plan/updated" || method === "turn/plan/update" || eventType === "turn.plan.updated") { if (!eventBelongsToCurrentTurn(payload)) return true; handlePlanUpdate(params); return true; } if (method === "turn/diff/updated" || method === "turn/diff/update" || eventType === "turn.diff.updated") { if (!eventBelongsToCurrentTurn(payload)) return true; handleDiffUpdate(params); return true; } if (/^(?:item\/)?fileChange\/outputDelta$/.test(method) || method === "item/fileChange/delta") { if (!eventBelongsToCurrentTurn(payload)) return true; appendFileChangeChunk(payload, textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk)); return true; } if (/^(?:item\/)?(?:fileRead|readFile|fileReadOutput)\/(?:outputDelta|delta|textDelta)$/.test(method) || method === "item/fileRead/outputDelta" || method === "item/fileRead/delta") { if (!eventBelongsToCurrentTurn(payload)) return true; appendOutputChunk(textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk ?? params.content), "read", { kind: "read", itemId: params.itemId, turnId: eventTurnId(payload), }); return true; } if (method === "item/commandExecution/outputDelta" || method === "command/exec/outputDelta" || method === "process/outputDelta") { if (!eventBelongsToCurrentTurn(payload)) return true; const stream = params.stream || params.channel || (params.stderr ? "stderr" : "stdout"); appendOutputChunk(textFromValue(params.delta ?? params.text ?? params.output ?? params.chunk), stream, { kind: "tool", itemId: params.itemId, turnId: eventTurnId(payload), }); return true; } if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta" || method === "item/plan/delta") { if (!eventBelongsToCurrentTurn(payload)) return true; appendOutputChunk(textFromValue(params.delta ?? params.text), "reasoning", { kind: method === "item/plan/delta" ? "plan" : "reasoning", itemId: params.itemId, turnId: eventTurnId(payload), }); return true; } if (method === "turn/started") { const turn = isRecord(params.turn) ? params.turn : params; const startedTurnId = turn.id || params.turnId; const workStart = timestampMs( turn.firstTurnWorkItemStartedAtMs, turn.workStartedAtMs, params.firstTurnWorkItemStartedAtMs, params.workStartedAtMs, ); if (workStart !== null) state.turnWorkStartedAt = workStart; startTurnClock(startedTurnId, turn.startedAtMs || params.startedAtMs, turn.elapsedMs || params.elapsedMs); attachPendingUserToTurn(startedTurnId); return true; } if (method === "turn/completed") { const turn = isRecord(params.turn) ? params.turn : params; const turnId = turn.id || params.turnId || ""; if (!eventBelongsToCurrentTurn(payload)) return true; const status = normalizeActivityStatus(turn.status || params.status, "completed"); const duration = turn.durationMs || params.durationMs; const worked = workedDurationFor(turn, workedDurationFor(params, null)); const finalAssistantStart = timestampMs(turn.finalAssistantStartedAtMs, params.finalAssistantStartedAtMs); if (finalAssistantStart !== null) state.finalAssistantStartedAt = finalAssistantStart; finishActivitiesForTurn(turnId, status); finishAssistantStream(); const completedTurnId = turnId || state.turnId || ""; stopTurnClock(status, duration, turn.completedAtMs || params.completedAtMs, worked); appendCompletedTurnDivider(completedTurnId, status, worked ?? state.lastWorkedDurationMs ?? duration ?? state.lastTurnDurationMs); reconcileTurnDividers(); if (completedTurnId) { state.retiredTurnIds.add(completedTurnId); if (state.retiredTurnIds.size > 100) state.retiredTurnIds.delete(state.retiredTurnIds.values().next().value); } state.pendingUserText = ""; state.turnId = ""; updateIds(); return true; } return false; } function handleEvent(event) { const eventSeq = finiteNumber(event.seq); // The relay sends a buffered event stream followed by one control // `session.snapshot`. Treat that control frame as the only baseline during // the handshake; an older host `session.snapshot` event in the buffer is // just historical data and may describe a turn that already ended. if (state.awaitingSnapshot) return; // `subscribe` may replay events that are already represented by the // following authoritative snapshot. Ignore those frames entirely so a // stale task.started/task.status cannot reopen the composer state. if (eventSeq !== null && state.lastSnapshotSeq > 0 && eventSeq <= state.lastSnapshotSeq) return; if (eventSeq !== null && eventSeq > state.lastSeq) state.lastSeq = eventSeq; $("latestSeq").textContent = `seq ${state.lastSeq}`; $("lastEvent").textContent = `${event.seq || "-"} / ${event.type || "event"}`; const payload = isRecord(event.payload) ? { ...event.payload } : {}; // Older bridge versions put identity/status fields on the event envelope // instead of inside payload. Normalize both shapes before routing so a // terminal event cannot be attributed to the wrong turn. for (const key of [ "threadId", "turnId", "requestId", "method", "params", "status", "executionStatus", "activity", "turnStatus", "activeFlags", "controlMode", "mode", "targetMode", "modeEpoch", "capabilities", "startedAtMs", "durationMs", "completedAtMs", "workedDurationMs", "workDurationMs", "workedForMs", "firstTurnWorkItemStartedAtMs", "firstWorkItemStartedAtMs", "workStartedAtMs", "finalAssistantStartedAtMs", ]) { if (payload[key] === undefined && event[key] !== undefined) payload[key] = event[key]; } const usagePayload = payload.tokenUsage ?? payload.latestTokenUsageInfo ?? payload.contextUsage ?? payload.usage ?? payload.params?.tokenUsage ?? payload.params?.latestTokenUsageInfo ?? payload.params?.contextUsage; if (usagePayload !== undefined) { state.tokenUsage = usagePayload; renderUsage(); } const protocolMethod = protocolMethodForEvent(event, payload); // Do not let a replayed terminal event from an older turn overwrite the // timer/status of a newer turn. The protocol handler performs the same // check, but status is normally applied before routing the event. const staleTerminalEvent = (event.type === "task.finished" || event.type === "task.cancelled" || protocolMethod === "turn/completed") && !eventBelongsToCurrentTurn(payload); // Status is carried both as a typed relay field and inside the payload for // older clients. Apply it before routing the event so a normal output // delta cannot hide an active thinking/editing/approval state. const hasExecutionStatus = isRecord(payload.executionStatus) || isRecord(event.status) || isRecord(payload.status) || typeof payload.activity === "string" || typeof payload.turnStatus === "string" || Array.isArray(payload.activeFlags) || payload.startedAtMs !== undefined || payload.durationMs !== undefined; if (hasExecutionStatus && !staleTerminalEvent) { applyStatusSnapshot({ ...payload, status: payload.executionStatus || event.status || payload.status, }, { allowTerminal: true, showIdle: false }); } if (protocolMethod && handleProtocolNotification(protocolMethod, payload, event.type)) { if (event.type === "item.started" || event.type === "item.completed" || event.type === "task.status" || protocolMethod === "turn/completed") updateIds(); return; } if (event.type === "control.mode.switching" || event.type === "control.mode.changed") { const requestedMode = normalizeControlMode(firstString(payload.controlMode, payload.mode, payload.targetMode)); if (requestedMode && (requestedMode !== state.controlMode || state.modeSwitching)) { state.modeSwitching = true; state.requestedControlMode = requestedMode; if (state.modeRequestEpoch < 0) state.modeRequestEpoch = state.modeEpoch; setConversationStatus("正在切换控制模式", "active"); updateIds(); } return; } if (event.type === "session.switching") { state.sessionSwitching = true; const targetThreadId = firstString(payload.targetThreadId, payload.threadId); let switchContext = state.sessionSwitchContext; if (targetThreadId) { state.sessionSelectedThreadId = targetThreadId; switchContext = beginSessionSwitchContext(targetThreadId); // Route subsequent target events against the requested owner while // the previous transcript remains mounted as the visual fallback. state.threadId = targetThreadId; if (switchContext?.targetTitle) { setConversationStatus( uiLocale() === "en-US" ? `Switching to “${switchContext.targetTitle}”` : `正在切换到「${switchContext.targetTitle}」`, "active", ); } } finishAssistantStream(); // Keep the previous transcript mounted until the target's authoritative // snapshot arrives. The bridge may emit `session.switching` before // owner discovery/follow completes; clearing here made a failed switch // look like an empty conversation and left the user with no way to tell // whether the target had actually loaded. setSessionSwitchingVisual(true); if (!switchContext?.targetTitle) setConversationStatus("正在切换会话", "active"); renderSessionPicker(); return; } if (event.type === "session.selected") { const selectedThreadId = firstString(payload.threadId, payload.activeThreadId); const switchContext = state.sessionSwitchContext; if (payload.failed === true) { // VS Code-driven attachment changes have no browser command result. // The adapter therefore publishes an explicit failed selection for // the previous thread. Roll routing/title back even if a target // snapshot was already rendered while the adapter validated its owner. const previousThreadId = firstString(switchContext?.previousThreadId, selectedThreadId); restoreSessionSwitchContext(); if (previousThreadId) { state.threadId = previousThreadId; state.sessionSelectedThreadId = previousThreadId; syncSessionActive(previousThreadId); } state.sessionListError = sessionErrorMessage(payload, "会话切换失败,已恢复原会话"); setConversationStatus(state.sessionListError, "warning"); renderSessionPicker(); return; } const matchesTarget = Boolean(switchContext && selectedThreadId && switchContext.targetThreadId === selectedThreadId); if (selectedThreadId && (!switchContext || matchesTarget)) { state.sessionSelectedThreadId = selectedThreadId; syncSessionActive(selectedThreadId); } // This is only one half of the switch commit. An acknowledgement for a // superseded target is ignored, and a matching acknowledgement keeps all // input disabled until the target's authoritative snapshot is committed. if (matchesTarget) { switchContext.selectedAckReady = true; if (finishSessionSwitchIfReady()) { setConversationStatus("会话已切换", "ready"); } else { state.sessionSwitching = true; setSessionSwitchingVisual(true); setConversationStatus("正在加载会话", "active"); } } else if (!switchContext) { state.sessionSwitching = false; finishSessionSwitchContext(); } renderSessionPicker(); return; } if (/error|warning/i.test(String(event.type || "")) || ["error", "warning"].includes(String(payload.method || "").toLowerCase())) { finishAssistantStream(); appendOutput(eventMessage(payload), /warning/i.test(String(event.type || "")) ? "meta" : "error"); return; } if (event.type === "connection.opened" || event.type === "app.ready") { state.appReady = true; $("appState").textContent = appStatusLabel("ready"); $("factApp").textContent = appStatusLabel("ready"); } if (event.type === "connection.closed" || event.type === "host.disconnected" || event.type === "app.exited") { finishAssistantStream(); const disconnectedTurnId = state.turnId; if (disconnectedTurnId) finishActivitiesForTurn(disconnectedTurnId, "interrupted"); state.pendingUserText = ""; state.appReady = false; state.snapshotNoticeShown = false; state.turnId = ""; state.turnStartedAt = null; state.turnWorkStartedAt = null; state.finalAssistantStartedAt = null; state.workedDurationMs = null; state.lastWorkedDurationMs = null; state.currentActivity = "idle"; state.currentActivityStartedAt = null; state.currentActivityDurationMs = null; state.currentActivityTurnId = ""; state.subagents = []; renderSubagents(); updateLiveActivity("idle"); state.sessionListLoading = false; state.sessionListCommandId = ""; state.newSessionCommandId = ""; state.sessions = []; state.sessionFocusedId = ""; if (state.sessionSwitching) { // An explicit host disconnect aborts an in-flight hand-off. Restore // the previous thread identity while keeping its transcript mounted. restoreSessionSwitchContext(); } state.sessionSelectedThreadId = ""; state.sessionListError = "VS Code 主机未连接"; renderSessionPicker(); $("appState").textContent = appStatusLabel("offline"); $("factApp").textContent = appStatusLabel("offline"); updateIds(); updateScrollToBottom($("output")); } if (event.type === "output.snapshot") { const snapshotThreadId = firstString(payload.threadId, state.threadId, state.syncedThreadId); if (!outputProjectionAllowed(snapshotThreadId)) return; const committed = commitOutputProjection(snapshotThreadId, typeof payload.text === "string" ? payload.text : "", payload.messages, { historyComplete: snapshotHistoryComplete(payload), authoritativeSnapshot: true, }); if (!committed) return; if (Array.isArray(payload.subagents)) { state.subagents = payload.subagents; renderSubagents(); } return; } if ((event.type === "output.delta" || event.type === "output.chunk") && (payload.text || Array.isArray(payload.messages) || isRecord(payload.messagesPatch))) { const projectionThreadId = firstString(payload.threadId, state.threadId, state.syncedThreadId); if (!outputProjectionAllowed(projectionThreadId)) return; if (!eventBelongsToCurrentTurn(payload)) return; const projectionMatches = !projectionThreadId || projectionThreadId === state.syncedThreadId; // Attach-mode adapters include the complete role-aware projection on a // delta. Re-rendering that projection keeps reasoning, tools, edits and // assistant text in their canonical item boundaries while retaining the // legacy append-only text field for older bridges. if (Array.isArray(payload.messages)) { if (!projectionMatches) { const committed = commitOutputProjection( projectionThreadId, typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? payload.text : "", payload.messages, { historyComplete: snapshotHistoryComplete(payload) }, ); if (committed && Array.isArray(payload.subagents)) { state.subagents = payload.subagents; renderSubagents(); } return; } if (Array.isArray(payload.subagents)) { state.subagents = payload.subagents; renderSubagents(); } if (payload.structureChanged === false && reconcileStructuredOutput( typeof payload.outputTail === "string" ? payload.outputTail : payload.text, payload.messages, )) return; replaceOutput( typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? payload.text : "", payload.messages, ); return; } if (isRecord(payload.messagesPatch)) { // A suffix patch has meaning only relative to the same thread's // authoritative baseline. Never apply it to the transcript retained // while another session is still loading. if (!projectionMatches) return; if (Array.isArray(payload.subagents)) { state.subagents = payload.subagents; renderSubagents(); } const patchedMessages = applyStructuredMessagesPatch(payload.messagesPatch); if (patchedMessages) { const outputTail = typeof payload.outputTail === "string" ? payload.outputTail : typeof payload.text === "string" ? `${$("output")?.dataset.outputTail || ""}${payload.text}`.slice(-32_000) : $("output")?.dataset.outputTail || ""; if (reconcileStructuredOutput(outputTail, patchedMessages)) return; replaceOutput(outputTail, patchedMessages); return; } } if (!projectionMatches) return; if (Array.isArray(payload.subagents)) { state.subagents = payload.subagents; renderSubagents(); } appendOutputChunk(payload.text, payload.stream, { kind: payload.kind, itemId: payload.itemId, turnId: eventTurnId(payload), timestamp: payload.timestamp || payload.startedAtMs, }); return; } if (event.type === "app.stderr") { finishAssistantStream(); appendOutput(payload.text, "meta"); return; } if (event.type === "approval.requested" || event.type === "input.requested" || event.type === "server.requested" || event.type === "server.request") { state.requests.set(requestKey(payload.requestId), { requestId: payload.requestId, method: payload.method, params: payload.params || payload, ...(payload.risk ? { risk: payload.risk } : {}), ...(payload.summary ? { summary: payload.summary } : {}), ...(payload.commandHash ? { commandHash: payload.commandHash } : {}), ...(payload.createdAt ? { createdAt: payload.createdAt } : {}), ...(payload.expiresAt ? { expiresAt: payload.expiresAt } : {}), }); renderRequests(); const inlineRequests = $("inlineRequests"); if (inlineRequests) inlineRequests.scrollTop = inlineRequests.scrollHeight; finishAssistantStream(); // The inline request card is the canonical representation. A separate // plain-text transcript line would duplicate the approval/input prompt // and could be mistaken for Codex output. const waitingActivity = payload.method === "item/tool/requestUserInput" || payload.method === "mcpServer/elicitation/request" ? "waiting_input" : "waiting_approval"; state.currentActivity = waitingActivity; setConversationStatus(statusActivityLabel(waitingActivity), "warning"); return; } if (event.type === "server.responded" || event.type === "approval.resolved" || event.type === "input.resolved" || event.type === "approval.expired" || event.type === "input.expired") { const key = resolveRequestKey(payload.requestId); state.responding.delete(key); state.requests.delete(key); renderRequests(); return; } if (event.type === "task.status") { applyStatusSnapshot(payload, { allowTerminal: true, showIdle: false }); updateIds(); return; } if (event.type === "task.started") { finishAssistantStream(); if (payload.turnId) state.turnId = payload.turnId; if (payload.threadId) state.threadId = payload.threadId; attachPendingUserToTurn(payload.turnId || state.turnId); applyStatusSnapshot(payload, { allowTerminal: false }); if (!state.currentActivity || state.currentActivity === "idle") state.currentActivity = "running"; updateLiveActivity(state.currentActivity, state.turnStartedAt, null, payload.activeFlags || [], payload.turnId || state.turnId); updateIds(); return; } if (event.type === "task.finished" || event.type === "task.cancelled") { if (!eventBelongsToCurrentTurn(payload)) return; finishAssistantStream(); const reportedStatus = payload.turnStatus ?? payload.status?.turnStatus ?? payload.status?.status ?? payload.status ?? payload.executionStatus?.turnStatus ?? payload.executionStatus?.status ?? payload.executionStatus; const finalStatus = event.type === "task.cancelled" ? "interrupted" : normalizeActivityStatus(reportedStatus, "completed"); const finishedTurnId = eventTurnId(payload) || state.turnId || ""; applyStatusSnapshot({ ...payload, turnStatus: finalStatus }, { allowTerminal: false }); state.pendingUserText = ""; const worked = workedDurationFor(payload, null); stopTurnClock(finalStatus, payload.durationMs, payload.completedAtMs, worked); appendCompletedTurnDivider(finishedTurnId, finalStatus, worked ?? state.lastWorkedDurationMs ?? payload.durationMs ?? state.lastTurnDurationMs); reconcileTurnDividers(); if (finishedTurnId) state.retiredTurnIds.add(finishedTurnId); if (state.retiredTurnIds.size > 100) state.retiredTurnIds.delete(state.retiredTurnIds.values().next().value); if (payload.threadId) state.threadId = payload.threadId; state.pendingUserText = ""; state.turnId = ""; updateIds(); return; } if (event.type === "session.created" && payload.thread?.id) { state.threadId = payload.thread.id; if (state.syncedThreadId === null || state.syncedThreadId === payload.thread.id) { prepareForSessionSnapshot(payload.thread.id); applySessionMetadata({ ...(isRecord(payload.metadata) ? payload.metadata : {}), ...payload }, payload); } else { setConversationStatus("正在加载会话", "active"); } updateIds(); return; } if (event.type === "session.snapshot") { state.awaitingSnapshot = false; state.appReady = true; const snapshotThreadId = payload.threadId || ""; applyControlModeSnapshot(payload.metadata); const waitingForSession = payload.state === "waiting_for_host" || (isRecord(payload.metadata) && payload.metadata.waitingForSession === true); const eventSnapshotSeq = finiteNumber(payload.latestSeq, event.seq); if (eventSnapshotSeq !== null) state.lastSnapshotSeq = eventSnapshotSeq; const adapterName = payload.metadata && payload.metadata.adapter; if (adapterName === "codex-ipc-follower") setAttachMode(true); else if (adapterName) setAttachMode(false); const hasProjection = typeof payload.outputTail === "string" || Array.isArray(payload.messages); const projectionCommitted = hasProjection && commitOutputProjection( snapshotThreadId, typeof payload.outputTail === "string" ? payload.outputTail : "", payload.messages, { historyComplete: snapshotHistoryComplete(payload, payload.state), authoritativeSnapshot: true, }, ); const appliesToView = projectionCommitted || snapshotThreadId === state.syncedThreadId; if (!appliesToView) { // Keep the retained transcript and loading state until a matching, // non-placeholder snapshot is available for the selected thread. updateIds(); return; } prepareForSessionSnapshot(snapshotThreadId); applySessionMetadata({ ...(isRecord(payload.metadata) ? payload.metadata : {}), ...payload }, payload.state); if (Array.isArray(payload.subagents)) { state.subagents = payload.subagents; renderSubagents(); } else if (isRecord(payload.state) && Array.isArray(payload.state.subagents)) { state.subagents = payload.state.subagents; renderSubagents(); } if (payload.threadId !== undefined) state.threadId = payload.threadId || ""; if (payload.turnId !== undefined) state.turnId = payload.turnId || ""; applyStatusSnapshot({ ...payload, ...(payload.executionStatus ? { status: payload.executionStatus } : {}), turnId: payload.turnId !== undefined ? payload.turnId : state.turnId, }, { allowTerminal: true, showIdle: false }); if (payload.metadata && typeof payload.metadata.cwd === "string") $("cwdInput").value = payload.metadata.cwd; reconcileSnapshotTerminalState(payload, payload.state); if (Array.isArray(payload.pendingRequests)) { state.requests = new Map(payload.pendingRequests .filter((request) => request && request.requestId !== undefined) .map((request) => [requestKey(request.requestId), request])); state.responding.clear(); renderRequests(); } if (waitingForSession) { setConversationStatus("等待在 VS Code 中打开 Codex 会话", "active"); state.snapshotNoticeShown = false; } else if (!state.snapshotNoticeShown && state.turnStartedAt === null && (!state.currentActivity || state.currentActivity === "idle")) { setConversationStatus("ready"); state.snapshotNoticeShown = true; } updateIds(); return; } if (event.type === "session.closed") { finishAssistantStream(); state.pendingUserText = ""; state.appReady = false; state.sessions = []; state.sessionFocusedId = ""; state.sessionSelectedThreadId = ""; state.threadId = ""; state.turnId = ""; const closedTitle = $("threadTitle"); if (closedTitle) closedTitle.textContent = "Codex"; state.turnStartedAt = null; state.turnWorkStartedAt = null; state.finalAssistantStartedAt = null; state.workedDurationMs = null; state.lastWorkedDurationMs = null; state.currentActivity = "idle"; state.currentActivityStartedAt = null; state.subagents = []; resetSessionModelMetadata(); renderSubagents(); updateLiveActivity("idle"); state.sessionSwitching = false; state.sessionSelectCommandId = ""; finishSessionSwitchContext(); setConversationStatus("会话已关闭", "warning"); state.sessionListError = "VS Code 会话已关闭"; renderSessionPicker(); updateIds(); return; } if (event.type === "app.notification") { if (payload.method === "thread/started" && payload.params?.thread?.id) state.threadId = payload.params.thread.id; if (payload.method === "turn/started" && payload.params?.turn?.id) { state.turnId = payload.params.turn.id; attachPendingUserToTurn(state.turnId); } if (payload.method === "turn/completed") { state.turnId = ""; finishAssistantStream(); } if (payload.text) { finishAssistantStream(); appendOutput(payload.text); } else if (["thread/started", "turn/started", "turn/completed", "thread/status/changed"].includes(payload.method)) appendOutput(`${payload.method}`, "meta"); updateIds(); return; } if (event.type === "thread.started" && payload.params?.thread?.id) { state.threadId = payload.params.thread.id; updateIds(); return; } if (event.type === "turn.started" && payload.params?.turn?.id) { state.turnId = payload.params.turn.id; attachPendingUserToTurn(state.turnId); updateIds(); return; } if (event.type === "turn.completed") { finishAssistantStream(); const completedTurnId = eventTurnId(payload) || state.turnId || ""; const status = normalizeActivityStatus(payload.status, "completed"); const worked = workedDurationFor(payload, null); stopTurnClock(status, payload.durationMs, payload.completedAtMs, worked); appendCompletedTurnDivider(completedTurnId, status, worked ?? state.lastWorkedDurationMs ?? payload.durationMs ?? state.lastTurnDurationMs); reconcileTurnDividers(); state.turnId = ""; updateIds(); return; } if (event.type === "command.result") { const commandId = payload.commandId; // The relay sends a sequenced event to every subscriber and a direct // acknowledgement to the originating browser. Render a result once. if (commandId) { const key = String(commandId); if (state.commandResults.has(key)) return; state.commandResults.add(key); if (state.commandResults.size > 2_000) state.commandResults.delete(state.commandResults.values().next().value); } const commandMethodName = sessionCommandMethod(payload.method); if (commandMethodName === "control/mode/set") { state.modeCommandId = ""; if (!payload.ok) { clearControlModeRequest(); setConversationStatus(sessionErrorMessage(payload, "控制模式切换失败"), "warning"); } else if (state.modeSwitching) { // The command result is only an acknowledgement. Keep the requested // segment pending until a newer authoritative snapshot supplies the // resulting modeEpoch and capabilities. setConversationStatus("正在切换控制模式", "active"); } updateIds(); return; } if (commandMethodName === "session/list" || commandMethodName === "thread/list") { if (!payload.ok) { state.sessionListLoading = false; state.sessionListError = eventMessage(payload); state.sessionListError = sessionErrorMessage(payload, state.sessionListError); renderSessionPicker(); } else { applySessionListResult(payload.result ?? payload); } return; } if (commandMethodName === "session/select" || commandMethodName === "thread/select") { if (!payload.ok) { state.sessionListError = failSessionSwitch(payload, eventMessage(payload)); renderSessionPicker(); setConversationStatus(state.sessionListError, "warning"); requestRefresh(); } else { applySessionSelectResult(payload.result ?? payload); } return; } if (commandMethodName === "session/new" || commandMethodName === "thread/new") { state.newSessionCommandId = ""; if (!payload.ok) { const detail = eventMessage(payload); setConversationStatus( uiLocale() === "en-US" ? `Unable to create a new conversation: ${detail}` : `新会话创建失败:${detail}`, "warning", ); } else { setConversationStatus("新会话已在 VS Code 中打开", "ready"); // The official command opens the new panel asynchronously. Give the // host a short window to publish its rollout/owner, then refresh the // same history menu so it can be selected without leaving the web UI. openSessionHistory(); let refreshAttempts = 0; const refreshNewSession = () => { if (!state.sessionPickerOpen || refreshAttempts >= 4) return; refreshAttempts += 1; if (!state.sessionListLoading) requestSessionList(); setTimeout(refreshNewSession, 700); }; setTimeout(refreshNewSession, 500); } updateIds(); return; } if (payload.method === "thread/settings/update") { state.modelUpdatePending = false; if (!payload.ok) { const detail = eventMessage(payload); appendOutput( uiLocale() === "en-US" ? `Unable to update model settings: ${detail}` : `模型设置更新失败:${detail}`, "error", ); } else setConversationStatus(t("模型设置已更新"), "ready"); renderModelPicker(); return; } if (!payload.ok) { const methodLabel = payload.method || t("命令"); const uncertainty = payload.uncertain ? uiText("(执行状态未知,请等待主机恢复)", " (execution status unknown; wait for the host to recover)") : ""; appendOutput(`${methodLabel}: ${JSON.stringify(payload.error)}${uncertainty}`, "error"); } else { const result = payload.result || {}; if (payload.method === "thread/start" && result.thread?.id) state.threadId = result.thread.id; if (payload.method === "turn/start" && result.turn?.id) state.turnId = result.turn.id; if (payload.method === "turn/start" && result.turn?.id) attachPendingUserToTurn(result.turn.id); if (payload.method === "thread/start") { applySessionMetadata({ ...result, ...(isRecord(result.thread) ? result.thread : {}) }, result); } finishAssistantStream(); appendOutput(`${payload.method || "命令"} 完成`, "meta"); updateIds(); } } } function handleMessage(message) { if (message.type === "auth.ok") { state.role = message.role; setAuthRequired(message.authRequired); $("roleBadge").textContent = message.role; $("roleBadge").className = `badge ${message.role === "operator" ? "" : "warning"}`; setConnection("pending", "同步中"); state.awaitingSnapshot = true; sendFrame({ type: "subscribe", fromSeq: state.lastSeq }); return; } // A host event can legitimately have the same type as a relay control // frame (notably `session.snapshot`). Route the envelope by `kind` first // so its payload is not mistaken for the compact control shape below. if (message.kind === "event") { handleEvent(message); return; } if (message.type === "session.snapshot") { state.awaitingSnapshot = false; const snapshot = message.snapshot || {}; const appState = snapshot.state || {}; const snapshotThreadId = appState.activeThreadId || ""; const controlMetadata = { ...(isRecord(snapshot.metadata) ? snapshot.metadata : {}), ...(isRecord(appState.sessionMetadata) ? appState.sessionMetadata : {}), }; applyControlModeSnapshot(controlMetadata); const waitingForSession = controlMetadata.waitingForSession === true || (!snapshotThreadId && controlMetadata.attachReady === false); $("appState").textContent = appStatusLabel(appState.app); $("factApp").textContent = appState.app ? appStatusLabel(appState.app) : "-"; $("factClients").textContent = String((snapshot.clients || []).length); state.appReady = appState.app === "ready" || appState.initialized === true; if (appState.mode === "host") setAttachMode(true); if (appState.mode === "embedded") setAttachMode(false); const snapshotSeq = finiteNumber(snapshot.latestSeq); if (snapshotSeq !== null) { state.lastSeq = snapshotSeq; state.lastSnapshotSeq = snapshotSeq; } // The control snapshot is authoritative for routing, but its transcript // fields can still be placeholders while VS Code is loading history. // Route target events immediately without replacing the retained view. if (snapshotThreadId || state.syncedThreadId === null) state.threadId = snapshotThreadId; state.turnId = appState.activeTurnId || ""; state.requests = new Map((snapshot.pendingRequests || []).map((request) => [requestKey(request.requestId), request])); state.responding.clear(); // `subscribe` replays buffered events before sending this control // snapshot. The snapshot is authoritative, so reconcile once at the // end of the replay instead of leaving transient duplicate bubbles. const hasProjection = typeof snapshot.outputTail === "string" || Array.isArray(snapshot.messages); const projectionCommitted = hasProjection && commitOutputProjection( snapshotThreadId, typeof snapshot.outputTail === "string" ? snapshot.outputTail : "", snapshot.messages, { historyComplete: snapshotHistoryComplete(snapshot, snapshot.metadata, appState), authoritativeSnapshot: true, }, ); const appliesToView = projectionCommitted || snapshotThreadId === state.syncedThreadId; if (appliesToView) { prepareForSessionSnapshot(snapshotThreadId); applySessionMetadata({ ...controlMetadata, ...appState, }, appState); if (Array.isArray(snapshot.subagents)) { state.subagents = snapshot.subagents; renderSubagents(); } else if (Array.isArray(appState.subagents)) { state.subagents = appState.subagents; renderSubagents(); } applyStatusSnapshot({ ...(snapshot.status ? { status: snapshot.status } : {}), ...(snapshot.executionStatus ? { status: snapshot.executionStatus } : {}), ...(snapshot.state && typeof snapshot.state === "object" ? snapshot.state : {}), turnId: state.turnId, }, { allowTerminal: true, showIdle: false }); reconcileSnapshotTerminalState(snapshot, appState); } else if (state.sessionSwitching || hasVisibleOutputProjection()) { setConversationStatus("正在加载会话", "active"); } renderRequests(); updateIds(); setConnection("online", "已连接"); if (waitingForSession) { setConversationStatus("等待在 VS Code 中打开 Codex 会话", "active"); state.snapshotNoticeShown = false; } else { $("outputHint").textContent = `${appStatusLabel(appState.app || "app-server")} / ${state.role}`; } if (state.sessionPickerOpen && state.appReady) requestSessionList(); return; } if (message.type === "resync.required") { appendOutput("事件窗口已过期,请以当前快照为准", "error"); return; } if (message.type === "response.accepted") { const key = resolveRequestKey(message.requestId); state.responding.delete(key); state.requests.delete(key); renderRequests(); appendOutput(`请求 #${message.requestId} 已提交`, "meta"); return; } if (message.type === "response.pending") { appendOutput(`请求 #${message.requestId} 已发送,等待 VS Code 主机确认`, "meta"); return; } if (message.type === "command.accepted") return; if (message.type === "command.rejected" || message.type === "response.rejected") { if (message.requestId !== undefined) state.responding.delete(resolveRequestKey(message.requestId)); const rejectedId = String(message.commandId || ""); if (rejectedId && rejectedId === String(state.modeCommandId || "")) { clearControlModeRequest(); setConversationStatus(sessionErrorMessage(message, "控制模式切换失败"), "warning"); updateIds(); return; } if (rejectedId && rejectedId === String(state.newSessionCommandId || "")) { state.newSessionCommandId = ""; const detail = message.message || message.code || t("未知错误"); setConversationStatus( uiLocale() === "en-US" ? `Unable to create a new conversation: ${detail}` : `新会话创建失败:${detail}`, "warning", ); updateIds(); return; } if (rejectedId && rejectedId === String(state.sessionListCommandId || "")) { state.sessionListLoading = false; state.sessionListCommandId = ""; state.sessionListError = sessionErrorMessage(message, "无法读取会话"); renderSessionPicker(); return; } if (rejectedId && rejectedId === String(state.sessionSelectCommandId || "")) { state.sessionListError = failSessionSwitch(message, "会话切换失败"); renderSessionPicker(); setConversationStatus(state.sessionListError, "warning"); requestRefresh(); return; } appendOutput(`${message.code}: ${message.message}`, "error"); renderRequests(); return; } if (message.type === "command.result") { handleEvent({ type: "command.result", seq: message.seq, payload: message }); return; } if (message.type === "error") appendOutput(message.message || "relay error", "error"); } function embeddedSocketUrl(value) { if (!embeddedInAether) return ""; const raw = String(value || "").trim(); if (!raw) return ""; try { const url = new URL(raw, location.href); const expectedProtocol = location.protocol === "https:" ? "wss:" : "ws:"; if (url.origin !== `${location.protocol}//${location.host}` && url.origin !== `${expectedProtocol}//${location.host}`) { throw new Error(t("云端连接地址必须与当前页面同源")); } if (url.protocol === "http:") url.protocol = "ws:"; if (url.protocol === "https:") url.protocol = "wss:"; if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new Error(t("云端连接配置无效")); return url.href; } catch (error) { appendOutput(error?.message || t("云端连接配置无效"), "error"); embedBridge.reportState("error", { code: "invalid_ws_url", message: error?.message || "invalid ws url" }); return ""; } } function requestEmbedTicket(reason = "missing") { if (!embeddedInAether || state.embedStopped || state.embedTicketRequested) return; state.embedTicketRequested = true; setConversationStatus(t("正在获取新的连接凭证"), "active"); embedBridge.requestTicket({ reason, deviceId: state.embedDeviceId || undefined }); } function disconnectEmbedded(reason = "parent") { if (!embeddedInAether) return; state.embedStopped = true; state.embedTicket = ""; state.embedTicketRequested = false; clearTimeout(state.reconnectTimer); state.reconnectTimer = null; const socket = state.ws; state.ws = null; if (socket && socket.readyState <= WebSocket.OPEN) socket.close(1000, reason); setConnection("offline", "云端连接已断开"); setConversationStatus(t("父页面已断开连接"), "warning"); } function applyEmbedConnection(message) { if (!embeddedInAether) return; if (message.locale) i18n?.setLocale?.(message.locale, { persist: false }); const ticket = typeof message.ticket === "string" ? message.ticket.trim() : ""; const wsUrl = embeddedSocketUrl(message.wsUrl || "/api/vscodex/ws"); if (!ticket || !wsUrl) { appendOutput(t("云端连接配置无效"), "error"); embedBridge.reportState("error", { code: "invalid_connection_config", message: "ticket and wsUrl are required" }); requestEmbedTicket("invalid"); return; } state.embedStopped = false; state.embedTicketRequested = false; state.embedTicket = ticket; state.embedWsUrl = wsUrl; state.embedDeviceId = typeof message.deviceId === "string" ? message.deviceId : state.embedDeviceId; setAuthRequired(true); document.body.classList.add("embed-aether"); setConversationStatus(t("正在连接云端会话"), "active"); if (state.ws && state.ws.readyState <= WebSocket.OPEN) { const socket = state.ws; state.ws = null; socket.close(1000, "connection replaced"); } connect(); } function connect() { if (state.ws && state.ws.readyState <= WebSocket.OPEN) return; if (embeddedInAether) { if (state.embedStopped) return; if (!state.embedTicket || !state.embedWsUrl) { requestEmbedTicket("missing"); return; } state.token = state.embedTicket; } else state.token = $("tokenInput").value.trim(); if (!state.token && state.authRequired === true) { appendOutput("当前 relay 需要 token", "error"); return; } setConnection("pending", "连接中"); const protocol = location.protocol === "https:" ? "wss:" : "ws:"; let socket; try { socket = new WebSocket(embeddedInAether ? state.embedWsUrl : `${protocol}//${location.host}/ws`); } catch (error) { if (embeddedInAether) { state.embedTicket = ""; requestEmbedTicket("socket-error"); } appendOutput(error?.message || "WebSocket 未连接", "error"); return; } const connectionTicket = embeddedInAether ? state.embedTicket : ""; if (embeddedInAether) { state.embedTicket = ""; state.token = ""; } state.ws = socket; socket.addEventListener("open", () => { if (state.ws !== socket) return; // Send a hello even when local auth is disabled so the relay can assign // the browser role without requiring a dummy password. socket.send(JSON.stringify({ v: 1, kind: "hello", clientType: "web", protocol: 1 })); // A loopback relay authenticates on hello. Do not send a stale token as // a second frame after that handshake, because it is already complete. const token = embeddedInAether ? connectionTicket : state.token; if (token && state.authRequired !== false) socket.send(JSON.stringify({ type: "auth", token })); }); socket.addEventListener("message", (event) => { if (state.ws !== socket) return; try { handleMessage(JSON.parse(event.data)); } catch { appendOutput("收到无法解析的 relay 消息", "error"); } }); socket.addEventListener("close", (event) => { if (state.ws !== socket) return; setConnection("offline", event.code === 1008 ? "认证失败,准备重连" : "准备重连"); state.appReady = false; state.turnId = ""; state.sessionListLoading = false; state.sessionListCommandId = ""; state.newSessionCommandId = ""; clearControlModeRequest(); state.sessions = []; state.sessionFocusedId = ""; // A transport reconnect may resume the same owner hand-off. Preserve // its previous/target identities so the next control placeholder cannot // be mistaken for an authoritative empty target transcript. if (!state.sessionSwitching) state.sessionSelectedThreadId = ""; state.sessionListError = "等待 relay 连接"; updateIds(); state.ws = null; renderSessionPicker(); clearTimeout(state.reconnectTimer); state.reconnectTimer = null; if (embeddedInAether) { if (!state.embedStopped) requestEmbedTicket(event.code === 1008 ? "ticket-rejected" : "disconnected"); } else state.reconnectTimer = setTimeout(connect, 3000); }); socket.addEventListener("error", () => { // The close handler owns retrying. Keep transient socket errors in the // connection indicator instead of adding noisy messages to the turn. if (state.ws === socket) setConnection("offline", "重连中"); }); } function closePopovers() { for (const id of ["panelMenu", "detailsPopover", "sessionPicker", "composerPlusMenu"]) { const element = $(id); if (element) element.hidden = true; } state.sessionPickerOpen = false; state.sessionSearch = ""; state.sessionFocusedId = ""; $("sessionPickerButton")?.setAttribute("aria-expanded", "false"); const sessionSearchInput = $("sessionSearchInput"); if (sessionSearchInput) { sessionSearchInput.value = ""; sessionSearchInput.setAttribute("aria-expanded", "false"); sessionSearchInput.setAttribute("aria-activedescendant", ""); } $("sessionSearchClear")?.setAttribute("hidden", ""); setModelMenu(false); setPermissionMenu(false); setUsageMenu(false); $("composerPlusButton")?.setAttribute("aria-expanded", "false"); const confirm = $("permissionConfirm"); if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; } } function requestRefresh() { if (state.ws && state.ws.readyState === WebSocket.OPEN) { try { sendFrame({ type: "subscribe", fromSeq: state.lastSeq }); } catch { connect(); } } else connect(); } document.querySelectorAll("[data-panel-action]").forEach((button) => { button.addEventListener("click", () => { const action = button.dataset.panelAction; if (action === "back" || action === "history") { setSessionPicker(!state.sessionPickerOpen); return; } if (action === "new-session") { requestNewSession(); return; } if (action === "expand") { document.body.classList.toggle("panel-expanded"); return; } if (action === "close") { document.body.classList.add("panel-hidden"); const restore = $("restorePanel"); if (restore) restore.hidden = false; closePopovers(); return; } if (action === "refresh") { closePopovers(); requestRefresh(); return; } if (action === "menu") { const menu = $("panelMenu"); const details = $("detailsPopover"); if (details) details.hidden = true; if (menu) menu.hidden = !menu.hidden; return; } if (action === "settings") { const details = $("detailsPopover"); const menu = $("panelMenu"); if (menu) menu.hidden = true; if (details) details.hidden = !details.hidden; updateIds(); } }); }); $("sessionPickerButton")?.addEventListener("click", () => { setSessionPicker(!state.sessionPickerOpen); }); $("controlModeSwitch")?.querySelectorAll("[data-control-mode]").forEach((button) => { button.addEventListener("click", () => requestControlMode(button.dataset.controlMode)); }); $("sessionPickerRefresh")?.addEventListener("click", (event) => { event.stopPropagation(); requestSessionList(); }); $("sessionSearchInput")?.addEventListener("input", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLInputElement)) return; state.sessionSearch = input.value; state.sessionFocusedId = ""; renderSessionPicker(); }); $("sessionSearchInput")?.addEventListener("keydown", handleSessionPickerKeydown); $("sessionList")?.addEventListener("keydown", handleSessionPickerKeydown); $("sessionSearchClear")?.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); state.sessionSearch = ""; state.sessionFocusedId = ""; renderSessionPicker(); $("sessionSearchInput")?.focus(); }); $("restorePanel")?.addEventListener("click", () => { document.body.classList.remove("panel-hidden"); $("restorePanel").hidden = true; }); $("panelMenu")?.querySelectorAll("[data-menu-action]").forEach((button) => { button.addEventListener("click", (event) => { if (button.dataset.menuAction === "sessions") { // The document-level outside-click handler runs in the same bubble // phase. Keep the picker open when it is launched from this menu. event.stopPropagation(); closePopovers(); setSessionPicker(true); return; } else if (button.dataset.menuAction === "clear") { renderEmptyOutput(); state.outputSynced = false; } else if (button.dataset.menuAction === "refresh") requestRefresh(); else if (button.dataset.menuAction === "expand") document.body.classList.toggle("panel-expanded"); else if (button.dataset.menuAction === "close") { document.body.classList.add("panel-hidden"); const restore = $("restorePanel"); if (restore) restore.hidden = false; } closePopovers(); }); }); $("detailsPopover")?.querySelectorAll("[data-settings-action]").forEach((button) => { button.addEventListener("click", (event) => { event.stopPropagation(); const action = button.dataset.settingsAction; closePopovers(); if (action === "model") { setModelMenu(true); $("modelPickerButton")?.focus(); } else if (action === "permission") { setPermissionMenu(true); $("permissionChip")?.focus(); } }); }); document.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof Element)) return; if (!target.closest(".model-picker")) setModelMenu(false); if (!target.closest(".permission-menu, #permissionChip")) setPermissionMenu(false); if (!target.closest(".usage-menu, #usageButton")) setUsageMenu(false); if (!target.closest(".composer-plus-menu, #composerPlusButton")) { const menu = $("composerPlusMenu"); if (menu) menu.hidden = true; $("composerPlusButton")?.setAttribute("aria-expanded", "false"); } if (!target.closest("[data-panel-action], .panel-popover, .composer-popover, .composer-icon-button, .permission-chip, .usage-button, #sessionPickerButton")) closePopovers(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.sessionPickerOpen) { event.preventDefault(); closePopovers(); $("sessionPickerButton")?.focus(); } }); $("tokenInput").addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); connect(); } }); $("tokenInput").addEventListener("change", connect); $("localeSelect")?.addEventListener("change", (event) => { if (!embeddedInAether) i18n?.setLocale?.(event.target.value, { persist: true }); }); window.addEventListener("aether-vscodex:locale", () => { for (const activity of state.activities.values()) { renderActivityText(activity); refreshActivity(activity); } for (const [turnId, divider] of state.turnDividers) { const label = divider.querySelector(".turn-divider-label"); if (!label) continue; const duration = finiteNumber(divider.dataset.durationMs); label.textContent = turnDividerLabel(divider.dataset.status || "completed", duration); } setAttachMode(state.attachMode); setAuthRequired(state.authRequired); renderSessionPicker(); renderModelPicker(); renderPermissionMenu(); renderUsage(); renderSubagents(); updateIds(); renderRequests(); for (const checkbox of document.querySelectorAll(".task-list-item input[type=checkbox]")) { checkbox.setAttribute("aria-label", t(checkbox.checked ? "已完成" : "未完成")); } }); $("clearOutputButton").addEventListener("click", () => { renderEmptyOutput(); state.outputSynced = false; }); $("startThreadButton").addEventListener("click", () => { if (state.attachMode) return; const params = { cwd: $("cwdInput").value.trim() || undefined, sandbox: $("sandboxInput").value, approvalPolicy: $("approvalInput").value }; if ($("modelInput").value.trim()) params.model = $("modelInput").value.trim(); command("thread/start", params); }); $("startTurnButton").addEventListener("click", () => { const text = composerText(); if (!text || !state.threadId) return; command("turn/start", { threadId: state.threadId, input: [{ type: "text", text, text_elements: [] }], ...(state.currentModel ? { model: state.currentModel } : {}), ...currentEffortParams(), }); clearComposer(); }); $("steerButton").addEventListener("click", () => { const text = composerText(); if (!text || !state.threadId || !state.turnId) return; command("turn/steer", { threadId: state.threadId, expectedTurnId: state.turnId, input: [{ type: "text", text, text_elements: [] }], ...(state.currentModel ? { model: state.currentModel } : {}), ...currentEffortParams(), }); clearComposer(); }); $("interruptButton").addEventListener("click", () => { if (state.threadId && state.turnId) command("turn/interrupt", { threadId: state.threadId, turnId: state.turnId }); }); $("messageInput").addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.shiftKey || event.isComposing) return; event.preventDefault(); const button = state.turnId ? $("steerButton") : $("startTurnButton"); if (button && !button.disabled) button.click(); }); $("messageInput").addEventListener("input", () => { resizeComposer(); updateIds(); }); $("messageInput").addEventListener("paste", (event) => { event.preventDefault(); const text = event.clipboardData?.getData("text/plain") || ""; if (!text) return; const editor = $("messageInput"); const selection = window.getSelection(); if (editor && selection && selection.rangeCount) { const range = selection.getRangeAt(0); if (editor.contains(range.commonAncestorContainer)) { range.deleteContents(); const node = document.createTextNode(text); range.insertNode(node); range.setStartAfter(node); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } else editor.append(document.createTextNode(text)); } else editor?.append(document.createTextNode(text)); resizeComposer(); updateIds(); }); $("modelPickerButton")?.addEventListener("click", (event) => { event.stopPropagation(); const menu = $("modelMenu"); setPermissionMenu(false); setUsageMenu(false); const plus = $("composerPlusMenu"); if (plus) plus.hidden = true; $("composerPlusButton")?.setAttribute("aria-expanded", "false"); setModelMenu(Boolean(menu?.hidden)); }); $("modelAdvancedToggle")?.addEventListener("click", (event) => { event.stopPropagation(); state.modelAdvancedOpen = !state.modelAdvancedOpen; renderModelPicker(); if (state.modelAdvancedOpen) $("modelAdvancedBack")?.focus(); else $("modelPowerSlider")?.focus(); }); $("modelAdvancedBack")?.addEventListener("click", (event) => { event.stopPropagation(); state.modelAdvancedOpen = false; renderModelPicker(); $("modelPowerSlider")?.focus(); }); $("modelPowerSlider")?.addEventListener("input", (event) => { selectPowerIndex(event.currentTarget.value); }); $("composerPlusButton")?.addEventListener("click", (event) => { event.stopPropagation(); const menu = $("composerPlusMenu"); if (!menu) return; const next = menu.hidden; menu.hidden = !next; $("composerPlusButton").setAttribute("aria-expanded", String(next)); if (next) { setPermissionMenu(false); setUsageMenu(false); setModelMenu(false); } }); $("permissionChip")?.addEventListener("click", (event) => { event.stopPropagation(); const menu = $("permissionMenu"); setPermissionMenu(Boolean(menu?.hidden)); if (!menu?.hidden) { const plus = $("composerPlusMenu"); if (plus) plus.hidden = true; $("composerPlusButton")?.setAttribute("aria-expanded", "false"); setUsageMenu(false); setModelMenu(false); } }); $("usageButton")?.addEventListener("click", (event) => { event.stopPropagation(); const menu = $("usageMenu"); setPermissionMenu(false); setModelMenu(false); const plus = $("composerPlusMenu"); if (plus) plus.hidden = true; $("composerPlusButton")?.setAttribute("aria-expanded", "false"); setUsageMenu(Boolean(menu?.hidden)); if (!menu?.hidden) { setPermissionMenu(false); setModelMenu(false); } }); const insertComposerText = (text) => { const editor = $("messageInput"); if (!editor || !text) return; editor.focus(); const selection = window.getSelection(); if (selection && selection.rangeCount) { const range = selection.getRangeAt(0); if (editor.contains(range.commonAncestorContainer)) { range.deleteContents(); const node = document.createTextNode(text); range.insertNode(node); range.setStartAfter(node); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } else editor.append(document.createTextNode(text)); } else editor.append(document.createTextNode(text)); resizeComposer(); updateIds(); }; $("composerPlusMenu")?.querySelectorAll("[data-composer-action]").forEach((button) => { button.addEventListener("click", () => { const action = button.dataset.composerAction; if (action === "attach") { const input = $("attachmentInput"); if (input) { input.accept = ".txt,.md,.json,.js,.ts,.tsx,.jsx,.css,.html,.yml,.yaml,.xml,.py,.go,.rs,.java,.c,.cpp,.h"; input.click(); } } else if (action === "photo") { const input = $("attachmentInput"); if (input) { input.accept = "image/*"; input.click(); } } else if (action === "workspace") { insertComposerText("\n\n@workspace "); setConversationStatus("已添加工作区上下文", "ready"); } else if (action === "web-search") { insertComposerText("\n\n/web-search "); setConversationStatus("已添加网页搜索", "ready"); } const menu = $("composerPlusMenu"); if (menu) menu.hidden = true; $("composerPlusButton")?.setAttribute("aria-expanded", "false"); }); }); $("attachmentInput")?.addEventListener("change", async (event) => { const files = [...(event.target?.files || [])]; for (const file of files) { try { if (file.type.startsWith("image/")) { insertComposerText(`\n\n[图片附件:${file.name}]\n`); continue; } const text = await file.text(); const clipped = text.length > 80_000 ? `${text.slice(0, 80_000)}\n${t("…(文件已截断)")}` : text; const fence = "```"; insertComposerText(`\n\n### ${file.name}\n\n${fence}\n${clipped}\n${fence}\n`); } catch { setConversationStatus(`无法读取 ${file.name}`, "warning"); } } event.target.value = ""; }); $("permissionMenu")?.querySelectorAll("[data-permission-mode], [data-sandbox], [data-approval]").forEach((button) => { button.addEventListener("click", () => { if (button.dataset.permissionMode) selectPermissionMode(button.dataset.permissionMode); else if (button.dataset.sandbox) selectPermissionSetting("sandbox", button.dataset.sandbox); else if (button.dataset.approval) selectPermissionSetting("approval", button.dataset.approval); }); }); $("permissionConfirmCancel")?.addEventListener("click", () => { const confirm = $("permissionConfirm"); if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; } }); $("permissionConfirmAccept")?.addEventListener("click", () => { const confirm = $("permissionConfirm"); const mode = confirm?.dataset.pendingMode || "full"; if (confirm) { confirm.hidden = true; delete confirm.dataset.pendingMode; } applyPermissionMode(mode); }); $("subagentsToggle")?.addEventListener("click", () => { state.subagentsCollapsed = !state.subagentsCollapsed; renderSubagents(); }); $("output").addEventListener("scroll", () => updateScrollToBottom($("output")), { passive: true }); $("scrollToBottom")?.addEventListener("click", () => { const output = $("output"); if (output) output.scrollTo({ top: output.scrollHeight, behavior: "smooth" }); }); if (typeof ResizeObserver === "function") { const output = $("output"); const observedContent = new Set(); const observeTranscriptContent = () => { const children = new Set(output ? [...output.children] : []); for (const child of observedContent) { if (children.has(child)) continue; layoutObserver.unobserve(child); observedContent.delete(child); } for (const child of children) { if (observedContent.has(child)) continue; observedContent.add(child); layoutObserver.observe(child); } }; const layoutObserver = new ResizeObserver(() => { const distance = state.outputDistanceFromBottom; const following = distance <= 24; const anchorLocked = motionClock() < (state.timelineAnchorLockUntil || 0); updateScrollPadding(); // Preserve the reader's distance from the bottom when a streamed item or // the composer changes height. The official thread layout uses the same // bottom-relative anchor instead of allowing content to jump. if (output && !anchorLocked) { if (following) scrollOutput(output, true); else output.scrollTop = Math.max(0, output.scrollHeight - output.clientHeight - distance); updateScrollToBottom(output); } else if (output) updateScrollToBottom(output); observeTranscriptContent(); }); layoutObserver.observe($("messageForm")); layoutObserver.observe($("inlineRequests")); if (output) layoutObserver.observe(output); observeTranscriptContent(); if (typeof MutationObserver === "function" && output) { const childObserver = new MutationObserver(observeTranscriptContent); childObserver.observe(output, { childList: true }); } } window.addEventListener("resize", updateScrollPadding, { passive: true }); $("cwdInput").value = location.pathname === "/" ? "" : ""; renderPermissionMenu(); renderUsage(); renderSessionPicker(); resizeComposer(); updateScrollPadding(); updateScrollToBottom($("output")); updateIds(); if (embeddedInAether) { document.body.classList.add("embed-aether"); setAuthRequired(true); setConversationStatus(t("正在等待云端连接"), "active"); embedBridge.on("connect", applyEmbedConnection); embedBridge.on("context", (message) => { if (message.locale) i18n?.setLocale?.(message.locale, { persist: false }); }); embedBridge.on("disconnect", () => disconnectEmbedded("parent disconnect")); embedBridge.on("error", (message) => { const detail = typeof message.message === "string" && message.message ? message.message : t("云端连接已断开"); appendOutput(detail, "error"); setConversationStatus(detail, "warning"); }); } else { // /api/health is intentionally public and only reports capability metadata. // Probe it first so a local relay can connect automatically without a token; // Authenticated deployments wait until a token is entered in the field. fetch("./api/health", { cache: "no-store" }).then(async (response) => { let health; try { health = await response.json(); } catch { return; } setAuthRequired(health.authRequired); if (health.authRequired === false || (health.authRequired === true && $("tokenInput").value.trim())) connect(); }).catch(() => undefined); } })();