Files
Aether/aether-vscodex/public/app.js
T

8200 lines
376 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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);
}
})();