Files
pikasTech-HWLAB/web/hwlab-cloud-web/app.ts
T
2026-06-02 12:25:16 +08:00

1202 lines
48 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 { 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"),
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"),
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: [],
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,
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,
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();
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);
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.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 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";
}
}