diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 2905823..74284f3 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -723,6 +723,19 @@ return "binary"; } + function focusLatestDispatchedControls(container) { + requestAnimationFrame(() => { + const transcript = $("#assistantMessages"); + const activeCard = container?.matches?.(".assistant-dispatched-controls") + ? container + : $(".assistant-dispatched-controls", container); + if (!transcript || !activeCard) return; + transcript.querySelectorAll(".assistant-dispatched-controls").forEach((card) => { + card.open = card === activeCard; + }); + }); + } + function appendDispatchedControls(message, result = {}) { if (!message?.body || !result || typeof result !== "object") return; const previous = message.dispatchedControlResult || {}; @@ -767,6 +780,7 @@ if (vueRenderer(host, viewCommands)) { host.dataset.commandSignature = signature; message.article?.classList.add("assistant-message-with-dispatched-controls"); + focusLatestDispatchedControls(host); followLatest(true); return; } @@ -778,6 +792,13 @@ card = document.createElement("details"); card.className = "assistant-dispatched-controls"; card.open = true; + card.addEventListener("toggle", () => { + if (!card.open) return; + const transcript = $("#assistantMessages"); + transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((otherCard) => { + if (otherCard !== card) otherCard.open = false; + }); + }); const summary = document.createElement("summary"); const headingIcon = document.createElement("span"); headingIcon.className = "assistant-dispatched-controls-heading-icon"; @@ -829,6 +850,7 @@ list.append(row); }); message.article?.classList.add("assistant-message-with-dispatched-controls"); + focusLatestDispatchedControls(card); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); followLatest(true); } diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index ef69b15..d40ac9e 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -1100,9 +1100,9 @@ sidebarToggle.setAttribute("aria-expanded", "false"); sidebarToggle.title = "展开左侧控制台"; - const footer = make("footer", "v2-sidebar-footer", ` + const footer = make("footer", "v2-sidebar-footer v2-control-link-card", ` -
智舱控制链路正在连接服务
+
智舱控制链路正在连接服务
`); commandPanel.append(sidebarToggle, footer); @@ -1675,6 +1675,11 @@ if (!list) return; const filter = $("#v2LogFilter")?.value || "all"; const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter); + const count = $("#v2SyncLogCount"); + if (count) { + count.textContent = String(items.length); + count.title = `${items.length} 条记录`; + } const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" }; list.innerHTML = items.length ? items.map((item) => `
  • @@ -1685,6 +1690,21 @@ window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); } + function setSyncLogCollapsed(collapsed) { + const panel = $(".v2-sync-log"); + const list = $("#v2SyncLogList"); + const toggle = $("#v2SyncLogToggle"); + if (!panel || !list || !toggle) return; + const nextCollapsed = Boolean(collapsed); + const label = nextCollapsed ? "展开运行日志与同步记录" : "折叠运行日志与同步记录"; + panel.dataset.collapsed = String(nextCollapsed); + list.hidden = nextCollapsed; + toggle.setAttribute("aria-expanded", String(!nextCollapsed)); + toggle.setAttribute("aria-label", label); + toggle.title = label; + requestAnimationFrame(syncSidebarLogBaseline); + } + function panelVisible(key) { const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel"); if (panel) return !panel.classList.contains("is-collapsed"); @@ -2439,9 +2459,15 @@ const sidebarFooter = $(".v2-sidebar-footer", sidebar); const syncSidebar = make("section", "v2-sync-sidebar", `
    -
    -

    运行日志 / 同步记录

    -
      +
      +
      +

      运行日志 / 同步记录

      +
      + + +
      +
      +
      `); if (sidebar && sidebarFooter) { sidebar.insertBefore(syncSidebar, sidebarFooter); @@ -2574,6 +2600,10 @@ if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target); }); $("#v2LogFilter")?.addEventListener("change", renderSyncLogs); + $("#v2SyncLogToggle")?.addEventListener("click", () => { + setSyncLogCollapsed($(".v2-sync-log")?.dataset.collapsed !== "true"); + }); + setSyncLogCollapsed(true); const beginPendingControlRequest = (request = {}) => { beginCommandTiming(request); resetValidatedCommandContext(); @@ -2879,18 +2909,38 @@ const connectionText = $("#v2ConnectionText"); if (connectionText) { const status = connectionState?.status || "connecting"; - const labels = { - connecting: "正在连接服务", - online: "服务已连接", - degraded: "连接不稳定", - offline: "服务已断开", - }; - connectionText.textContent = labels[status] || labels.connecting; - connectionText.title = connectionState?.lastError || connectionText.textContent; - $(".v2-sidebar-footer")?.classList.toggle( - "offline", - status === "offline" || status === "degraded", - ); + const selectedTransport = $('.vehicle-transport-options [data-control-transport][aria-pressed="true"]') + || $('.vehicle-transport-options [data-control-transport].active'); + const transport = selectedTransport?.dataset.controlTransport || ""; + const transportOnline = selectedTransport?.dataset.connectionState === "online"; + const transportLabel = transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"; + const footer = connectionText.closest(".v2-control-link-card"); + let presentationState = status; + let label = "正在连接服务"; + + if (status === "online" && transport) { + presentationState = transportOnline ? "online" : "offline"; + label = transport === "wireless_mqtt" + ? `${transportLabel} · MQTT ${transportOnline ? "已连接" : "未连接"}` + : `${transportLabel} · 信号链路${transportOnline ? "正常" : "断开"}`; + } else if (status === "online") { + presentationState = "online"; + label = "服务已连接"; + } else if (status === "degraded") { + label = transport ? `${transportLabel} · 主服务连接不稳定` : "主服务连接不稳定"; + } else if (status === "offline") { + label = transport ? `${transportLabel} · 主服务已断开` : "主服务已断开"; + } + + connectionText.textContent = label; + connectionText.title = connectionState?.lastError + || (!transportOnline ? selectedTransport?.title : "") + || label; + if (footer) { + footer.dataset.transport = transport || "service"; + footer.dataset.state = presentationState; + footer.classList.toggle("offline", presentationState !== "online"); + } } keepRunButtonAvailable(); } @@ -2927,6 +2977,7 @@ syncRuntimePresentation(event.detail); }); window.addEventListener("cognitive-agent:vehicle-feedback-state", () => { + syncRuntimePresentation(); renderSyncedDevices(); if (displaySync.activeDevice) { presentTwinDevice(displaySync.activeDevice, { @@ -2935,6 +2986,14 @@ }); } }); + const transportOptions = $(".vehicle-transport-options"); + if (transportOptions) { + new MutationObserver(() => syncRuntimePresentation()).observe(transportOptions, { + attributes: true, + attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"], + subtree: true, + }); + } const observer = new MutationObserver(() => keepRunButtonAvailable()); [$("#hardwareMode"), $("#simulationMode")] .filter(Boolean) diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index b2cf0e2..34cf120 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"; +const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ diff --git a/apps/web-console/src/styles/design-system.css b/apps/web-console/src/styles/design-system.css index 5327730..1d2a840 100644 --- a/apps/web-console/src/styles/design-system.css +++ b/apps/web-console/src/styles/design-system.css @@ -44,6 +44,7 @@ body.console-v2 :is( .v2-sync-transport-field, .v2-sync-execution-field, .v2-can-command-panel, + .v2-control-link-card, .v2-nfc-inline, .v2-extension-gateway, .v2-extension-synced-device, @@ -132,6 +133,119 @@ body.console-v2 .v2-sync-log h3 { white-space: nowrap; } +/* Sidebar disclosure, connection status and control-command readability use + * the same semantic tokens in both day and night themes. */ +body.console-v2 .v2-sync-log[data-collapsed="true"] { + min-height: 0; + align-self: start; + grid-template-rows: auto; + flex: 0 0 auto; +} + +body.console-v2 .v2-sync-log[data-collapsed="true"] > header { + border-bottom-color: transparent; +} + +body.console-v2 .v2-sync-log[data-collapsed="true"] #v2LogFilter { + display: none; +} + +body.console-v2 .v2-sync-log #v2SyncLogList[hidden] { + display: none !important; +} + +body.console-v2 .v2-sync-log > header > div:first-child { + min-width: 0; +} + +body.console-v2 .v2-sync-log .v2-sync-log-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle { + min-width: 46px; + min-height: var(--n7-control-height-sm); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 7px; + color: var(--muted-foreground); + background: var(--secondary); + border: 1px solid var(--input); + border-radius: var(--n7-radius-control); + cursor: pointer; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle:is(:hover, :focus-visible) { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--ring); + outline: none; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle svg { + width: 15px; + height: 15px; + transition: transform 160ms ease; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +body.console-v2.v2-passive-display.v2-interactive-sync .v2-control-link-card { + min-height: 56px; + grid-template-columns: 10px minmax(0, 1fr); + gap: 10px; + margin: 0 !important; + padding: 10px 11px !important; + color: var(--card-foreground); + background: var(--glass-card) !important; + border: 1px solid var(--glass-line) !important; + border-radius: var(--n7-radius-card) !important; + box-shadow: var(--n7-shadow-card) !important; + -webkit-backdrop-filter: blur(16px) saturate(1.2); + backdrop-filter: blur(16px) saturate(1.2); +} + +body.console-v2 .v2-control-link-card strong { + color: var(--card-foreground) !important; +} + +body.console-v2 .v2-control-link-card small { + color: var(--muted-foreground) !important; +} + +body.console-v2 .v2-control-link-card[data-state="online"] .v2-live-dot { + background: var(--status-online); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-online) 18%, transparent); +} + +body.console-v2 .v2-control-link-card[data-state="connecting"] .v2-live-dot { + background: var(--status-info); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-info) 18%, transparent); +} + +body.console-v2 .v2-control-link-card[data-state="degraded"] .v2-live-dot { + background: var(--status-warning); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-warning) 18%, transparent); +} + +body.console-v2 .v2-control-link-card[data-state="offline"] .v2-live-dot { + background: var(--status-offline); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-offline) 18%, transparent); +} + +body.console-v2.v2-passive-display.v2-interactive-sync +.v2-can-command-list li[data-can-channel] +.v2-can-command-actual code { + color: var(--card-foreground) !important; +} + body.console-v2 :is( .mode-gallery-header h2, .mode-editor-header h2, diff --git a/apps/web-console/vue-ui.js b/apps/web-console/vue-ui.js index 03ba581..d087c76 100644 --- a/apps/web-console/vue-ui.js +++ b/apps/web-console/vue-ui.js @@ -120,6 +120,11 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue"; open: state.open, onToggle(event) { state.open = event.currentTarget.open; + if (!state.open) return; + const transcript = document.querySelector("#assistantMessages"); + transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((card) => { + if (card !== event.currentTarget) card.open = false; + }); }, }, [ h("summary", null, [ diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 35d80dc..5c7f9f4 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -1363,7 +1363,7 @@ operations: self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn( - 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"', + 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"', service_worker, ) self.assertIn('/console-v2.css?v=delivery-state-69', service_worker) diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index 75041c9..0c41c1c 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface(): def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8") assert ( - 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' + 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker ) assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker @@ -120,6 +120,22 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card(): assert ".assistant-dispatched-controls[open]" in css +def test_latest_dispatched_control_group_is_the_only_group_expanded(): + assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text( + encoding="utf-8" + ) + vue_ui = (ROOT / "apps/web-console/vue-ui.js").read_text(encoding="utf-8") + + assert "function focusLatestDispatchedControls(container)" in assistant + assert 'transcript.querySelectorAll(".assistant-dispatched-controls")' in assistant + assert "card.open = card === activeCard" in assistant + assert "focusLatestDispatchedControls(host)" in assistant + assert "focusLatestDispatchedControls(card)" in assistant + assert 'querySelectorAll(".assistant-dispatched-controls[open]")' in assistant + assert 'querySelectorAll(".assistant-dispatched-controls[open]")' in vue_ui + assert "if (card !== event.currentTarget) card.open = false" in vue_ui + + def test_saved_mode_execution_opens_with_pending_flow_before_http_completion(): app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8") flow = app.split( diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 077fee9..e3673f2 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: "/energy-monitor.js?v=energy-monitor-4" ) assert ( - 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' + 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker ) diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py index 41cb2a8..6f6b2b6 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -52,7 +52,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None: assert source_asset in main assert f'"{cached_asset}"' in service_worker assert 'import "./styles/design-system.css"' in main - assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker def test_reference_glass_theme_covers_the_complete_workspace() -> None: @@ -247,6 +247,22 @@ def test_command_fields_use_distinct_semantic_colors() -> None: assert mapping in console +def test_sidebar_command_and_transport_status_use_theme_semantics() -> None: + console = source("console-v2.js") + design_system = source("src/styles/design-system.css") + + assert '"v2-sidebar-footer v2-control-link-card"' in console + assert 'transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"' in console + assert 'MQTT ${transportOnline ? "已连接" : "未连接"}' in console + assert 'footer.dataset.state = presentationState' in console + assert 'attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"]' in console + assert ".v2-control-link-card" in design_system + assert 'data-state="online"' in design_system + assert 'data-state="offline"' in design_system + assert ".v2-can-command-actual code" in design_system + assert "color: var(--card-foreground) !important" in design_system + + def test_function_modes_use_an_inline_accessible_collapsible() -> None: html = source("index.html") app = source("app.js") diff --git a/tests/unit/test_spatial_ui_consistency_contract.py b/tests/unit/test_spatial_ui_consistency_contract.py index 6b812bc..c572bec 100644 --- a/tests/unit/test_spatial_ui_consistency_contract.py +++ b/tests/unit/test_spatial_ui_consistency_contract.py @@ -133,6 +133,22 @@ def test_compact_sidebar_overrides_cannot_break_shared_geometry() -> None: assert "white-space: nowrap" in design_system +def test_runtime_log_starts_collapsed_with_an_accessible_disclosure() -> None: + console = source("console-v2.js") + design_system = source("src/styles/design-system.css") + + assert 'class="v2-sync-log" aria-labelledby="v2SyncLogTitle" data-collapsed="true"' in console + assert 'id="v2SyncLogList" aria-live="polite" hidden' in console + assert 'id="v2SyncLogToggle"' in console + assert 'aria-controls="v2SyncLogList"' in console + assert 'aria-expanded="false"' in console + assert "function setSyncLogCollapsed(collapsed)" in console + assert "setSyncLogCollapsed(true)" in console + assert '.v2-sync-log[data-collapsed="true"]' in design_system + assert '.v2-sync-log[data-collapsed="true"] #v2LogFilter' in design_system + assert '#v2SyncLogList[hidden]' in design_system + + def test_assistant_composer_hint_stays_inside_narrow_input() -> None: styles = source("ui-consistency.css") diff --git a/tests/unit/test_vue_frontend_contract.py b/tests/unit/test_vue_frontend_contract.py index 659aedd..8c72f01 100644 --- a/tests/unit/test_vue_frontend_contract.py +++ b/tests/unit/test_vue_frontend_contract.py @@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None: assert package["scripts"]["build"] == "vite build" assert 'import vue from "@vitejs/plugin-vue"' in vite assert 'outDir: outputRoot' in vite - assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker assert "unpkg.com" not in html assert "cdn.jsdelivr.net" not in html