Эх сурвалжийг харах

feat: persist debug console presentation state

Problem: collapsed control sections and the debug drawer were unreachable, and session/mode context was not synchronized in the new console shell.\n\nRisk: localStorage failures are ignored so presentation preferences cannot block runtime requests.
zhenyu.hu 2 долоо хоног өмнө
parent
commit
b9fe38aa3f

+ 109 - 0
src/agent_lab/presentation/static/app.js

@@ -28,6 +28,7 @@ const toolModeHelp = document.querySelector("#tool-mode-help");
 const eventAgentModelSettings = document.querySelector("#event-agent-model-settings");
 const WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
 const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
+const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";
 
 let socket = null;
 let activeAssistant = null;
@@ -91,6 +92,7 @@ chatForm.addEventListener("submit", (event) => {
   runDebugSession();
 });
 
+initializeConsoleUi();
 initializePromptList();
 refreshWorkspaceSnapshotSelector();
 loadTools();
@@ -98,6 +100,109 @@ loadSessions({ preserveStatus: true });
 renderAuditReplay([]);
 renderSessionUsage({ calls: [], turns: [], session: {} });
 
+function readConsoleUiState() {
+  try {
+    const state = JSON.parse(localStorage.getItem(CONSOLE_UI_STORAGE_KEY) || "{}");
+    return state && typeof state === "object" && !Array.isArray(state) ? state : {};
+  } catch (error) {
+    return {};
+  }
+}
+
+function writeConsoleUiState(state) {
+  try {
+    localStorage.setItem(CONSOLE_UI_STORAGE_KEY, JSON.stringify(state));
+  } catch (error) {
+    // Presentation preferences are optional when storage is unavailable.
+  }
+}
+
+function setControlSectionExpanded(button, expanded, { persist = true } = {}) {
+  const targetId = button.dataset.collapseTarget;
+  const target = targetId ? document.querySelector(`#${targetId}`) : null;
+  if (!target) {
+    return;
+  }
+  button.setAttribute("aria-expanded", String(expanded));
+  target.hidden = !expanded;
+  if (persist) {
+    const state = readConsoleUiState();
+    const sections = state.sections && typeof state.sections === "object"
+      ? state.sections
+      : {};
+    writeConsoleUiState({
+      ...state,
+      sections: { ...sections, [targetId]: Boolean(expanded) },
+    });
+  }
+}
+
+function setDebugDrawerOpen(open, { persist = true } = {}) {
+  const layout = document.querySelector("#console-layout");
+  const drawer = document.querySelector("#debug-drawer");
+  const button = document.querySelector("#toggle-debug-drawer");
+  if (layout) {
+    layout.classList.toggle("debug-drawer-closed", !open);
+  }
+  if (drawer) {
+    drawer.hidden = !open;
+  }
+  if (button) {
+    button.setAttribute("aria-expanded", String(open));
+    button.textContent = open ? "Hide Debug" : "Show Debug";
+  }
+  if (persist) {
+    writeConsoleUiState({
+      ...readConsoleUiState(),
+      debug_drawer_open: Boolean(open),
+    });
+  }
+}
+
+function initializeConsoleUi() {
+  const state = readConsoleUiState();
+  document.querySelectorAll(".control-section-toggle").forEach((button) => {
+    const targetId = button.dataset.collapseTarget;
+    const storedExpanded = state.sections && typeof state.sections[targetId] === "boolean"
+      ? state.sections[targetId]
+      : button.getAttribute("aria-expanded") === "true";
+    setControlSectionExpanded(button, storedExpanded, { persist: false });
+    button.addEventListener("click", () => {
+      setControlSectionExpanded(
+        button,
+        button.getAttribute("aria-expanded") !== "true",
+      );
+    });
+  });
+
+  const drawerButton = document.querySelector("#toggle-debug-drawer");
+  if (drawerButton) {
+    const storedOpen = typeof state.debug_drawer_open === "boolean"
+      ? state.debug_drawer_open
+      : drawerButton.getAttribute("aria-expanded") === "true";
+    setDebugDrawerOpen(storedOpen, { persist: false });
+    drawerButton.addEventListener("click", () => {
+      setDebugDrawerOpen(drawerButton.getAttribute("aria-expanded") !== "true");
+    });
+  }
+  updateTopbarStatus();
+}
+
+function updateTopbarStatus() {
+  const sessionLabel = document.querySelector("#current-session-label");
+  const modeLabel = document.querySelector("#current-mode-label");
+  if (sessionLabel) {
+    sessionLabel.textContent = currentSessionId
+      ? `Session ${currentSessionId.slice(0, 8)}`
+      : "No session";
+  }
+  if (modeLabel) {
+    modeLabel.textContent = toolInvocationMode.value === "chat_agent_tools"
+      ? "ChatAgent Tools"
+      : "Dual Agent";
+  }
+}
+
 function openChatConfig() {
   chatConfigDialog.showModal();
 }
@@ -181,6 +286,7 @@ async function createSession() {
       }),
     });
     currentSessionId = session.id;
+    updateTopbarStatus();
     sessionTitle.value = session.title;
     await loadSessions({ preserveStatus: true });
     await loadSessionReplay(session.id, { preserveStatus: true });
@@ -203,6 +309,7 @@ async function loadSelectedSession() {
   closeSocket();
   setSessionStatus("Loading session");
   currentSessionId = sessionId;
+  updateTopbarStatus();
   await loadSessionReplay(sessionId);
 }
 
@@ -441,6 +548,7 @@ function restoreWorkspaceSnapshot(snapshot) {
 
 function refreshToolModeState() {
   const isChatAgentTools = toolInvocationMode.value === "chat_agent_tools";
+  updateTopbarStatus();
   updateToolModeHelp();
   eventAgentModelSettings
     .querySelectorAll("input, textarea, select, button")
@@ -995,6 +1103,7 @@ function handleServerMessage(message) {
   if (message.type === "session_started") {
     if (message.session_id) {
       currentSessionId = message.session_id;
+      updateTopbarStatus();
       loadSessions({ preserveStatus: true });
     }
     appendLog("session", "Session started");

+ 436 - 0
tests/test_websocket_api.py

@@ -132,6 +132,23 @@ def _run_node_json(source: str) -> object:
     return json.loads(completed.stdout)
 
 
+def _console_ui_source(js: str) -> str:
+    assert 'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";' in js
+    for function_name in [
+        "readConsoleUiState",
+        "writeConsoleUiState",
+        "setControlSectionExpanded",
+        "setDebugDrawerOpen",
+        "initializeConsoleUi",
+        "updateTopbarStatus",
+    ]:
+        assert f"function {function_name}(" in js
+    return js[
+        js.index("function readConsoleUiState(") :
+        js.index("function openChatConfig(")
+    ]
+
+
 def _run_load_session_replay_scenario(scenario: str) -> object:
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
     load_replay = js[
@@ -1564,6 +1581,424 @@ def test_static_professional_console_control_sections_have_accessible_defaults()
         assert ("hidden" in body.attrs) is not expanded
 
 
+def test_static_console_ui_section_click_opens_hidden_content_and_persists():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    console_ui = _console_ui_source(js)
+    result = _run_node_json(
+        "\n".join(
+            [
+                'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
+                """
+function makeElement({ dataset = {}, expanded = "false", hidden = false } = {}) {
+  const listeners = {};
+  return {
+    dataset,
+    hidden,
+    textContent: "",
+    attributes: { "aria-expanded": expanded },
+    classList: { toggle() {} },
+    addEventListener(type, listener) { listeners[type] = listener; },
+    click() { listeners.click(); },
+    getAttribute(name) { return this.attributes[name]; },
+    setAttribute(name, value) { this.attributes[name] = String(value); },
+  };
+}
+const sectionButton = makeElement({
+  dataset: { collapseTarget: "agent-config-controls" },
+  expanded: "false",
+});
+const sectionBody = makeElement({ hidden: true });
+const drawerButton = makeElement({ expanded: "true" });
+drawerButton.textContent = "Hide Debug";
+const drawer = makeElement();
+const layout = makeElement();
+const elements = {
+  "#agent-config-controls": sectionBody,
+  "#toggle-debug-drawer": drawerButton,
+  "#debug-drawer": drawer,
+  "#console-layout": layout,
+};
+const document = {
+  querySelector(selector) { return elements[selector] || null; },
+  querySelectorAll(selector) {
+    return selector === ".control-section-toggle" ? [sectionButton] : [];
+  },
+};
+let stored = JSON.stringify({
+  debug_drawer_open: false,
+  sections: { "agent-config-controls": false },
+});
+globalThis.localStorage = {
+  getItem() { return stored; },
+  setItem(key, value) { stored = value; },
+};
+""",
+                console_ui,
+                """
+initializeConsoleUi();
+sectionButton.click();
+console.log(JSON.stringify({
+  expanded: sectionButton.getAttribute("aria-expanded"),
+  hidden: sectionBody.hidden,
+  stored: JSON.parse(stored),
+}));
+""",
+            ]
+        )
+    )
+
+    assert result == {
+        "expanded": "true",
+        "hidden": False,
+        "stored": {
+            "debug_drawer_open": False,
+            "sections": {"agent-config-controls": True},
+        },
+    }
+
+
+def test_static_console_ui_drawer_click_updates_layout_visibility_and_control():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    console_ui = _console_ui_source(js)
+    result = _run_node_json(
+        "\n".join(
+            [
+                'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
+                """
+function makeElement({ expanded = "true" } = {}) {
+  const listeners = {};
+  const classes = new Set();
+  return {
+    hidden: false,
+    textContent: "Hide Debug",
+    attributes: { "aria-expanded": expanded },
+    classList: {
+      toggle(name, force) {
+        if (force) classes.add(name); else classes.delete(name);
+      },
+      contains(name) { return classes.has(name); },
+    },
+    addEventListener(type, listener) { listeners[type] = listener; },
+    click() { listeners.click(); },
+    getAttribute(name) { return this.attributes[name]; },
+    setAttribute(name, value) { this.attributes[name] = String(value); },
+  };
+}
+const drawerButton = makeElement();
+const drawer = makeElement();
+const layout = makeElement();
+const elements = {
+  "#toggle-debug-drawer": drawerButton,
+  "#debug-drawer": drawer,
+  "#console-layout": layout,
+};
+const document = {
+  querySelector(selector) { return elements[selector] || null; },
+  querySelectorAll() { return []; },
+};
+let stored = JSON.stringify({ sections: { "snapshot-controls": true } });
+globalThis.localStorage = {
+  getItem() { return stored; },
+  setItem(key, value) { stored = value; },
+};
+function snapshot() {
+  return {
+    closed: layout.classList.contains("debug-drawer-closed"),
+    hidden: drawer.hidden,
+    text: drawerButton.textContent,
+    expanded: drawerButton.getAttribute("aria-expanded"),
+    stored: JSON.parse(stored),
+  };
+}
+""",
+                console_ui,
+                """
+initializeConsoleUi();
+drawerButton.click();
+const closed = snapshot();
+drawerButton.click();
+const opened = snapshot();
+console.log(JSON.stringify({ closed, opened }));
+""",
+            ]
+        )
+    )
+
+    assert result["closed"] == {
+        "closed": True,
+        "hidden": True,
+        "text": "Show Debug",
+        "expanded": "false",
+        "stored": {
+            "sections": {"snapshot-controls": True},
+            "debug_drawer_open": False,
+        },
+    }
+    assert result["opened"] == {
+        "closed": False,
+        "hidden": False,
+        "text": "Hide Debug",
+        "expanded": "true",
+        "stored": {
+            "sections": {"snapshot-controls": True},
+            "debug_drawer_open": True,
+        },
+    }
+
+
+def test_static_console_ui_restores_valid_state_without_writing_on_initialize():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    console_ui = _console_ui_source(js)
+    result = _run_node_json(
+        "\n".join(
+            [
+                'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
+                """
+function makeElement({ dataset = {}, expanded = "false", hidden = false } = {}) {
+  const listeners = {};
+  const classes = new Set();
+  return {
+    dataset,
+    hidden,
+    textContent: "",
+    attributes: { "aria-expanded": expanded },
+    classList: {
+      toggle(name, force) {
+        if (force) classes.add(name); else classes.delete(name);
+      },
+      contains(name) { return classes.has(name); },
+    },
+    addEventListener(type, listener) { listeners[type] = listener; },
+    getAttribute(name) { return this.attributes[name]; },
+    setAttribute(name, value) { this.attributes[name] = String(value); },
+  };
+}
+const agentButton = makeElement({
+  dataset: { collapseTarget: "agent-config-controls" },
+  expanded: "false",
+});
+const snapshotButton = makeElement({
+  dataset: { collapseTarget: "snapshot-controls" },
+  expanded: "false",
+});
+const agentBody = makeElement({ hidden: true });
+const snapshotBody = makeElement({ hidden: true });
+const drawerButton = makeElement({ expanded: "true" });
+drawerButton.textContent = "Hide Debug";
+const drawer = makeElement();
+const layout = makeElement();
+const elements = {
+  "#agent-config-controls": agentBody,
+  "#snapshot-controls": snapshotBody,
+  "#toggle-debug-drawer": drawerButton,
+  "#debug-drawer": drawer,
+  "#console-layout": layout,
+};
+const document = {
+  querySelector(selector) { return elements[selector] || null; },
+  querySelectorAll(selector) {
+    return selector === ".control-section-toggle"
+      ? [agentButton, snapshotButton]
+      : [];
+  },
+};
+let writes = 0;
+globalThis.localStorage = {
+  getItem() {
+    return JSON.stringify({
+      debug_drawer_open: false,
+      sections: { "agent-config-controls": true },
+    });
+  },
+  setItem() { writes += 1; },
+};
+""",
+                console_ui,
+                """
+initializeConsoleUi();
+console.log(JSON.stringify({
+  agentExpanded: agentButton.getAttribute("aria-expanded"),
+  agentHidden: agentBody.hidden,
+  snapshotExpanded: snapshotButton.getAttribute("aria-expanded"),
+  snapshotHidden: snapshotBody.hidden,
+  drawerExpanded: drawerButton.getAttribute("aria-expanded"),
+  drawerHidden: drawer.hidden,
+  drawerClosed: layout.classList.contains("debug-drawer-closed"),
+  writes,
+}));
+""",
+            ]
+        )
+    )
+
+    assert result == {
+        "agentExpanded": "true",
+        "agentHidden": False,
+        "snapshotExpanded": "false",
+        "snapshotHidden": True,
+        "drawerExpanded": "false",
+        "drawerHidden": True,
+        "drawerClosed": True,
+        "writes": 0,
+    }
+
+
+def test_static_console_ui_storage_failures_fall_back_without_throwing():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    console_ui = _console_ui_source(js)
+    result = _run_node_json(
+        "\n".join(
+            [
+                'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
+                """
+function makeElement({ dataset = {}, expanded = "false", hidden = false } = {}) {
+  return {
+    dataset,
+    hidden,
+    textContent: "",
+    attributes: { "aria-expanded": expanded },
+    classList: { toggle() {} },
+    addEventListener() {},
+    getAttribute(name) { return this.attributes[name]; },
+    setAttribute(name, value) { this.attributes[name] = String(value); },
+  };
+}
+const sectionButton = makeElement({
+  dataset: { collapseTarget: "snapshot-controls" },
+  expanded: "false",
+});
+const sectionBody = makeElement({ hidden: true });
+const drawerButton = makeElement({ expanded: "true" });
+const drawer = makeElement();
+const layout = makeElement();
+const elements = {
+  "#snapshot-controls": sectionBody,
+  "#toggle-debug-drawer": drawerButton,
+  "#debug-drawer": drawer,
+  "#console-layout": layout,
+};
+const document = {
+  querySelector(selector) { return elements[selector] || null; },
+  querySelectorAll(selector) {
+    return selector === ".control-section-toggle" ? [sectionButton] : [];
+  },
+};
+globalThis.localStorage = {
+  getItem() { return "{malformed"; },
+  setItem() { throw new Error("storage unavailable"); },
+};
+""",
+                console_ui,
+                """
+const malformed = readConsoleUiState();
+writeConsoleUiState({ debug_drawer_open: true });
+initializeConsoleUi();
+const malformedDefaults = {
+  sectionExpanded: sectionButton.getAttribute("aria-expanded"),
+  sectionHidden: sectionBody.hidden,
+  drawerExpanded: drawerButton.getAttribute("aria-expanded"),
+  drawerHidden: drawer.hidden,
+};
+Object.defineProperty(globalThis, "localStorage", {
+  configurable: true,
+  get() { throw new Error("storage unavailable"); },
+});
+const unavailable = readConsoleUiState();
+writeConsoleUiState({ sections: {} });
+initializeConsoleUi();
+console.log(JSON.stringify({ malformed, malformedDefaults, unavailable }));
+""",
+            ]
+        )
+    )
+
+    assert result == {
+        "malformed": {},
+        "malformedDefaults": {
+            "sectionExpanded": "false",
+            "sectionHidden": True,
+            "drawerExpanded": "true",
+            "drawerHidden": False,
+        },
+        "unavailable": {},
+    }
+
+
+def test_static_topbar_status_updates_for_session_and_tool_mode():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    console_ui = _console_ui_source(js)
+    result = _run_node_json(
+        "\n".join(
+            [
+                'const CONSOLE_UI_STORAGE_KEY = "agent-lab-console-ui-v1";',
+                """
+const sessionLabel = { textContent: "" };
+const modeLabel = { textContent: "" };
+const toolInvocationMode = { value: "dual_agent" };
+const document = {
+  querySelector(selector) {
+    return {
+      "#current-session-label": sessionLabel,
+      "#current-mode-label": modeLabel,
+    }[selector] || null;
+  },
+};
+let currentSessionId = null;
+""",
+                console_ui,
+                """
+updateTopbarStatus();
+const initial = { session: sessionLabel.textContent, mode: modeLabel.textContent };
+currentSessionId = "1234567890abcdef";
+toolInvocationMode.value = "chat_agent_tools";
+updateTopbarStatus();
+console.log(JSON.stringify({
+  initial,
+  updated: { session: sessionLabel.textContent, mode: modeLabel.textContent },
+}));
+""",
+            ]
+        )
+    )
+
+    assert result == {
+        "initial": {"session": "No session", "mode": "Dual Agent"},
+        "updated": {"session": "Session 12345678", "mode": "ChatAgent Tools"},
+    }
+
+
+def test_static_console_ui_bootstrap_and_session_paths_refresh_topbar_status():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    refresh_tool_mode = js[
+        js.index("function refreshToolModeState(") :
+        js.index("function updateToolModeHelp(")
+    ]
+
+    assert "initializeConsoleUi();" in js[: js.index("function openChatConfig(")]
+    assert "updateTopbarStatus();" in refresh_tool_mode
+    assert "currentSessionId = session.id;\n    updateTopbarStatus();" in js
+    assert "currentSessionId = sessionId;\n  updateTopbarStatus();" in js
+    assert "currentSessionId = message.session_id;\n      updateTopbarStatus();" in js
+
+
+def test_static_presentation_state_is_excluded_from_request_and_snapshot():
+    js = Path("src/agent_lab/presentation/static/app.js").read_text()
+    build_snapshot = js[
+        js.index("function buildWorkspaceSnapshot()") :
+        js.index("function restoreWorkspaceSnapshot(")
+    ]
+    build_request = js[
+        js.index("function buildRequest(") :
+        js.index("function selectedTools(")
+    ]
+
+    for source in [build_snapshot, build_request]:
+        assert "CONSOLE_UI_STORAGE_KEY" not in source
+        assert "readConsoleUiState" not in source
+        assert "writeConsoleUiState" not in source
+        assert "debug_drawer_open" not in source
+
+
 def test_static_professional_console_defines_visual_and_responsive_contracts():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     css = Path("src/agent_lab/presentation/static/styles.css").read_text()
@@ -1790,6 +2225,7 @@ const toolInvocationMode = { value: "dual_agent" };
 const eventAgentModelSettings = { querySelectorAll() { return []; } };
 const availableTools = [{ name: "handoff_note" }];
 let eventPromptInsertionIndex = 0;
+function updateTopbarStatus() {}
 function updateToolModeHelp() {}
 function selectedTools() { return ["handoff_note"]; }
 function buildAvailableEventsPrompt() { return generated; }