1081 lines
41 KiB
TypeScript
1081 lines
41 KiB
TypeScript
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 { 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"]);
|
|
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;
|
|
|
|
await ensureWorkbenchAuth({
|
|
loginShell: el.loginShell,
|
|
appShell: el.shell
|
|
});
|
|
|
|
const state = {
|
|
conversationId: null,
|
|
sessionId: null,
|
|
threadId: null,
|
|
codeAgentAvailability: null,
|
|
chatMessages: [],
|
|
traceStreams: new Map(),
|
|
traceDetailsOpen: new Map(),
|
|
conversationRenderVersion: 0,
|
|
conversationScrollUserActiveUntil: 0,
|
|
conversationScrollPosition: { top: 0, left: 0 },
|
|
traceScrollPositions: 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 请求使用 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;
|
|
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.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,
|
|
updatedAtMs: Date.now(),
|
|
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/agent/conversations?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}&limit=1`, {
|
|
timeoutMs: Math.min(API_TIMEOUT_MS, 5000),
|
|
timeoutName: "Code Agent account conversation hydrate"
|
|
});
|
|
if (!response.ok) return;
|
|
const conversation = response.data?.defaultConversation ?? (Array.isArray(response.data?.conversations) ? response.data.conversations[0] : null);
|
|
if (!conversation) {
|
|
accountConversationHydrated = true;
|
|
if (state.conversationId) persistCodeAgentSessionState();
|
|
return;
|
|
}
|
|
const payload = accountConversationToStoredSessionPayload(conversation);
|
|
if (!payload) 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.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 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 (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
|
|
}
|
|
})
|
|
});
|
|
}
|
|
} finally {
|
|
accountConversationPersistInFlight = false;
|
|
}
|
|
}
|
|
|
|
function latestTraceIdFromStoredMessages(messages) {
|
|
for (const message of [...(messages ?? [])].reverse()) {
|
|
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";
|
|
}
|
|
}
|