Files
pikasTech-HWLAB/web/hwlab-cloud-web/app.ts
T
2026-06-03 10:40:50 +08:00

1474 lines
58 KiB
TypeScript
Raw Blame History

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