1473 lines
58 KiB
TypeScript
1473 lines
58 KiB
TypeScript
// CI/CD performance probe: web-only comment used to measure env reuse and git mirror pre-sync.
|
||
import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.ts";
|
||
import {
|
||
codeAgentRuntimePathFromMessage,
|
||
runnerTraceSummary as compactRunnerTraceSummary,
|
||
skillsSummary as compactSkillsSummary,
|
||
toolCallsSummary as compactToolCallsSummary
|
||
} from "./code-agent-facts.ts";
|
||
import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.ts";
|
||
import { computeCodeAgentComposerState } from "./composer-policy.ts";
|
||
import { classifyWorkbenchLiveStatus } from "./live-status.ts";
|
||
import { activeSessionKeyFromRuntime, sessionTabsFromConversations, sessionTabsFromMessages } from "./app-session-tabs.ts";
|
||
import { marked } from "./third_party/marked/marked.esm.js";
|
||
import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.ts";
|
||
|
||
const DEFAULT_API_TIMEOUT_MS = 4500;
|
||
const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench";
|
||
const DEFAULT_LIVE_SURFACE_TIMEOUT_MS = 12000;
|
||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1800000;
|
||
const MAX_CODE_AGENT_TIMEOUT_MS = 2400000;
|
||
const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000;
|
||
const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000;
|
||
const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000;
|
||
const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1";
|
||
const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1";
|
||
const CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY = "hwlab.workbench.codeAgentProviderProfile.v1";
|
||
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
|
||
const CODE_AGENT_PROVIDER_PROFILES = Object.freeze(["deepseek", "codex-api", "minimax-m3"]);
|
||
const CODE_AGENT_SESSION_STORAGE_KEY = "hwlab.workbench.codeAgentSession.v1";
|
||
const CODE_AGENT_SESSION_STORAGE_VERSION = 1;
|
||
const CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||
const CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT = 30;
|
||
const CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT = 80;
|
||
const CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS = 250;
|
||
const TRACE_STREAM_FALLBACK_MS = 2500;
|
||
const TRACE_POLL_INTERVAL_MS = 1000;
|
||
const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000;
|
||
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
|
||
const LIVE_SURFACE_TIMEOUT_MS = resolveTimeoutMs("liveSurfaceTimeoutMs", DEFAULT_LIVE_SURFACE_TIMEOUT_MS, { min: 5000, max: 60000 });
|
||
let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs();
|
||
let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs();
|
||
let CODE_AGENT_PROVIDER_PROFILE = resolveUserCodeAgentProviderProfile();
|
||
const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 });
|
||
const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 });
|
||
const rpcReadMethods = Object.freeze([
|
||
"system.health",
|
||
"cloud.adapter.describe"
|
||
]);
|
||
|
||
const STATUS_LABELS = Object.freeze({
|
||
active: "活动",
|
||
available: "可用",
|
||
blocked_after_cloud_api: "cloud-api 后阻塞",
|
||
blocked: "待处理",
|
||
canceled: "已取消",
|
||
completed: "完成",
|
||
connected: "已连接",
|
||
degraded: "降级",
|
||
disabled: "禁用",
|
||
entry: "入口",
|
||
failed: "失败",
|
||
healthy: "健康",
|
||
live: "实况",
|
||
ok: "正常",
|
||
open: "未关闭",
|
||
pass: "通过",
|
||
pending: "等待",
|
||
running: "处理中",
|
||
timeout: "超时",
|
||
error: "错误",
|
||
probing: "探测中",
|
||
ready: "就绪",
|
||
recorded: "已记录",
|
||
sent: "已发送",
|
||
requires: "依赖",
|
||
source: "来源",
|
||
unverified: "未验证",
|
||
"dry-run": "演练",
|
||
"dev-live": "实况已验证"
|
||
});
|
||
|
||
|
||
const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["codex-stdio"]);
|
||
const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]);
|
||
const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner";
|
||
const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived";
|
||
const CODEX_APP_SERVER_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session";
|
||
const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
|
||
const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([
|
||
"codex_cli_binary_missing",
|
||
"runner_lifecycle_missing",
|
||
"stdio_protocol_not_wired",
|
||
"codex_stdio_supervisor_disabled",
|
||
"not-codex-stdio",
|
||
"not-write-capable",
|
||
"process-local-session-registry"
|
||
]);
|
||
const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
|
||
const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1";
|
||
const LEFT_SIDEBAR_DEFAULT_WIDTH = 92;
|
||
const LEFT_SIDEBAR_MIN_WIDTH = 72;
|
||
const LEFT_SIDEBAR_MAX_WIDTH = 180;
|
||
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
|
||
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
|
||
const RIGHT_SIDEBAR_MAX_WIDTH = 740;
|
||
const SCROLL_BOTTOM_PIN_PX = 24;
|
||
const SCROLL_USER_ACTIVITY_MS = 700;
|
||
const MESSAGE_MARKDOWN_STATUSES = Object.freeze(["completed", "source"]);
|
||
|
||
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
||
let rpcSequence = 0;
|
||
|
||
const el = {
|
||
loginShell: byId("login-shell"),
|
||
shell: query("[data-app-shell]"),
|
||
logoutButton: byId("logout-button"),
|
||
leftSidebarToggle: byId("left-sidebar-toggle"),
|
||
rightSidebarToggle: byId("right-sidebar-toggle"),
|
||
leftSidebarResize: byId("left-sidebar-resize"),
|
||
rightSidebarResize: byId("right-sidebar-resize"),
|
||
routePath: byId("route-path"),
|
||
liveStatus: byId("live-status"),
|
||
liveDetail: byId("live-detail"),
|
||
liveBuildSummary: byId("live-build-summary"),
|
||
liveBuildToggle: byId("live-build-toggle"),
|
||
liveBuildLatest: byId("live-build-latest"),
|
||
liveBuildList: byId("live-build-list"),
|
||
agentChatStatus: byId("agent-chat-status"),
|
||
codeAgentSummary: byId("code-agent-summary"),
|
||
codeAgentSummaryIcon: byId("code-agent-summary-icon"),
|
||
codeAgentSummaryLabel: byId("code-agent-summary-label"),
|
||
codeAgentSummaryCapability: byId("code-agent-summary-capability"),
|
||
codeAgentSummaryTrace: byId("code-agent-summary-trace"),
|
||
codeAgentSummaryDetail: byId("code-agent-summary-detail"),
|
||
sessionTabs: byId("session-tabs"),
|
||
sessionCreate: byId("session-create"),
|
||
sessionDelete: byId("session-delete"),
|
||
sessionStatus: byId("session-status"),
|
||
conversationList: byId("conversation-list"),
|
||
gateSourceStatus: byId("gate-source-status"),
|
||
gateStatusFilter: byId("gate-status-filter"),
|
||
gateSearch: byId("gate-search"),
|
||
gateRefresh: byId("gate-refresh"),
|
||
gateReviewBody: byId("gate-review-body"),
|
||
skillStatus: byId("skill-status"),
|
||
skillRefresh: byId("skill-refresh"),
|
||
skillUploadForm: byId("skill-upload-form"),
|
||
skillUploadInput: byId("skill-upload-input"),
|
||
skillUploadButton: byId("skill-upload-button"),
|
||
skillAssembly: byId("skill-assembly"),
|
||
skillList: byId("skill-list"),
|
||
skillDetailTitle: byId("skill-detail-title"),
|
||
skillTree: byId("skill-tree"),
|
||
skillPreview: byId("skill-preview"),
|
||
commandForm: byId("command-form"),
|
||
commandInput: byId("command-input"),
|
||
codeAgentProviderProfile: byId("code-agent-provider-profile"),
|
||
codeAgentTimeout: byId("code-agent-timeout"),
|
||
gatewayShellTimeout: byId("gateway-shell-timeout"),
|
||
commandSend: byId("command-send"),
|
||
commandClear: byId("command-clear"),
|
||
devicePodSelect: byId("device-pod-select"),
|
||
devicePodStatusTag: byId("device-pod-status-tag"),
|
||
devicePodSummary: byId("device-pod-summary"),
|
||
devicePodId: byId("device-pod-id"),
|
||
devicePodTarget: byId("device-pod-target"),
|
||
devicePodProfile: byId("device-pod-profile"),
|
||
devicePodFreshness: byId("device-pod-freshness"),
|
||
devicePodInterfaces: byId("device-pod-interfaces"),
|
||
deviceEventFollow: byId("device-event-follow"),
|
||
deviceEventJump: byId("device-event-jump"),
|
||
deviceEventScroll: byId("device-event-scroll"),
|
||
deviceEventText: byId("device-event-text"),
|
||
deviceDetailDialog: byId("device-detail-dialog"),
|
||
deviceDetailTitle: byId("device-detail-title"),
|
||
deviceDetailBody: byId("device-detail-body"),
|
||
helpContent: byId("help-content"),
|
||
helpStatus: byId("help-status")
|
||
};
|
||
|
||
let activeWorkbenchDialog = null;
|
||
|
||
const workbenchAuthSession = await ensureWorkbenchAuth({
|
||
loginShell: el.loginShell,
|
||
appShell: el.shell
|
||
});
|
||
const workbenchActorId = nonEmptyString(workbenchAuthSession?.user?.id ?? workbenchAuthSession?.actor?.id ?? workbenchAuthSession?.user?.username);
|
||
|
||
const state = {
|
||
conversationId: null,
|
||
sessionId: null,
|
||
threadId: null,
|
||
activeTraceId: null,
|
||
workspaceId: null,
|
||
workspaceRevision: null,
|
||
workspaceUpdatedAt: null,
|
||
codeAgentAvailability: null,
|
||
chatMessages: [],
|
||
activeSessionKey: null,
|
||
traceStreams: new Map(),
|
||
traceDetailsOpen: new Map(),
|
||
conversationRenderVersion: 0,
|
||
conversationScrollUserActiveUntil: 0,
|
||
conversationScrollPosition: { top: 0, left: 0 },
|
||
traceScrollPositions: new Map(),
|
||
traceScrollPinnedToBottom: new Map(),
|
||
traceScrollUserActiveUntil: new Map(),
|
||
traceBodyScrollPositions: new Map(),
|
||
traceBodyScrollUserActiveUntil: new Map(),
|
||
traceProgrammaticScrollWrites: 0,
|
||
fullTraceReplayInFlight: new Set(),
|
||
resultReconciliationInFlight: new Set(),
|
||
canceledTraces: new Set(),
|
||
currentRequest: null,
|
||
sessionStatus: null,
|
||
sessionList: {
|
||
loading: false,
|
||
error: null,
|
||
items: [],
|
||
loadedAt: null,
|
||
pendingAction: null
|
||
},
|
||
chatPending: false,
|
||
liveSurface: null,
|
||
gateDiagnostics: {
|
||
loading: false,
|
||
payload: null,
|
||
rows: [],
|
||
error: null,
|
||
loadedAt: null
|
||
},
|
||
skills: {
|
||
loading: false,
|
||
uploadPending: false,
|
||
detailLoading: false,
|
||
previewLoading: false,
|
||
items: [],
|
||
roots: null,
|
||
agentRunAssembly: null,
|
||
selectedId: null,
|
||
detail: null,
|
||
preview: null,
|
||
error: null
|
||
},
|
||
devicePod: {
|
||
selectedDevicePodId: "device-pod-71-freq",
|
||
list: null,
|
||
status: null,
|
||
events: null,
|
||
chipId: null,
|
||
uart: null,
|
||
uartTail: null,
|
||
followEvents: true,
|
||
unreadEvents: 0,
|
||
lastEventLineCount: 0,
|
||
eventScrollUserActiveUntil: 0,
|
||
details: new Map()
|
||
},
|
||
layout: {
|
||
leftSidebarCollapsed: false,
|
||
leftSidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH,
|
||
leftDrag: null,
|
||
rightSidebarCollapsed: false,
|
||
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
|
||
rightDrag: null
|
||
}
|
||
};
|
||
|
||
let codeAgentSessionPersistTimer = null;
|
||
let accountConversationHydrated = false;
|
||
let accountConversationPersistInFlight = false;
|
||
let accountConversationPersistPending = false;
|
||
|
||
restoreCodeAgentSessionState();
|
||
|
||
initRoutes();
|
||
initLayoutSizing();
|
||
initLeftSidebarToggle();
|
||
initRightSidebarToggle();
|
||
initLeftSidebarResize();
|
||
initRightSidebarResize();
|
||
initDevicePodPanel();
|
||
initSkillsPanel();
|
||
initSessionSidebar();
|
||
initCodeAgentTimeoutControl();
|
||
initConversationScrollMemory();
|
||
initCommandBar();
|
||
initLiveBuildOverlay();
|
||
initWorkbenchLogout(el.logoutButton);
|
||
initGateControls();
|
||
installWorkbenchTestHooks();
|
||
renderStaticWorkbench();
|
||
renderProbePending();
|
||
renderCodeAgentSummary();
|
||
loadHelpSurface();
|
||
loadLiveSurface().then(renderLiveSurface);
|
||
hydrateAccountCodeAgentSessionState();
|
||
window.setTimeout(refreshRestoredCodeAgentTraces, 0);
|
||
|
||
function byId(id) {
|
||
const element = document.getElementById(id);
|
||
if (!element) {
|
||
throw new Error(`missing required element #${id}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function query(selector) {
|
||
const element = document.querySelector(selector);
|
||
if (!element) {
|
||
throw new Error(`missing required element ${selector}`);
|
||
}
|
||
return element;
|
||
}
|
||
|
||
function resolveTimeoutMs(name, fallback, { min, max }) {
|
||
const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {};
|
||
const configured = Number(config?.[name]);
|
||
if (!Number.isFinite(configured)) return fallback;
|
||
return Math.min(Math.max(Math.trunc(configured), min), max);
|
||
}
|
||
|
||
function configuredTimeoutMinMs(name, fallback) {
|
||
const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {};
|
||
const configured = Number(config?.[`${name}MinMs`]);
|
||
if (!Number.isFinite(configured)) return fallback;
|
||
return Math.max(1, Math.min(Math.trunc(configured), fallback));
|
||
}
|
||
|
||
function clampTimeoutMs(value, { min, max }) {
|
||
if (value === null || value === undefined) return null;
|
||
if (typeof value === "string" && value.trim() === "") return null;
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return null;
|
||
return Math.min(Math.max(Math.trunc(numeric), min), max);
|
||
}
|
||
|
||
function resolveUserCodeAgentTimeoutMs() {
|
||
const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000);
|
||
let stored = null;
|
||
try {
|
||
stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null;
|
||
} catch {
|
||
stored = null;
|
||
}
|
||
return clampTimeoutMs(stored, { min, max: MAX_CODE_AGENT_TIMEOUT_MS })
|
||
?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: MAX_CODE_AGENT_TIMEOUT_MS });
|
||
}
|
||
|
||
function resolveUserGatewayShellTimeoutMs() {
|
||
let stored = null;
|
||
try {
|
||
stored = window.localStorage?.getItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY) ?? null;
|
||
} catch {
|
||
stored = null;
|
||
}
|
||
return clampTimeoutMs(stored, { min: 30000, max: 600000 })
|
||
?? resolveTimeoutMs("gatewayShellTimeoutMs", DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 30000, max: 600000 });
|
||
}
|
||
|
||
function resolveUserCodeAgentProviderProfile() {
|
||
let stored = null;
|
||
try {
|
||
stored = window.localStorage?.getItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY) ?? null;
|
||
} catch {
|
||
stored = null;
|
||
}
|
||
return normalizeCodeAgentProviderProfile(stored);
|
||
}
|
||
|
||
function normalizeCodeAgentProviderProfile(value) {
|
||
const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase();
|
||
return CODE_AGENT_PROVIDER_PROFILES.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE;
|
||
}
|
||
|
||
function initCodeAgentTimeoutControl() {
|
||
syncCodeAgentTimeoutControl();
|
||
el.codeAgentProviderProfile.addEventListener("change", () => {
|
||
CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(el.codeAgentProviderProfile.value);
|
||
try {
|
||
window.localStorage?.setItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY, CODE_AGENT_PROVIDER_PROFILE);
|
||
} catch {
|
||
// localStorage can be unavailable in hardened browser modes.
|
||
}
|
||
syncCodeAgentTimeoutControl();
|
||
renderCodeAgentSummary();
|
||
});
|
||
el.codeAgentTimeout.addEventListener("change", () => {
|
||
const next = clampTimeoutMs(el.codeAgentTimeout.value, { min: 30000, max: MAX_CODE_AGENT_TIMEOUT_MS }) ?? DEFAULT_CODE_AGENT_TIMEOUT_MS;
|
||
CODE_AGENT_TIMEOUT_MS = next;
|
||
try {
|
||
window.localStorage?.setItem(CODE_AGENT_TIMEOUT_STORAGE_KEY, String(next));
|
||
} catch {
|
||
// localStorage can be unavailable in hardened browser modes.
|
||
}
|
||
syncCodeAgentTimeoutControl();
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
});
|
||
el.gatewayShellTimeout.addEventListener("change", () => {
|
||
const next = clampTimeoutMs(el.gatewayShellTimeout.value, { min: 30000, max: 600000 }) ?? DEFAULT_GATEWAY_SHELL_TIMEOUT_MS;
|
||
GATEWAY_SHELL_TIMEOUT_MS = next;
|
||
try {
|
||
window.localStorage?.setItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY, String(next));
|
||
} catch {
|
||
// localStorage can be unavailable in hardened browser modes.
|
||
}
|
||
syncCodeAgentTimeoutControl();
|
||
renderCodeAgentSummary();
|
||
});
|
||
}
|
||
|
||
function syncCodeAgentTimeoutControl() {
|
||
el.codeAgentProviderProfile.value = CODE_AGENT_PROVIDER_PROFILE;
|
||
el.codeAgentProviderProfile.title = CODE_AGENT_PROVIDER_PROFILE === "codex-api"
|
||
? "本次 Code Agent 请求使用原 Codex API / Responses 通道"
|
||
: CODE_AGENT_PROVIDER_PROFILE === "minimax-m3"
|
||
? "本次 Code Agent 请求通过 AgentRun MiniMax-M3 backend profile 执行"
|
||
: "本次 Code Agent 请求使用 G14 DeepSeek Responses bridge";
|
||
el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS);
|
||
el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`;
|
||
el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS);
|
||
el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`;
|
||
}
|
||
|
||
function restoreCodeAgentSessionState() {
|
||
let payload = null;
|
||
try {
|
||
const raw = window.localStorage?.getItem(CODE_AGENT_SESSION_STORAGE_KEY);
|
||
payload = raw ? JSON.parse(raw) : null;
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return;
|
||
if (workbenchActorId && payload.actorId && payload.actorId !== workbenchActorId) {
|
||
clearCodeAgentSessionState();
|
||
return;
|
||
}
|
||
const updatedAtMs = Number(payload.updatedAtMs);
|
||
if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) {
|
||
clearCodeAgentSessionState();
|
||
return;
|
||
}
|
||
state.conversationId = nonEmptyString(payload.conversationId);
|
||
state.sessionId = nonEmptyString(payload.sessionId);
|
||
state.threadId = nonEmptyString(payload.threadId);
|
||
state.workspaceId = nonEmptyString(payload.workspaceId);
|
||
state.workspaceRevision = Number.isInteger(payload.workspaceRevision) ? payload.workspaceRevision : null;
|
||
state.workspaceUpdatedAt = nonEmptyString(payload.workspaceUpdatedAt);
|
||
state.activeTraceId = nonEmptyString(payload.activeTraceId);
|
||
state.sessionStatus = nonEmptyString(payload.sessionStatus);
|
||
state.chatMessages = Array.isArray(payload.chatMessages)
|
||
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
|
||
: [];
|
||
reconcileRestoredCodeAgentResults();
|
||
}
|
||
|
||
function restoreStoredChatMessage(message) {
|
||
if (!message || typeof message !== "object") return null;
|
||
const restored = { ...message };
|
||
restored.id = nonEmptyString(restored.id) ?? nextProtocolId("msg");
|
||
restored.role = restored.role === "user" ? "user" : restored.role === "system" ? "system" : "agent";
|
||
restored.title = nonEmptyString(restored.title) ?? (restored.role === "user" ? "用户" : "Agent");
|
||
restored.text = typeof restored.text === "string" ? restored.text : "";
|
||
restored.status = nonEmptyString(restored.status) ?? "source";
|
||
if (restored.status === "running") restored.status = "source";
|
||
restored.conversationId = nonEmptyString(restored.conversationId) ?? state.conversationId;
|
||
restored.sessionId = nonEmptyString(restored.sessionId) ?? state.sessionId;
|
||
restored.threadId = nonEmptyString(restored.threadId) ?? state.threadId;
|
||
restored.traceId = nonEmptyString(restored.traceId);
|
||
if (restored.runnerTrace && typeof restored.runnerTrace === "object") {
|
||
restored.runnerTrace = restoreStoredRunnerTrace(restored.runnerTrace);
|
||
}
|
||
return restored;
|
||
}
|
||
|
||
function restoreStoredRunnerTrace(trace) {
|
||
return {
|
||
...trace,
|
||
events: Array.isArray(trace.events) ? trace.events : [],
|
||
eventCount: Number.isInteger(trace.eventCount) ? trace.eventCount : Array.isArray(trace.events) ? trace.events.length : 0,
|
||
eventsCompacted: trace.eventsCompacted === true,
|
||
fullTraceLoaded: trace.fullTraceLoaded === true
|
||
};
|
||
}
|
||
|
||
function scheduleCodeAgentSessionPersist() {
|
||
if (codeAgentSessionPersistTimer !== null) window.clearTimeout(codeAgentSessionPersistTimer);
|
||
codeAgentSessionPersistTimer = window.setTimeout(() => {
|
||
codeAgentSessionPersistTimer = null;
|
||
persistCodeAgentSessionState();
|
||
}, CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS);
|
||
}
|
||
|
||
function persistCodeAgentSessionState() {
|
||
if (!state.conversationId && !state.sessionId && !state.threadId && state.chatMessages.length === 0) {
|
||
clearCodeAgentSessionState();
|
||
return;
|
||
}
|
||
const payload = codeAgentSessionSnapshotPayload();
|
||
try {
|
||
window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload));
|
||
} catch {
|
||
// Keep the in-memory conversation when localStorage is unavailable or full.
|
||
}
|
||
persistAccountCodeAgentSessionState(payload);
|
||
}
|
||
|
||
function codeAgentSessionSnapshotPayload() {
|
||
return {
|
||
version: CODE_AGENT_SESSION_STORAGE_VERSION,
|
||
actorId: workbenchActorId,
|
||
updatedAtMs: Date.now(),
|
||
workspaceId: state.workspaceId,
|
||
workspaceRevision: state.workspaceRevision,
|
||
workspaceUpdatedAt: state.workspaceUpdatedAt,
|
||
activeTraceId: state.currentRequest?.traceId ?? state.activeTraceId ?? activeTraceIdFromStoredMessages(state.chatMessages),
|
||
conversationId: state.conversationId,
|
||
sessionId: state.sessionId,
|
||
threadId: state.threadId,
|
||
sessionStatus: state.sessionStatus,
|
||
chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage)
|
||
};
|
||
}
|
||
|
||
async function hydrateAccountCodeAgentSessionState() {
|
||
const response = await fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||
timeoutName: "Workbench workspace hydrate"
|
||
});
|
||
if (!response.ok) return;
|
||
applyWorkbenchWorkspace(response.data?.workspace);
|
||
loadSessionSidebar({ quiet: true });
|
||
const conversation = response.data?.workspace?.selectedConversation ?? null;
|
||
const payload = conversation
|
||
? accountConversationToStoredSessionPayload(conversation)
|
||
: workspaceToStoredSessionPayload(response.data?.workspace);
|
||
if (!payload) {
|
||
accountConversationHydrated = true;
|
||
if (state.conversationId) persistCodeAgentSessionState();
|
||
return;
|
||
}
|
||
const shouldReplace = !state.conversationId || Number(payload.updatedAtMs) > latestLocalCodeAgentSessionUpdatedAt();
|
||
if (!shouldReplace) {
|
||
accountConversationHydrated = true;
|
||
persistCodeAgentSessionState();
|
||
return;
|
||
}
|
||
state.conversationId = nonEmptyString(payload.conversationId);
|
||
state.sessionId = nonEmptyString(payload.sessionId);
|
||
state.threadId = nonEmptyString(payload.threadId);
|
||
state.workspaceId = nonEmptyString(payload.workspaceId) ?? state.workspaceId;
|
||
state.workspaceRevision = Number.isInteger(payload.workspaceRevision) ? payload.workspaceRevision : state.workspaceRevision;
|
||
state.workspaceUpdatedAt = nonEmptyString(payload.workspaceUpdatedAt) ?? state.workspaceUpdatedAt;
|
||
state.sessionStatus = nonEmptyString(payload.sessionStatus);
|
||
state.chatMessages = Array.isArray(payload.chatMessages)
|
||
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
|
||
: [];
|
||
accountConversationHydrated = true;
|
||
persistCodeAgentSessionState();
|
||
renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult());
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
window.setTimeout(refreshRestoredCodeAgentTraces, 0);
|
||
}
|
||
|
||
function applyWorkbenchWorkspace(workspace) {
|
||
if (!workspace || typeof workspace !== "object") return null;
|
||
state.workspaceId = nonEmptyString(workspace.workspaceId) ?? state.workspaceId;
|
||
state.workspaceRevision = Number.isInteger(workspace.revision) ? workspace.revision : state.workspaceRevision;
|
||
state.workspaceUpdatedAt = nonEmptyString(workspace.updatedAt) ?? state.workspaceUpdatedAt;
|
||
state.activeTraceId = nonEmptyString(workspace.activeTraceId ?? workspace.workspace?.activeTraceId) ?? state.activeTraceId;
|
||
state.conversationId = nonEmptyString(workspace.selectedConversationId) ?? nonEmptyString(workspace.workspace?.selectedConversationId) ?? state.conversationId;
|
||
state.sessionId = nonEmptyString(workspace.selectedAgentSessionId) ?? nonEmptyString(workspace.workspace?.selectedAgentSessionId) ?? state.sessionId;
|
||
state.threadId = nonEmptyString(workspace.selectedConversation?.threadId ?? workspace.selectedConversation?.session?.threadId ?? workspace.workspace?.threadId) ?? state.threadId;
|
||
state.sessionStatus = nonEmptyString(workspace.workspace?.sessionStatus) ?? state.sessionStatus;
|
||
CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(workspace.providerProfile ?? workspace.workspace?.providerProfile ?? CODE_AGENT_PROVIDER_PROFILE);
|
||
syncCodeAgentTimeoutControl();
|
||
if (workspace.selectedDevicePodId) state.devicePod.selectedDevicePodId = workspace.selectedDevicePodId;
|
||
return workspace;
|
||
}
|
||
|
||
function initSessionSidebar() {
|
||
renderSessionSidebar();
|
||
el.sessionCreate.addEventListener("click", () => createSessionSidebarSession());
|
||
el.sessionDelete.addEventListener("click", () => deleteCurrentSessionSidebarSession());
|
||
}
|
||
|
||
async function loadSessionSidebar(options = {}) {
|
||
state.sessionList.loading = true;
|
||
state.sessionList.error = null;
|
||
renderSessionSidebar();
|
||
const response = await fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}&limit=100`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||
timeoutName: "Code Agent session list"
|
||
});
|
||
state.sessionList.loading = false;
|
||
if (!response.ok) {
|
||
state.sessionList.error = response.error || "session list unavailable";
|
||
if (options.quiet !== true) renderSessionSidebar();
|
||
return false;
|
||
}
|
||
state.sessionList.items = Array.isArray(response.data?.conversations) ? response.data.conversations : [];
|
||
state.sessionList.loadedAt = new Date().toISOString();
|
||
renderSessionSidebar();
|
||
return true;
|
||
}
|
||
|
||
function refreshSessionSidebar(options = {}) {
|
||
if (state.sessionList.loading) return;
|
||
void loadSessionSidebar({ quiet: options.quiet !== false });
|
||
}
|
||
|
||
function renderSessionSidebar() {
|
||
const view = sessionTabsFromConversations(sessionSidebarConversations(), {
|
||
selectedConversationId: state.conversationId,
|
||
selectedSessionId: state.sessionId,
|
||
activeTraceId: state.activeTraceId
|
||
});
|
||
replaceChildren(el.sessionTabs, ...view.tabs.map(sessionTabButton));
|
||
const pending = Boolean(state.sessionList.pendingAction);
|
||
el.sessionCreate.disabled = pending;
|
||
el.sessionDelete.disabled = pending || !view.activeConversation?.conversationId;
|
||
el.sessionStatus.textContent = sessionSidebarStatusText(view.tabs.length);
|
||
el.sessionStatus.title = state.sessionList.error || state.sessionList.loadedAt || "session list not loaded";
|
||
}
|
||
|
||
function sessionSidebarConversations() {
|
||
const items = Array.isArray(state.sessionList.items) ? [...state.sessionList.items] : [];
|
||
if (state.conversationId && !items.some((item) => item?.conversationId === state.conversationId)) {
|
||
items.unshift(currentStateConversationSnapshot());
|
||
}
|
||
return items.filter(Boolean);
|
||
}
|
||
|
||
function currentStateConversationSnapshot() {
|
||
return {
|
||
conversationId: state.conversationId,
|
||
sessionId: state.sessionId,
|
||
threadId: state.threadId,
|
||
status: state.sessionStatus || deriveAgentChatStatus(),
|
||
lastTraceId: activeTraceIdFromStoredMessages(state.chatMessages) || state.activeTraceId,
|
||
updatedAt: new Date().toISOString(),
|
||
messages: state.chatMessages
|
||
};
|
||
}
|
||
|
||
function sessionSidebarStatusText(count) {
|
||
if (state.sessionList.pendingAction === "create") return "创建中";
|
||
if (state.sessionList.pendingAction === "switch") return "切换中";
|
||
if (state.sessionList.pendingAction === "delete") return "删除中";
|
||
if (state.sessionList.loading) return count > 0 ? `刷新中 / ${count} 个` : "加载中";
|
||
if (state.sessionList.error) return "列表受阻";
|
||
return count > 0 ? `${count} 个 session` : "无 session";
|
||
}
|
||
|
||
function sessionTabButton(tab) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = `session-tab tone-border-${toneClass(sessionTabTone(tab))}`;
|
||
button.dataset.conversationId = tab.conversationId;
|
||
button.dataset.sessionId = tab.sessionId || "";
|
||
button.setAttribute("role", "tab");
|
||
button.setAttribute("aria-selected", tab.active ? "true" : "false");
|
||
button.setAttribute("aria-label", `${tab.label},${tab.subtitle},${tab.messageCount} 条消息`);
|
||
button.title = sessionTabTitle(tab);
|
||
button.append(
|
||
textSpan(tab.label, "session-tab-label"),
|
||
textSpan(tab.subtitle, "session-tab-subtitle"),
|
||
textSpan(`${tab.messageCount} 条`, "session-tab-count")
|
||
);
|
||
button.addEventListener("click", () => selectSessionSidebarSession(tab));
|
||
return button;
|
||
}
|
||
|
||
function sessionTabTone(tab) {
|
||
if (["running", "busy", "pending", "active"].includes(tab.status)) return "pending";
|
||
if (["failed", "timeout", "error", "canceled", "archived"].includes(tab.status)) return "blocked";
|
||
if (["completed", "idle"].includes(tab.status)) return "dev-live";
|
||
return "source";
|
||
}
|
||
|
||
function sessionTabTitle(tab) {
|
||
return [
|
||
`conversation=${tab.conversationId ?? "unknown"}`,
|
||
`session=${tab.sessionId ?? "unknown"}`,
|
||
`thread=${tab.threadId ?? "unknown"}`,
|
||
`trace=${tab.lastTraceId ?? "unknown"}`
|
||
].join(" / ");
|
||
}
|
||
|
||
async function ensureWorkbenchWorkspace() {
|
||
if (state.workspaceId) return true;
|
||
const response = await fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||
timeoutName: "Workbench workspace restore"
|
||
});
|
||
if (!response.ok) {
|
||
state.sessionList.error = response.error || "workspace restore failed";
|
||
renderSessionSidebar();
|
||
return false;
|
||
}
|
||
applyWorkbenchWorkspace(response.data?.workspace);
|
||
return Boolean(state.workspaceId);
|
||
}
|
||
|
||
async function createSessionSidebarSession() {
|
||
if (!(await ensureWorkbenchWorkspace())) return;
|
||
state.sessionList.pendingAction = "create";
|
||
renderSessionSidebar();
|
||
try {
|
||
const response = await fetchJson(`/v1/workbench/workspace/${encodeURIComponent(state.workspaceId)}/select-conversation`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 8000),
|
||
timeoutName: "Code Agent session create",
|
||
body: JSON.stringify({ projectId: WORKBENCH_PROJECT_ID, create: true, updatedByClient: "cloud-web" })
|
||
});
|
||
if (!response.ok) throw new Error(response.error || "session create failed");
|
||
applyWorkbenchWorkspace(response.data?.workspace);
|
||
applyConversationToActiveSession(response.data?.workspace?.selectedConversation, { clearWhenMissing: true });
|
||
clearCodeAgentSessionState();
|
||
await loadSessionSidebar({ quiet: true });
|
||
renderAgentChatStatus("idle");
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
} catch (error) {
|
||
state.sessionList.error = error?.message || String(error);
|
||
renderSessionSidebar();
|
||
} finally {
|
||
state.sessionList.pendingAction = null;
|
||
renderSessionSidebar();
|
||
}
|
||
}
|
||
|
||
async function selectSessionSidebarSession(tab) {
|
||
if (!tab?.conversationId || tab.conversationId === state.conversationId) return;
|
||
if (!(await ensureWorkbenchWorkspace())) return;
|
||
state.sessionList.pendingAction = "switch";
|
||
renderSessionSidebar();
|
||
try {
|
||
const response = await fetchJson(`/v1/workbench/workspace/${encodeURIComponent(state.workspaceId)}/select-conversation`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 8000),
|
||
timeoutName: "Code Agent session switch",
|
||
body: JSON.stringify({
|
||
projectId: WORKBENCH_PROJECT_ID,
|
||
conversationId: tab.conversationId,
|
||
sessionId: tab.sessionId,
|
||
threadId: tab.threadId,
|
||
updatedByClient: "cloud-web"
|
||
})
|
||
});
|
||
if (!response.ok) throw new Error(response.error || "session switch failed");
|
||
applyWorkbenchWorkspace(response.data?.workspace);
|
||
applyConversationToActiveSession(response.data?.workspace?.selectedConversation, { clearWhenMissing: true });
|
||
state.conversationScrollPosition = { top: 0, left: 0, bottomGap: 0 };
|
||
persistCodeAgentSessionState();
|
||
renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult());
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
} catch (error) {
|
||
state.sessionList.error = error?.message || String(error);
|
||
renderSessionSidebar();
|
||
} finally {
|
||
state.sessionList.pendingAction = null;
|
||
renderSessionSidebar();
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentSessionSidebarSession() {
|
||
const conversationId = state.conversationId;
|
||
if (!conversationId) return;
|
||
if (!window.confirm("删除当前 session?历史 trace 仍会保留在后端审计记录中。")) return;
|
||
state.sessionList.pendingAction = "delete";
|
||
renderSessionSidebar();
|
||
try {
|
||
const response = await fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 8000),
|
||
timeoutName: "Code Agent session delete",
|
||
body: JSON.stringify({ projectId: WORKBENCH_PROJECT_ID, workspaceId: state.workspaceId, updatedByClient: "cloud-web" })
|
||
});
|
||
if (!response.ok) throw new Error(response.error || "session delete failed");
|
||
if (response.data?.workspace) applyWorkbenchWorkspace(response.data.workspace);
|
||
applyConversationToActiveSession(response.data?.workspace?.selectedConversation, { clearWhenMissing: true });
|
||
clearCodeAgentSessionState();
|
||
await loadSessionSidebar({ quiet: true });
|
||
renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult());
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
} catch (error) {
|
||
state.sessionList.error = error?.message || String(error);
|
||
renderSessionSidebar();
|
||
} finally {
|
||
state.sessionList.pendingAction = null;
|
||
renderSessionSidebar();
|
||
}
|
||
}
|
||
|
||
function applyConversationToActiveSession(conversation, options = {}) {
|
||
const payload = accountConversationToStoredSessionPayload(conversation);
|
||
if (!payload) {
|
||
if (options.clearWhenMissing === true) clearActiveConversationState();
|
||
return false;
|
||
}
|
||
state.conversationId = nonEmptyString(payload.conversationId);
|
||
state.sessionId = nonEmptyString(payload.sessionId);
|
||
state.threadId = nonEmptyString(payload.threadId);
|
||
state.sessionStatus = nonEmptyString(payload.sessionStatus);
|
||
state.chatMessages = Array.isArray(payload.chatMessages)
|
||
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
|
||
: [];
|
||
state.activeTraceId = activeTraceIdFromStoredMessages(state.chatMessages);
|
||
state.currentRequest = null;
|
||
state.chatPending = false;
|
||
return true;
|
||
}
|
||
|
||
function clearActiveConversationState() {
|
||
state.conversationId = null;
|
||
state.sessionId = null;
|
||
state.threadId = null;
|
||
state.activeTraceId = null;
|
||
state.sessionStatus = null;
|
||
state.chatMessages = [];
|
||
state.currentRequest = null;
|
||
state.chatPending = false;
|
||
}
|
||
|
||
function workspaceToStoredSessionPayload(workspace) {
|
||
if (!workspace || typeof workspace !== "object") return null;
|
||
const workspaceJson = workspace.workspace && typeof workspace.workspace === "object" ? workspace.workspace : {};
|
||
const conversationId = nonEmptyString(workspace.selectedConversationId ?? workspaceJson.selectedConversationId);
|
||
if (!conversationId) return null;
|
||
const updatedAtMs = Date.parse(workspace.updatedAt ?? workspaceJson.updatedAt ?? "");
|
||
return {
|
||
version: CODE_AGENT_SESSION_STORAGE_VERSION,
|
||
actorId: workbenchActorId,
|
||
updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now(),
|
||
workspaceId: nonEmptyString(workspace.workspaceId),
|
||
workspaceRevision: Number.isInteger(workspace.revision) ? workspace.revision : null,
|
||
workspaceUpdatedAt: nonEmptyString(workspace.updatedAt),
|
||
activeTraceId: nonEmptyString(workspace.activeTraceId ?? workspaceJson.activeTraceId),
|
||
conversationId,
|
||
sessionId: nonEmptyString(workspace.selectedAgentSessionId ?? workspaceJson.selectedAgentSessionId),
|
||
threadId: nonEmptyString(workspaceJson.threadId),
|
||
sessionStatus: nonEmptyString(workspaceJson.sessionStatus),
|
||
chatMessages: Array.isArray(workspaceJson.messages) ? workspaceJson.messages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT) : []
|
||
};
|
||
}
|
||
|
||
function accountConversationToStoredSessionPayload(conversation) {
|
||
if (!conversation || typeof conversation !== "object") return null;
|
||
const conversationId = nonEmptyString(conversation.conversationId);
|
||
if (!conversationId) return null;
|
||
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
|
||
const updatedAtMs = Date.parse(conversation.updatedAt ?? conversation.snapshot?.updatedAt ?? "");
|
||
return {
|
||
version: CODE_AGENT_SESSION_STORAGE_VERSION,
|
||
updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now(),
|
||
conversationId,
|
||
sessionId: nonEmptyString(conversation.sessionId ?? conversation.session?.sessionId),
|
||
threadId: nonEmptyString(conversation.threadId ?? conversation.session?.threadId),
|
||
sessionStatus: nonEmptyString(conversation.snapshot?.sessionStatus ?? conversation.status),
|
||
chatMessages: messages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT)
|
||
};
|
||
}
|
||
|
||
function latestLocalCodeAgentSessionUpdatedAt() {
|
||
let latest = 0;
|
||
for (const message of state.chatMessages) {
|
||
const value = Date.parse(message.updatedAt ?? message.createdAt ?? "");
|
||
if (Number.isFinite(value) && value > latest) latest = value;
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
function persistAccountCodeAgentSessionState(payload) {
|
||
if (!accountConversationHydrated) return;
|
||
if (!payload.conversationId) return;
|
||
accountConversationPersistPending = true;
|
||
if (state.chatPending) return;
|
||
if (accountConversationPersistInFlight) return;
|
||
void flushAccountCodeAgentSessionState();
|
||
}
|
||
|
||
async function flushAccountCodeAgentSessionState() {
|
||
accountConversationPersistInFlight = true;
|
||
try {
|
||
while (accountConversationPersistPending) {
|
||
accountConversationPersistPending = false;
|
||
const payload = codeAgentSessionSnapshotPayload();
|
||
if (!payload.conversationId) continue;
|
||
await fetchJson(`/v1/agent/conversations/${encodeURIComponent(payload.conversationId)}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||
timeoutName: "Code Agent account conversation persist",
|
||
body: JSON.stringify({
|
||
projectId: WORKBENCH_PROJECT_ID,
|
||
conversationId: payload.conversationId,
|
||
sessionId: payload.sessionId,
|
||
threadId: payload.threadId,
|
||
sessionStatus: payload.sessionStatus,
|
||
lastTraceId: latestTraceIdFromStoredMessages(payload.chatMessages),
|
||
messages: payload.chatMessages,
|
||
snapshot: {
|
||
source: "cloud-web-account-sync",
|
||
updatedAt: new Date(payload.updatedAtMs).toISOString(),
|
||
sessionStatus: payload.sessionStatus
|
||
}
|
||
})
|
||
});
|
||
await persistWorkbenchWorkspace(payload);
|
||
}
|
||
} finally {
|
||
accountConversationPersistInFlight = false;
|
||
}
|
||
}
|
||
|
||
async function persistWorkbenchWorkspace(payload) {
|
||
if (!payload.conversationId) return;
|
||
if (!state.workspaceId) {
|
||
const response = await fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||
timeoutName: "Workbench workspace hydrate before persist"
|
||
});
|
||
if (!response.ok) return;
|
||
applyWorkbenchWorkspace(response.data?.workspace);
|
||
}
|
||
if (!state.workspaceId) return;
|
||
const response = await fetchJson(`/v1/workbench/workspace/${encodeURIComponent(state.workspaceId)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
||
timeoutName: "Workbench workspace persist",
|
||
body: JSON.stringify({
|
||
projectId: WORKBENCH_PROJECT_ID,
|
||
expectedRevision: state.workspaceRevision,
|
||
selectedConversationId: payload.conversationId,
|
||
selectedAgentSessionId: payload.sessionId,
|
||
selectedDevicePodId: state.devicePod.selectedDevicePodId,
|
||
activeTraceId: activeTraceIdFromStoredMessages(payload.chatMessages),
|
||
providerProfile: CODE_AGENT_PROVIDER_PROFILE,
|
||
sessionStatus: payload.sessionStatus,
|
||
threadId: payload.threadId,
|
||
lastTraceId: latestTraceIdFromStoredMessages(payload.chatMessages),
|
||
messages: payload.chatMessages,
|
||
workspace: {
|
||
updatedAt: new Date(payload.updatedAtMs).toISOString(),
|
||
sessionStatus: payload.sessionStatus,
|
||
threadId: payload.threadId,
|
||
source: "cloud-web-account-workspace-sync"
|
||
},
|
||
updatedByClient: "cloud-web"
|
||
})
|
||
});
|
||
if (response.status === 409 && response.data?.workspace) {
|
||
applyWorkbenchWorkspace(response.data.workspace);
|
||
return;
|
||
}
|
||
if (response.ok) applyWorkbenchWorkspace(response.data?.workspace);
|
||
}
|
||
|
||
function latestTraceIdFromStoredMessages(messages) {
|
||
for (const message of [...(messages ?? [])].reverse()) {
|
||
const traceId = nonEmptyString(message?.traceId ?? message?.runnerTrace?.traceId);
|
||
if (traceId) return traceId;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function activeTraceIdFromStoredMessages(messages) {
|
||
for (const message of [...(messages ?? [])].reverse()) {
|
||
const status = String(message?.status ?? "").toLowerCase();
|
||
if (status !== "running") continue;
|
||
const traceId = nonEmptyString(message?.traceId ?? message?.runnerTrace?.traceId);
|
||
if (traceId) return traceId;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function clearCodeAgentSessionState() {
|
||
if (codeAgentSessionPersistTimer !== null) {
|
||
window.clearTimeout(codeAgentSessionPersistTimer);
|
||
codeAgentSessionPersistTimer = null;
|
||
}
|
||
try {
|
||
window.localStorage?.removeItem(CODE_AGENT_SESSION_STORAGE_KEY);
|
||
} catch {
|
||
// Ignore storage availability errors.
|
||
}
|
||
}
|
||
|
||
function storedChatMessage(message) {
|
||
const stored = { ...message };
|
||
if (stored.runnerTrace && typeof stored.runnerTrace === "object") {
|
||
stored.runnerTrace = storedRunnerTrace(stored.runnerTrace);
|
||
}
|
||
delete stored.availability;
|
||
return stored;
|
||
}
|
||
|
||
function storedRunnerTrace(trace) {
|
||
const events = Array.isArray(trace.events) ? trace.events : [];
|
||
const storedEvents = trace.fullTraceLoaded === true && events.length <= CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT
|
||
? events
|
||
: [];
|
||
const fullTraceLoaded = storedEvents.length > 0 && trace.fullTraceLoaded === true;
|
||
return {
|
||
...trace,
|
||
events: storedEvents,
|
||
eventsCompacted: !fullTraceLoaded,
|
||
fullTraceLoaded
|
||
};
|
||
}
|
||
|
||
function refreshRestoredCodeAgentTraces() {
|
||
for (const message of state.chatMessages) {
|
||
if (!message.traceId) continue;
|
||
if (message.runnerTrace?.fullTraceLoaded === true) continue;
|
||
replayFullTrace(message.id, { quiet: true });
|
||
}
|
||
}
|
||
|
||
function reconcileRestoredCodeAgentResults() {
|
||
for (const message of state.chatMessages) {
|
||
if (!message.traceId || message.role !== "agent") continue;
|
||
if (!shouldReconcileRestoredCodeAgentResult(message)) continue;
|
||
reconcileCodeAgentResult(message.id, { quiet: true });
|
||
}
|
||
}
|
||
|
||
function initLiveBuildOverlay() {
|
||
el.liveBuildToggle.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
openWorkbenchDialog({
|
||
title: "构建版本明细",
|
||
body: cloneLiveBuildList(),
|
||
restoreFocus: el.liveBuildToggle
|
||
});
|
||
});
|
||
}
|
||
|
||
function cloneLiveBuildList() {
|
||
const clone = el.liveBuildList.cloneNode(true);
|
||
clone.id = "";
|
||
clone.hidden = false;
|
||
clone.removeAttribute("hidden");
|
||
clone.tabIndex = 0;
|
||
clone.classList.remove("live-build-list-template");
|
||
return clone;
|
||
}
|
||
|
||
function openWorkbenchDialog({ title, body, restoreFocus = null }) {
|
||
closeWorkbenchDialog({ restoreFocus: false });
|
||
const layer = document.createElement("div");
|
||
layer.className = "workbench-dialog-layer";
|
||
const backdrop = document.createElement("button");
|
||
backdrop.type = "button";
|
||
backdrop.className = "workbench-dialog-backdrop";
|
||
backdrop.setAttribute("aria-label", "关闭弹窗");
|
||
const dialog = document.createElement("section");
|
||
dialog.className = "workbench-dialog";
|
||
dialog.setAttribute("role", "dialog");
|
||
dialog.setAttribute("aria-modal", "true");
|
||
dialog.setAttribute("aria-label", title);
|
||
const head = document.createElement("div");
|
||
head.className = "workbench-dialog-head";
|
||
const heading = document.createElement("strong");
|
||
heading.textContent = title;
|
||
const close = document.createElement("button");
|
||
close.type = "button";
|
||
close.className = "workbench-dialog-close";
|
||
close.textContent = "关闭";
|
||
close.setAttribute("aria-label", `关闭${title}`);
|
||
const content = document.createElement("div");
|
||
content.className = "workbench-dialog-content";
|
||
if (body) {
|
||
body.hidden = false;
|
||
body.removeAttribute("hidden");
|
||
content.append(body);
|
||
}
|
||
head.append(heading, close);
|
||
dialog.append(head, content);
|
||
layer.append(backdrop, dialog);
|
||
|
||
const closeDialog = () => closeWorkbenchDialog({ restoreFocus: true });
|
||
const keydown = (event) => {
|
||
if (event.key !== "Escape") return;
|
||
event.preventDefault();
|
||
closeDialog();
|
||
};
|
||
close.addEventListener("click", closeDialog);
|
||
backdrop.addEventListener("click", closeDialog);
|
||
document.addEventListener("keydown", keydown);
|
||
document.body.append(layer);
|
||
activeWorkbenchDialog = { layer, keydown, restoreFocus };
|
||
close.focus();
|
||
}
|
||
|
||
function closeWorkbenchDialog({ restoreFocus = true } = {}) {
|
||
if (!activeWorkbenchDialog) return;
|
||
const { layer, keydown, restoreFocus: target } = activeWorkbenchDialog;
|
||
document.removeEventListener("keydown", keydown);
|
||
layer.remove();
|
||
activeWorkbenchDialog = null;
|
||
if (restoreFocus && target && typeof target.focus === "function") {
|
||
target.focus();
|
||
}
|
||
}
|
||
|
||
function initRoutes() {
|
||
window.addEventListener("hashchange", () => showView(routeFromLocation()));
|
||
for (const button of document.querySelectorAll("[data-route]")) {
|
||
button.addEventListener("click", () => {
|
||
window.location.hash = `/${button.dataset.route}`;
|
||
});
|
||
}
|
||
showView(routeFromLocation());
|
||
}
|
||
|
||
function initLayoutSizing() {
|
||
const persisted = readPersistedLayout();
|
||
const leftSidebarWidth = clampLeftSidebarWidth(persisted?.leftSidebarWidth ?? LEFT_SIDEBAR_DEFAULT_WIDTH);
|
||
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
|
||
setLeftSidebarWidth(leftSidebarWidth, { persist: false });
|
||
setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false });
|
||
setRightSidebarWidth(rightSidebarWidth, { persist: false });
|
||
setRightSidebarCollapsed(persisted?.rightSidebarCollapsed === true, { persist: false });
|
||
}
|
||
|
||
function readPersistedLayout() {
|
||
try {
|
||
const raw = window.localStorage?.getItem(LAYOUT_STORAGE_KEY);
|
||
if (!raw) return null;
|
||
const payload = JSON.parse(raw);
|
||
if (payload?.version !== 1) return null;
|
||
return {
|
||
leftSidebarCollapsed: payload.leftSidebarCollapsed === true,
|
||
leftSidebarWidth: Number(payload.leftSidebarWidth),
|
||
rightSidebarCollapsed: payload.rightSidebarCollapsed === true,
|
||
rightSidebarWidth: Number(payload.rightSidebarWidth)
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function persistLayout() {
|
||
try {
|
||
window.localStorage?.setItem(
|
||
LAYOUT_STORAGE_KEY,
|
||
JSON.stringify({
|
||
version: 1,
|
||
leftSidebarCollapsed: state.layout.leftSidebarCollapsed,
|
||
leftSidebarWidth: state.layout.leftSidebarWidth,
|
||
rightSidebarCollapsed: state.layout.rightSidebarCollapsed,
|
||
rightSidebarWidth: state.layout.rightSidebarWidth
|
||
})
|
||
);
|
||
} catch {
|
||
// Browser storage can be unavailable; layout still works for the session.
|
||
}
|
||
}
|
||
|
||
function initLeftSidebarToggle() {
|
||
el.leftSidebarToggle.addEventListener("click", () => {
|
||
setLeftSidebarCollapsed(!state.layout.leftSidebarCollapsed);
|
||
});
|
||
}
|
||
|
||
function setLeftSidebarCollapsed(collapsed, options = {}) {
|
||
state.layout.leftSidebarCollapsed = collapsed === true;
|
||
el.shell.classList.toggle("is-left-sidebar-collapsed", state.layout.leftSidebarCollapsed);
|
||
el.leftSidebarToggle.setAttribute("aria-expanded", state.layout.leftSidebarCollapsed ? "false" : "true");
|
||
el.leftSidebarToggle.setAttribute(
|
||
"aria-label",
|
||
state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"
|
||
);
|
||
el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航";
|
||
el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起";
|
||
syncLeftSidebarResizeA11y();
|
||
if (options.persist !== false) persistLayout();
|
||
}
|
||
|
||
function initRightSidebarToggle() {
|
||
el.rightSidebarToggle.addEventListener("click", () => {
|
||
setRightSidebarCollapsed(!state.layout.rightSidebarCollapsed);
|
||
});
|
||
}
|
||
|
||
function setRightSidebarCollapsed(collapsed, options = {}) {
|
||
state.layout.rightSidebarCollapsed = collapsed === true;
|
||
el.shell.classList.toggle("is-right-sidebar-collapsed", state.layout.rightSidebarCollapsed);
|
||
el.rightSidebarToggle.setAttribute("aria-expanded", state.layout.rightSidebarCollapsed ? "false" : "true");
|
||
el.rightSidebarToggle.setAttribute(
|
||
"aria-label",
|
||
state.layout.rightSidebarCollapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"
|
||
);
|
||
el.rightSidebarToggle.title = state.layout.rightSidebarCollapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板";
|
||
el.rightSidebarToggle.textContent = state.layout.rightSidebarCollapsed ? "展开看板" : "收起看板";
|
||
syncRightSidebarResizeA11y();
|
||
if (options.persist !== false) persistLayout();
|
||
}
|
||
|
||
function setLeftSidebarWidth(width, options = {}) {
|
||
const clamped = clampLeftSidebarWidth(width);
|
||
state.layout.leftSidebarWidth = clamped;
|
||
el.shell.style.setProperty("--left-sidebar-width", `${clamped}px`);
|
||
syncLeftSidebarResizeA11y();
|
||
if (options.persist !== false) persistLayout();
|
||
return clamped;
|
||
}
|
||
|
||
function setRightSidebarWidth(width, options = {}) {
|
||
const clamped = clampRightSidebarWidth(width);
|
||
state.layout.rightSidebarWidth = clamped;
|
||
el.shell.style.setProperty("--right-sidebar-width", `${clamped}px`);
|
||
syncRightSidebarResizeA11y();
|
||
if (options.persist !== false) persistLayout();
|
||
return clamped;
|
||
}
|
||
|
||
function clampLeftSidebarWidth(value) {
|
||
const numeric = Number(value);
|
||
const width = Number.isFinite(numeric) ? numeric : LEFT_SIDEBAR_DEFAULT_WIDTH;
|
||
const bounds = leftSidebarResizeBounds();
|
||
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
|
||
}
|
||
|
||
function clampRightSidebarWidth(value) {
|
||
const numeric = Number(value);
|
||
const width = Number.isFinite(numeric) ? numeric : RIGHT_SIDEBAR_DEFAULT_WIDTH;
|
||
const bounds = rightSidebarResizeBounds();
|
||
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
|
||
}
|
||
|
||
function boundedResizeRange(min, max, dynamicMax) {
|
||
const safeMin = Math.max(0, Math.round(min));
|
||
const safeMax = Math.max(safeMin, Math.round(Math.min(max, Number.isFinite(dynamicMax) ? dynamicMax : max)));
|
||
return { min: safeMin, max: safeMax };
|
||
}
|
||
|
||
function leftSidebarResizeBounds() {
|
||
const styles = getComputedStyle(el.shell);
|
||
const cssMin = readCssPixel(styles.getPropertyValue("--left-sidebar-min-width"), LEFT_SIDEBAR_MIN_WIDTH);
|
||
const cssMax = readCssPixel(styles.getPropertyValue("--left-sidebar-max-width"), LEFT_SIDEBAR_MAX_WIDTH);
|
||
if (window.matchMedia("(max-width: 1240px)").matches) {
|
||
const fixedRailWidth = readCssPixel(styles.getPropertyValue("--rail-width"), LEFT_SIDEBAR_DEFAULT_WIDTH);
|
||
return { min: Math.round(fixedRailWidth), max: Math.round(fixedRailWidth) };
|
||
}
|
||
const rightWidth = state.layout.rightSidebarWidth;
|
||
const dynamicMax = window.innerWidth - rightWidth - 420 - 4;
|
||
return boundedResizeRange(cssMin, cssMax, dynamicMax);
|
||
}
|
||
|
||
function rightSidebarResizeBounds() {
|
||
const styles = getComputedStyle(el.shell);
|
||
const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH);
|
||
const cssMax = readCssPixel(styles.getPropertyValue("--right-sidebar-max-width"), RIGHT_SIDEBAR_MAX_WIDTH);
|
||
if (window.matchMedia("(max-width: 1240px)").matches) {
|
||
return { min: 0, max: Math.max(0, window.innerWidth) };
|
||
}
|
||
const railWidth = state.layout.leftSidebarCollapsed
|
||
? readCssPixel(styles.getPropertyValue("--rail-collapsed-width"), LEFT_SIDEBAR_DEFAULT_WIDTH)
|
||
: state.layout.leftSidebarWidth;
|
||
const dynamicMax = window.innerWidth - railWidth - 420 - 4;
|
||
return boundedResizeRange(cssMin, cssMax, dynamicMax);
|
||
}
|
||
|
||
function readCssPixel(value, fallback) {
|
||
const parsed = Number.parseFloat(String(value ?? "").trim());
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
function syncRightSidebarResizeA11y() {
|
||
const bounds = rightSidebarResizeBounds();
|
||
const disabled = !canResizeRightSidebar();
|
||
el.rightSidebarResize.tabIndex = disabled ? -1 : 0;
|
||
el.rightSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
|
||
el.rightSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
|
||
el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
|
||
el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
|
||
el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth));
|
||
el.rightSidebarResize.setAttribute("aria-valuetext", `右侧 Device Pod 看板宽度 ${state.layout.rightSidebarWidth} 像素`);
|
||
}
|
||
|
||
function syncLeftSidebarResizeA11y() {
|
||
const bounds = leftSidebarResizeBounds();
|
||
const disabled = !canResizeLeftSidebar();
|
||
el.leftSidebarResize.tabIndex = disabled ? -1 : 0;
|
||
el.leftSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
|
||
el.leftSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
|
||
el.leftSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
|
||
el.leftSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
|
||
el.leftSidebarResize.setAttribute("aria-valuenow", String(state.layout.leftSidebarWidth));
|
||
el.leftSidebarResize.setAttribute("aria-valuetext", `左侧导航宽度 ${state.layout.leftSidebarWidth} 像素`);
|
||
}
|
||
|
||
function routeFromLocation() {
|
||
const hashRoute = window.location.hash.replace(/^#\/?/, "");
|
||
if (viewIds.has(hashRoute)) return hashRoute;
|
||
if (helpPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "help";
|
||
if (skillsPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "skills";
|
||
if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate";
|
||
return "workspace";
|
||
}
|
||
|
||
function showView(route) {
|
||
const activeRoute = viewIds.has(route) ? route : "workspace";
|
||
for (const view of document.querySelectorAll("[data-view]")) {
|
||
view.hidden = view.dataset.view !== activeRoute;
|
||
}
|
||
for (const button of document.querySelectorAll("[data-route]")) {
|
||
const active = button.dataset.route === activeRoute;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-current", active ? "page" : "false");
|
||
}
|
||
if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) {
|
||
loadGateDiagnostics();
|
||
}
|
||
if (activeRoute === "skills" && state.skills.items.length === 0 && !state.skills.loading) {
|
||
loadSkillsSurface();
|
||
}
|
||
}
|
||
|
||
function initLeftSidebarResize() {
|
||
window.addEventListener("resize", () => {
|
||
if (canResizeLeftSidebar()) setLeftSidebarWidth(state.layout.leftSidebarWidth, { persist: false });
|
||
syncLeftSidebarResizeA11y();
|
||
});
|
||
el.leftSidebarResize.addEventListener("pointerdown", (event) => {
|
||
if (!canResizeLeftSidebar()) return;
|
||
event.preventDefault();
|
||
el.leftSidebarResize.setPointerCapture?.(event.pointerId);
|
||
state.layout.leftDrag = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startWidth: state.layout.leftSidebarWidth
|
||
};
|
||
el.shell.classList.add("is-resizing");
|
||
});
|
||
el.leftSidebarResize.addEventListener("pointermove", (event) => {
|
||
const drag = state.layout.leftDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
setLeftSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false });
|
||
});
|
||
el.leftSidebarResize.addEventListener("pointerup", finishLeftSidebarResize);
|
||
el.leftSidebarResize.addEventListener("pointercancel", finishLeftSidebarResize);
|
||
el.leftSidebarResize.addEventListener("keydown", (event) => {
|
||
if (!canResizeLeftSidebar()) return;
|
||
const keySteps = {
|
||
ArrowLeft: -8,
|
||
ArrowRight: 8,
|
||
PageUp: 24,
|
||
PageDown: -24
|
||
};
|
||
if (Object.hasOwn(keySteps, event.key)) {
|
||
event.preventDefault();
|
||
setLeftSidebarWidth(state.layout.leftSidebarWidth + keySteps[event.key]);
|
||
} else if (event.key === "Home") {
|
||
event.preventDefault();
|
||
setLeftSidebarWidth(leftSidebarResizeBounds().min);
|
||
} else if (event.key === "End") {
|
||
event.preventDefault();
|
||
setLeftSidebarWidth(leftSidebarResizeBounds().max);
|
||
}
|
||
});
|
||
}
|
||
|
||
function finishLeftSidebarResize(event) {
|
||
const drag = state.layout.leftDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
state.layout.leftDrag = null;
|
||
el.shell.classList.remove("is-resizing");
|
||
persistLayout();
|
||
}
|
||
|
||
function canResizeLeftSidebar() {
|
||
return !state.layout.leftSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches;
|
||
}
|
||
|
||
function initRightSidebarResize() {
|
||
window.addEventListener("resize", () => {
|
||
if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false });
|
||
syncRightSidebarResizeA11y();
|
||
});
|
||
el.rightSidebarResize.addEventListener("pointerdown", (event) => {
|
||
if (!canResizeRightSidebar()) return;
|
||
event.preventDefault();
|
||
el.rightSidebarResize.setPointerCapture?.(event.pointerId);
|
||
state.layout.rightDrag = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startWidth: state.layout.rightSidebarWidth
|
||
};
|
||
el.shell.classList.add("is-resizing");
|
||
});
|
||
el.rightSidebarResize.addEventListener("pointermove", (event) => {
|
||
const drag = state.layout.rightDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
setRightSidebarWidth(drag.startWidth + drag.startX - event.clientX, { persist: false });
|
||
});
|
||
el.rightSidebarResize.addEventListener("pointerup", finishRightSidebarResize);
|
||
el.rightSidebarResize.addEventListener("pointercancel", finishRightSidebarResize);
|
||
el.rightSidebarResize.addEventListener("keydown", (event) => {
|
||
if (!canResizeRightSidebar()) return;
|
||
const keySteps = {
|
||
ArrowLeft: 16,
|
||
ArrowRight: -16,
|
||
PageUp: 48,
|
||
PageDown: -48
|
||
};
|
||
if (Object.hasOwn(keySteps, event.key)) {
|
||
event.preventDefault();
|
||
setRightSidebarWidth(state.layout.rightSidebarWidth + keySteps[event.key]);
|
||
} else if (event.key === "Home") {
|
||
event.preventDefault();
|
||
setRightSidebarWidth(rightSidebarResizeBounds().min);
|
||
} else if (event.key === "End") {
|
||
event.preventDefault();
|
||
setRightSidebarWidth(rightSidebarResizeBounds().max);
|
||
}
|
||
});
|
||
}
|
||
|
||
function finishRightSidebarResize(event) {
|
||
const drag = state.layout.rightDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
state.layout.rightDrag = null;
|
||
el.shell.classList.remove("is-resizing");
|
||
persistLayout();
|
||
}
|
||
|
||
function canResizeRightSidebar() {
|
||
return !state.layout.rightSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches;
|
||
}
|
||
|
||
async function loadHelpSurface() {
|
||
el.helpStatus.textContent = "加载中";
|
||
el.helpStatus.className = "state-tag tone-dry-run";
|
||
el.helpContent.dataset.helpState = "loading";
|
||
|
||
try {
|
||
const response = await fetch("/help.md", { cache: "no-store" });
|
||
if (!response.ok) {
|
||
throw new Error(`help.md HTTP ${response.status}`);
|
||
}
|
||
const markdown = await response.text();
|
||
el.helpContent.innerHTML = marked.parse(markdown, { gfm: true, breaks: false });
|
||
el.helpContent.dataset.helpState = "ready";
|
||
el.helpStatus.textContent = "已就绪";
|
||
el.helpStatus.className = "state-tag tone-source";
|
||
} catch (error) {
|
||
el.helpContent.dataset.helpState = "error";
|
||
el.helpContent.replaceChildren();
|
||
const notice = document.createElement("div");
|
||
notice.className = "help-error";
|
||
notice.textContent = `帮助内容加载失败:${error.message}。默认工作台仍可继续使用。`;
|
||
el.helpContent.append(notice);
|
||
el.helpStatus.textContent = "加载失败";
|
||
el.helpStatus.className = "state-tag tone-blocked";
|
||
}
|
||
}
|