From 5cefd0ddb6da354ac2a0fa514773d1d211a69e72 Mon Sep 17 00:00:00 2001 From: Loocor Date: Wed, 27 May 2026 13:58:06 +0800 Subject: [PATCH 1/2] refactor(settings): consolidate native helper page Remove the obsolete standalone settings page and align Helper settings with Codex native settings groups while keeping default settings consistent across dev and Tauri backends. --- runtime/_test-settings.test.js | 49 +++++--- runtime/constants.js | 11 +- runtime/native-settings.js | 25 +++- runtime/settings.js | 203 +-------------------------------- runtime/styles.js | 132 --------------------- runtime/zed-open.js | 2 +- src-tauri/src/settings.rs | 115 ++++++++++--------- src/routes.test.ts | 20 ++++ src/routes.ts | 95 +++++++++------ 9 files changed, 199 insertions(+), 453 deletions(-) diff --git a/runtime/_test-settings.test.js b/runtime/_test-settings.test.js index 9e014da..7719020 100644 --- a/runtime/_test-settings.test.js +++ b/runtime/_test-settings.test.js @@ -8,6 +8,7 @@ const nativeSettingsSource = readFileSync( join(import.meta.dir, "native-settings.js"), "utf8", ); +const zedOpenSource = readFileSync(join(import.meta.dir, "zed-open.js"), "utf8"); function extractFunction(name) { const marker = `function ${name}(`; @@ -186,18 +187,20 @@ test("settings page exposes port forwarding policy switches", () => { const descKeyBinding = templatePlaceholder("descKey"); const toggleKeyBinding = templatePlaceholder("toggleKey"); expect(source).toContain("Enable port forwarding"); - expect(source).toContain('${helperSettingsSectionAttribute}="port-forwarding"'); + expect(source).toContain('${helperSettingsSectionAttribute}="${sectionId}"'); + expect(source).toContain('nativeSettingsGroupSection("Port forwarding"'); + expect(source).toContain('"port-forwarding")'); expect(source).toContain("function focusHelperSettingsSection("); expect(source).toContain(`data-codex-helper-setting-desc="${descKeyBinding}"`); expect(source).toContain(`${helperToggleBinding}="${toggleKeyBinding}"`); expect(source).toContain( - 'switchRow("Enable port forwarding", "Detect and forward ports from agent sessions.", "portForwardingEnabled"', + 'nativeSettingsSwitchRow("Enable port forwarding", "Detect and forward ports from agent sessions.", "portForwardingEnabled"', ); expect(source).toContain( - 'switchRow("Auto-forward detected web ports", "Open forwarded web URLs when a common dev port is detected.", "portAutoForwardWeb"', + 'nativeSettingsSwitchRow("Auto-forward detected web ports", "Open forwarded web URLs when a common dev port is detected.", "portAutoForwardWeb"', ); expect(source).toContain( - 'switchRow("Use the same local port by default", "Bind forwarded ports to the same local port number when possible.", "portSameLocalPort"', + 'nativeSettingsSwitchRow("Use the same local port by default", "Bind forwarded ports to the same local port number when possible.", "portSameLocalPort"', ); }); @@ -217,24 +220,23 @@ test("disabling port forwarding stops managed tunnels", () => { }); test("settings page groups options by feature area", () => { - expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Basic'); - expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Auto naming'); - expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Sessions'); - expect(source).toContain('codex-helper-settings-section-title text-sm font-medium text-token-text-primary">Port forwarding'); - expect(source).toContain('sectionHeading("Loaded scripts"'); - expect(source).toContain('sectionHeading("Log files"'); + expect(source).toContain('nativeSettingsGroupSection("Integrations"'); + expect(source).toContain('nativeSettingsGroupSection("Session actions"'); + expect(source).toContain('nativeSettingsGroupSection("Chat titles"'); + expect(source).toContain('nativeSettingsGroupSection("Port forwarding"'); + expect(source).not.toContain('>Basic'); + expect(source).not.toContain('sectionHeading("Loaded scripts"'); + expect(source).not.toContain('sectionHeading("Log files"'); expect(source).toContain("https://github.com/loocor/codex-helper"); - expect(source).toContain('externalLinkRow("Project repository"'); - expect(source).toContain('actionRow("Open in Zed"'); - expect(source).toContain("sectionHeading"); + expect(source).toContain("function nativeSettingsAboutPageContent("); + expect(source).toContain('nativeSettingsActionRow("Open in Zed"'); expect(source).toContain("open-scripts-dir"); expect(source).toContain("open-logs-dir"); - expect(source).toContain("codex-helper-settings-section-link"); expect(source).not.toContain("Helper directory"); expect(source).toContain("codex-helper-settings-scroll"); - expect(source).toContain('forkRemoteProject: "Fork into Remote Project..."'); - expect(source).toContain('forkLocalProject: "Fork into Local Project..."'); - expect(source).toContain('forkAnotherProject: "Fork into Another Project..."'); + expect(source).toContain('forkRemoteProject: "Fork into remote project..."'); + expect(source).toContain('forkLocalProject: "Fork into local project..."'); + expect(source).toContain('forkAnotherProject: "Fork into another project..."'); expect(source).toContain('const order = ["autoRename", "export", "fork"]'); expect(source).toContain('autoRename: "Regenerate chat title"'); expect(source).toContain('bridge("/auto-rename-chat"'); @@ -490,9 +492,22 @@ test("native settings opener can use an existing Settings menu item or trigger c test("standalone helper settings dialog is not bundled", () => { expect(source).not.toContain("function showHelperSettingsDialog("); + expect(source).not.toContain("function renderHelperPage("); + expect(source).not.toContain("function clearHelperSettingsPage("); + expect(source).not.toContain("function stashHostContent("); + expect(source).not.toContain("function restoreStashedContent("); + expect(source).not.toContain("helperPageAttribute"); + expect(source).not.toContain("helperEntryAttribute"); + expect(source).not.toContain("helperContentHostAttribute"); + expect(source).not.toContain("helperPageRoot"); + expect(source).not.toContain("helperContentHost"); + expect(source).not.toContain("helperContentStash"); expect(source).not.toContain("helperDialogRoot = renderHelperPage(body,"); expect(source).not.toContain("pageAttribute: helperDialogPageAttribute"); expect(source).not.toContain("helperDialogRoot = renderNativeHelperSettingsPage"); + expect(source).toContain("helperNativeSettingsPageAttribute"); + expect(zedOpenSource).not.toContain("data-codex-helper-settings-page"); + expect(zedOpenSource).toContain("data-codex-helper-native-settings-page"); }); test("startup does not eagerly mount inline General settings page", () => { diff --git a/runtime/constants.js b/runtime/constants.js index 9132dca..cc81e01 100644 --- a/runtime/constants.js +++ b/runtime/constants.js @@ -1,7 +1,4 @@ // Constants and mutable runtime state -const helperEntryAttribute = "data-codex-helper-settings-entry"; -const helperContentHostAttribute = "data-codex-helper-content-host"; -const helperPageAttribute = "data-codex-helper-settings-page"; const helperCommandAttribute = "data-codex-helper-command"; const helperToggleAttribute = "data-codex-helper-setting-toggle"; const helperNumberAttribute = "data-codex-helper-setting-number"; @@ -28,13 +25,13 @@ const helperPanelClass = "codex-helper-panel flex flex-col divide-y-[0.5px] divide-token-border overflow-hidden rounded-lg border border-token-border"; let observerInstalled = false; let helperRuntimeObserver = null; -let helperPageRoot = null; +let sessionContextMenuMapRestore = null; +let sessionContextMenuReplayInFlight = false; +const SESSION_CONTEXT_MENU_MAX_AGE_MS = 2500; let helperNativeSettingsRoot = null; let helperNativeSettingsContentHost = null; let helperNativeSettingsContentStash = null; let helperNativeSettingsActivePage = ""; -let helperContentHost = null; -let helperContentStash = null; let pendingSessionMenuContext = null; let pendingPortScan = 0; let maintainPortsPanelTimer = 0; @@ -57,7 +54,7 @@ const suppressedPortMappings = new Set(); let featureSettings = { markdownExportEnabled: false, sessionMoveEnabled: false, - autoRenameMenuEnabled: false, + autoRenameMenuEnabled: true, markdownFriendlyFilenameEnabled: true, autoNamingMinChars: 4, autoNamingMaxChars: 10, diff --git a/runtime/native-settings.js b/runtime/native-settings.js index 384f0db..30855e2 100644 --- a/runtime/native-settings.js +++ b/runtime/native-settings.js @@ -396,6 +396,21 @@ function nativeSettingsPanel(rows, extraClass = "") { return `
${rows}
`; } +function nativeSettingsGroupTitle(title) { + return `
${title}
`; +} + +function nativeSettingsGroupSection(title, rows, sectionId = "") { + const sectionAttr = sectionId + ? ` ${helperSettingsSectionAttribute}="${sectionId}"` + : ""; + return ` +
+ ${nativeSettingsGroupTitle(title)} + ${nativeSettingsPanel(rows)} +
`; +} + function nativeSettingsIconSvg(name) { if (name === "refresh") { return nativeSettingsStandardIconSvg("refresh-cw"); @@ -531,26 +546,26 @@ function nativeSettingsPageContent(pageId) { return nativeSettingsAboutPageContent(); } return ` - ${nativeSettingsPanel(` + ${nativeSettingsGroupSection("Integrations", ` ${nativeSettingsActionRow("Backend", "Loading", "refresh", "Refresh", "data-codex-helper-backend")} ${nativeSettingsActionRow("Open in Zed", "Loading", "refresh", "Refresh", "data-codex-helper-zed-status")} ${nativeSettingsActionRow("DevTools", "Open Chrome DevTools for this Codex window.", "open-devtools", "Open")} `)} - ${nativeSettingsPanel(` + ${nativeSettingsGroupSection("Session actions", ` ${nativeSettingsSwitchRow("Markdown export", "Export conversations as Markdown from the session menu.", "markdownExportEnabled", "markdownExportEnabled", "Markdown export")} ${nativeSettingsSwitchRow("Fork sessions", "Fork sessions into local, remote, or another project from the sidebar context menu.", "sessionMoveEnabled", "sessionMoveEnabled", "Fork sessions")} `)} - ${nativeSettingsPanel(` + ${nativeSettingsGroupSection("Chat titles", ` ${nativeSettingsSwitchRow("Regenerate chat title", "Show Regenerate chat title in the session context menu.", "autoRenameMenuEnabled", "autoRenameMenuEnabled", "Regenerate chat title")} ${nativeSettingsSwitchRow("Friendly Markdown filenames", "Use Codex auto naming for exported Markdown filenames.", "markdownFriendlyFilenameEnabled", "markdownFriendlyFilenameEnabled", "Friendly Markdown filenames")} ${nativeSettingsNumberRow("Minimum characters", "Smallest expected auto name length.", "autoNamingMinChars", "Minimum auto naming characters")} ${nativeSettingsNumberRow("Maximum characters", "Largest expected auto name length; 10 works well for Chinese names.", "autoNamingMaxChars", "Maximum auto naming characters")} `)} - ${nativeSettingsPanel(` + ${nativeSettingsGroupSection("Port forwarding", ` ${nativeSettingsSwitchRow("Enable port forwarding", "Detect and forward ports from agent sessions.", "portForwardingEnabled", "portForwardingEnabled", "Enable port forwarding")} ${nativeSettingsSwitchRow("Auto-forward detected web ports", "Open forwarded web URLs when a common dev port is detected.", "portAutoForwardWeb", "portAutoForwardWeb", "Auto-forward detected web ports")} ${nativeSettingsSwitchRow("Use the same local port by default", "Bind forwarded ports to the same local port number when possible.", "portSameLocalPort", "portSameLocalPort", "Use the same local port by default")} - `)} + `, "port-forwarding")} `; } diff --git a/runtime/settings.js b/runtime/settings.js index 7de8c7a..0757fad 100644 --- a/runtime/settings.js +++ b/runtime/settings.js @@ -1,201 +1,6 @@ -// Helper Settings page content and commands -function setEntryActive(active) { - void active; -} - -function renderHelperPage(host, options = {}) { - const replaceHost = options.replaceHost === true; - const hidePageHeader = options.hidePageHeader === true; - const insertAfter = - options.insertAfter instanceof HTMLElement ? options.insertAfter : null; - const pageAttribute = options.pageAttribute || helperPageAttribute; - host.querySelectorAll(`[${pageAttribute}]`).forEach((node) => { - node.remove(); - }); - if (replaceHost && host.id !== helperSettingsPanelId) { - stashHostContent(host); - } else if (replaceHost) { - restoreStashedContent(); - } - if (replaceHost) { - host.setAttribute(helperContentHostAttribute, "true"); - host.setAttribute("data-codex-helper-active", "true"); - } - const page = document.createElement("section"); - page.setAttribute(pageAttribute, "true"); - page.className = "flex flex-col"; - const panelStyle = - 'style="background-color: var(--color-background-panel, var(--color-token-bg-fog));"'; - const switchRow = (title, description, descKey, toggleKey, ariaLabel) => ` -
-
-
${title}
-
${description}
-
- -
`; - const numberRow = (title, description, numberKey, ariaLabel) => ` -
-
-
${title}
-
${description}
-
- -
`; - const sectionLinkIcon = ``; - const sectionHeading = (title, command, ariaLabel) => ` -
-
${title}
- -
`; - const sectionToolbar = (statusAttr, command, buttonLabel = "Refresh") => ` -
-
Loading
- -
`; - const actionRow = (title, detail, command, buttonLabel, detailAttr = "") => ` -
-
-
${title}
-
${detail}
-
- -
`; - const externalLinkRow = (title, description, url, linkLabel) => ` -
-
-
${title}
-
${description}
-
- ${linkLabel} -
`; - const settingsPanel = (rows) => - `
${rows}
`; - page.innerHTML = ` - ${hidePageHeader - ? "" - : `
-
-
Codex Helper
-
-
` - } -
-
-
Basic
- ${settingsPanel(` - ${actionRow("Backend", "Loading", "refresh", "Refresh", "data-codex-helper-backend")} - ${actionRow("Open in Zed", "Loading", "refresh", "Refresh", "data-codex-helper-zed-status")} - ${actionRow("DevTools", "Open Chrome DevTools for this Codex window.", "open-devtools", "Open")} - `)} -
-
-
Sessions
- ${settingsPanel(` - ${switchRow("Markdown export", "Export conversations as Markdown from the session menu.", "markdownExportEnabled", "markdownExportEnabled", "Markdown export")} - ${switchRow("Fork sessions", "Fork sessions into local, remote, or another project from the sidebar context menu.", "sessionMoveEnabled", "sessionMoveEnabled", "Fork sessions")} - `)} -
-
-
Auto naming
- ${settingsPanel(` - ${switchRow("Regenerate chat title", "Show Regenerate chat title in the session context menu.", "autoRenameMenuEnabled", "autoRenameMenuEnabled", "Regenerate chat title")} - ${switchRow("Friendly Markdown filenames", "Use Codex auto naming for exported Markdown filenames.", "markdownFriendlyFilenameEnabled", "markdownFriendlyFilenameEnabled", "Friendly Markdown filenames")} - ${numberRow("Minimum characters", "Smallest expected auto name length.", "autoNamingMinChars", "Minimum auto naming characters")} - ${numberRow("Maximum characters", "Largest expected auto name length; 10 works well for Chinese names.", "autoNamingMaxChars", "Maximum auto naming characters")} - `)} -
-
-
Port forwarding
- ${settingsPanel(` - ${switchRow("Enable port forwarding", "Detect and forward ports from agent sessions.", "portForwardingEnabled", "portForwardingEnabled", "Enable port forwarding")} - ${switchRow("Auto-forward detected web ports", "Open forwarded web URLs when a common dev port is detected.", "portAutoForwardWeb", "portAutoForwardWeb", "Auto-forward detected web ports")} - ${switchRow("Use the same local port by default", "Bind forwarded ports to the same local port number when possible.", "portSameLocalPort", "portSameLocalPort", "Use the same local port by default")} - `)} -
-
- ${sectionHeading("Loaded scripts", "open-scripts-dir", "Open scripts folder")} - ${settingsPanel(` - ${sectionToolbar("data-codex-helper-scripts-status", "refresh")} -
- `)} -
-
- ${sectionHeading("Log files", "open-logs-dir", "Open logs folder")} - ${settingsPanel(` - ${sectionToolbar("data-codex-helper-log-path", "refresh")} -
Loading
- `)} -
-
-
About
- ${settingsPanel(` - ${externalLinkRow("Project repository", "Source code, issues, and releases on GitHub.", helperRepoUrl, "Open")} - `)} -
-
- `; - if (insertAfter && insertAfter.parentElement === host) { - insertAfter.insertAdjacentElement("afterend", page); - } else { - host.appendChild(page); - } - return page; -} - -function stashHostContent(host) { - if (!(host instanceof HTMLElement)) return; - if (helperContentStash?.host === host) return; - restoreStashedContent(); - - const marker = document.createComment("codex-helper-content-stash"); - const fragment = document.createDocumentFragment(); - host.insertBefore(marker, host.firstChild); - for (const node of Array.from(host.childNodes)) { - if (node === marker) continue; - if (node instanceof HTMLElement && node.hasAttribute(helperPageAttribute)) - continue; - fragment.appendChild(node); - } - helperContentStash = { host, marker, fragment }; -} - -function restoreStashedContent() { - if (!helperContentStash) return; - const { host, marker, fragment } = helperContentStash; - if ( - host instanceof HTMLElement && - marker instanceof Comment && - marker.parentNode === host - ) { - host.insertBefore(fragment, marker.nextSibling); - marker.remove(); - } - helperContentStash = null; -} - -function clearHelperSettingsPage() { - for (const node of document.querySelectorAll(`[${helperPageAttribute}]`)) { - node.remove(); - } - restoreNativeSettingsPanels(); - restoreStashedContent(); - for (const node of document.querySelectorAll( - `[${helperContentHostAttribute}]`, - )) { - node.removeAttribute("data-codex-helper-active"); - node.removeAttribute(helperContentHostAttribute); - } - helperContentHost = null; - helperPageRoot = null; - setEntryActive(false); -} - +// Helper Settings shared commands and state refresh function helperSettingsRoots() { - return [helperPageRoot, helperNativeSettingsRoot].filter( + return [helperNativeSettingsRoot].filter( (root) => root instanceof HTMLElement && root.isConnected, ); } @@ -280,9 +85,7 @@ function renderLoadedScripts(result) { if (scriptList.length === 0) { list.appendChild( createScrollEmptyMessage( - list.closest(`[${helperNativeSettingsPageAttribute}]`) - ? "No user scripts found." - : "No user scripts found in ~/.codex-helper/scripts.", + "No user scripts found.", ), ); continue; diff --git a/runtime/styles.js b/runtime/styles.js index 115d9e1..5fafb9e 100644 --- a/runtime/styles.js +++ b/runtime/styles.js @@ -8,7 +8,6 @@ function installHelperStyles() { document.head.appendChild(style); } style.textContent = ` - [${helperEntryAttribute}][data-active="true"], [${helperNativeSettingsEntryAttribute}][data-active="true"] { background: color-mix(in srgb, currentColor 10%, transparent) !important; } @@ -16,13 +15,6 @@ function installHelperStyles() { background: transparent !important; box-shadow: none !important; } - [${helperContentHostAttribute}][data-codex-helper-active="true"] > :not([${helperPageAttribute}]) { - display: none !important; - } - [${helperContentHostAttribute}][data-codex-helper-active="true"] { - min-height: 0 !important; - overflow: auto !important; - } [${helperNativeSettingsContentHostAttribute}][data-codex-helper-active="true"] > :not([${helperNativeSettingsPageAttribute}]) { display: none !important; } @@ -288,130 +280,6 @@ function installHelperStyles() { font-size: 15px; font-weight: 600; } - [${helperPageAttribute}] { - display: flex; - flex-direction: column; - border-top: 0.5px solid var(--color-token-border, rgba(26, 28, 31, 0.12)); - padding-top: var(--padding-panel, 20px); - color: inherit; - } - [${helperPageAttribute}] .codex-helper-panel { - background-color: var(--color-background-panel, var(--color-token-bg-fog)); - } - [${helperPageAttribute}] .codex-helper-action { - border-color: transparent; - } - [${helperPageAttribute}] a.codex-helper-external-link { - text-decoration: none; - } - [${helperPageAttribute}] .codex-helper-switch { - position: relative; - } - [${helperPageAttribute}] .codex-helper-switch input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; - } - [${helperPageAttribute}] .codex-helper-switch input:focus-visible + span { - outline: 2px solid var(--color-token-focus-border, rgb(48, 145, 255)); - outline-offset: 2px; - } - [${helperPageAttribute}] .codex-helper-switch input:checked + span { - background-color: var(--color-token-charts-blue, rgb(48, 145, 255)); - } - [${helperPageAttribute}] .codex-helper-switch input:checked + span > span { - transform: translateX(14px); - } - [${helperPageAttribute}] .codex-helper-number-input { - width: 64px; - border: 1px solid color-mix(in srgb, currentColor 16%, transparent); - border-radius: 6px; - background: transparent; - color: inherit; - padding: 3px 6px; - font: inherit; - font-size: 13px; - } - [${helperPageAttribute}] pre[data-codex-helper-log] { - margin: 0; - padding: 12px; - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - line-height: 1.45; - } - [${helperPageAttribute}] .codex-helper-settings-scroll { - max-height: 160px; - overflow: auto; - min-height: 0; - } - [${helperPageAttribute}] .codex-helper-settings-compact-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; - padding: 8px 12px; - border-top: 0.5px solid color-mix(in srgb, currentColor 10%, transparent); - } - [${helperPageAttribute}] .codex-helper-settings-compact-row:first-child { - border-top: 0; - } - [${helperPageAttribute}] .codex-helper-settings-compact-text { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - color: inherit; - } - [${helperPageAttribute}] .codex-helper-settings-compact-meta { - flex-shrink: 0; - font-size: 12px; - color: color-mix(in srgb, currentColor 55%, transparent); - } - [${helperPageAttribute}] .codex-helper-settings-scroll-empty { - padding: 12px; - font-size: 13px; - color: color-mix(in srgb, currentColor 55%, transparent); - } - [${helperPageAttribute}] .codex-helper-settings-section-title { - padding: 0; - } - [${helperPageAttribute}] .codex-helper-settings-section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 2px; - } - [${helperPageAttribute}] .codex-helper-settings-section-link { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: 0; - border-radius: 6px; - background: transparent; - color: color-mix(in srgb, currentColor 70%, transparent); - cursor: pointer; - } - [${helperPageAttribute}] .codex-helper-settings-section-link:hover { - background: color-mix(in srgb, currentColor 8%, transparent); - color: inherit; - } - [${helperPageAttribute}] .codex-helper-settings-section-link:focus-visible { - outline: 2px solid var(--color-token-focus-border, rgb(48, 145, 255)); - outline-offset: 2px; - } - [${helperPageAttribute}] .codex-helper-settings-section-link svg { - width: 14px; - height: 14px; - } [data-codex-helper-port-row][data-codex-helper-port-row-menu-open="true"] [class*="summary-panel-row-accessory"] { opacity: 1 !important; diff --git a/runtime/zed-open.js b/runtime/zed-open.js index 749311c..edf8231 100644 --- a/runtime/zed-open.js +++ b/runtime/zed-open.js @@ -3,7 +3,7 @@ const helperZedAttribute = "data-codex-helper-zed-menu-item"; const helperUiSelector = - "[data-codex-helper-settings-entry], [data-codex-helper-zed-menu-item], [data-codex-helper-settings-page]"; + "[data-codex-helper-native-settings-entry], [data-codex-helper-zed-menu-item], [data-codex-helper-native-settings-page]"; const sidebarThreadSelector = "[data-app-action-sidebar-thread-id]"; let observerInstalled = false; let zedRemoteContextCache = { scope: null, at: 0, value: null }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 3b60fcf..dba97dd 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -24,7 +24,7 @@ impl Default for HelperSettings { Self { markdown_export_enabled: false, session_move_enabled: false, - auto_rename_menu_enabled: false, + auto_rename_menu_enabled: true, markdown_friendly_filename_enabled: true, auto_naming_min_chars: 4, auto_naming_max_chars: 10, @@ -61,33 +61,11 @@ fn settings_from_value(value: &Value) -> anyhow::Result { .ok_or_else(|| anyhow::anyhow!("Settings file must contain a JSON object"))?; let mut settings = HelperSettings::default(); for (key, value) in object { - match key.as_str() { - "markdownExportEnabled" => settings.markdown_export_enabled = bool_setting(key, value)?, - "sessionMoveEnabled" => settings.session_move_enabled = bool_setting(key, value)?, - "autoRenameMenuEnabled" => { - settings.auto_rename_menu_enabled = bool_setting(key, value)? - } - "markdownFriendlyFilenameEnabled" => { - settings.markdown_friendly_filename_enabled = bool_setting(key, value)? - } - "autoNamingMinChars" => { - settings.auto_naming_min_chars = char_count_setting(key, value)? - } - "autoNamingMaxChars" => { - settings.auto_naming_max_chars = char_count_setting(key, value)? - } - "autoNamingMinWords" if !object.contains_key("autoNamingMinChars") => { - settings.auto_naming_min_chars = char_count_setting("autoNamingMinChars", value)? - } - "autoNamingMaxWords" if !object.contains_key("autoNamingMaxChars") => { - settings.auto_naming_max_chars = char_count_setting("autoNamingMaxChars", value)? - } - "autoNamingMinWords" | "autoNamingMaxWords" => {} - "portForwardingEnabled" => settings.port_forwarding_enabled = bool_setting(key, value)?, - "portAutoForwardWeb" => settings.port_auto_forward_web = bool_setting(key, value)?, - "portSameLocalPort" => settings.port_same_local_port = bool_setting(key, value)?, - key if LEGACY_SETTINGS_KEYS.contains(&key) => {} - _ => anyhow::bail!("Unknown settings key: {key}"), + if apply_setting_value(&mut settings, key, value, object)? { + continue; + } + if !LEGACY_SETTINGS_KEYS.contains(&key.as_str()) { + anyhow::bail!("Unknown settings key: {key}"); } } validate_auto_naming_range(&settings)?; @@ -101,32 +79,8 @@ pub fn update_settings(path: &Path, payload: &Value) -> anyhow::Result settings.markdown_export_enabled = bool_setting(key, value)?, - "sessionMoveEnabled" => settings.session_move_enabled = bool_setting(key, value)?, - "autoRenameMenuEnabled" => { - settings.auto_rename_menu_enabled = bool_setting(key, value)? - } - "markdownFriendlyFilenameEnabled" => { - settings.markdown_friendly_filename_enabled = bool_setting(key, value)? - } - "autoNamingMinChars" => { - settings.auto_naming_min_chars = char_count_setting(key, value)? - } - "autoNamingMaxChars" => { - settings.auto_naming_max_chars = char_count_setting(key, value)? - } - "autoNamingMinWords" if !object.contains_key("autoNamingMinChars") => { - settings.auto_naming_min_chars = char_count_setting("autoNamingMinChars", value)? - } - "autoNamingMaxWords" if !object.contains_key("autoNamingMaxChars") => { - settings.auto_naming_max_chars = char_count_setting("autoNamingMaxChars", value)? - } - "autoNamingMinWords" | "autoNamingMaxWords" => {} - "portForwardingEnabled" => settings.port_forwarding_enabled = bool_setting(key, value)?, - "portAutoForwardWeb" => settings.port_auto_forward_web = bool_setting(key, value)?, - "portSameLocalPort" => settings.port_same_local_port = bool_setting(key, value)?, - _ => return Err(anyhow::anyhow!("Unknown settings key: {key}")), + if !apply_setting_value(&mut settings, key, value, object)? { + return Err(anyhow::anyhow!("Unknown settings key: {key}")); } } validate_auto_naming_range(&settings)?; @@ -135,6 +89,55 @@ pub fn update_settings(path: &Path, payload: &Value) -> anyhow::Result, +) -> anyhow::Result { + match key { + "markdownExportEnabled" => settings.markdown_export_enabled = bool_setting(key, value)?, + "sessionMoveEnabled" => settings.session_move_enabled = bool_setting(key, value)?, + "autoRenameMenuEnabled" => settings.auto_rename_menu_enabled = bool_setting(key, value)?, + "markdownFriendlyFilenameEnabled" => { + settings.markdown_friendly_filename_enabled = bool_setting(key, value)? + } + "autoNamingMinChars" => settings.auto_naming_min_chars = char_count_setting(key, value)?, + "autoNamingMaxChars" => settings.auto_naming_max_chars = char_count_setting(key, value)?, + "autoNamingMinWords" | "autoNamingMaxWords" => { + apply_auto_naming_alias(settings, key, value, object)? + } + "portForwardingEnabled" => settings.port_forwarding_enabled = bool_setting(key, value)?, + "portAutoForwardWeb" => settings.port_auto_forward_web = bool_setting(key, value)?, + "portSameLocalPort" => settings.port_same_local_port = bool_setting(key, value)?, + _ => return Ok(false), + } + Ok(true) +} + +fn apply_auto_naming_alias( + settings: &mut HelperSettings, + key: &str, + value: &Value, + object: &serde_json::Map, +) -> anyhow::Result<()> { + let canonical_key = if key == "autoNamingMinWords" { + "autoNamingMinChars" + } else { + "autoNamingMaxChars" + }; + if object.contains_key(canonical_key) { + return Ok(()); + } + let count = char_count_setting(canonical_key, value)?; + if canonical_key == "autoNamingMinChars" { + settings.auto_naming_min_chars = count; + } else { + settings.auto_naming_max_chars = count; + } + Ok(()) +} + fn bool_setting(key: &str, value: &Value) -> anyhow::Result { value .as_bool() @@ -172,7 +175,7 @@ mod tests { use super::*; #[test] - fn default_settings_disable_session_tools() { + fn default_settings_enable_chat_title_regeneration() { let settings = HelperSettings::default(); assert!(!settings.markdown_export_enabled); @@ -180,7 +183,7 @@ mod tests { assert!(!settings.port_forwarding_enabled); assert!(settings.port_auto_forward_web); assert!(settings.port_same_local_port); - assert!(!settings.auto_rename_menu_enabled); + assert!(settings.auto_rename_menu_enabled); assert!(settings.markdown_friendly_filename_enabled); assert_eq!(settings.auto_naming_min_chars, 4); assert_eq!(settings.auto_naming_max_chars, 10); diff --git a/src/routes.test.ts b/src/routes.test.ts index 6060191..b60a0f4 100644 --- a/src/routes.test.ts +++ b/src/routes.test.ts @@ -136,6 +136,26 @@ test("dev bridge accepts known removed settings keys", async () => { } }); +test("dev bridge creates default settings with chat title regeneration enabled", async () => { + const previous = process.env.CODEX_HELPER_HOME; + const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); + try { + process.env.CODEX_HELPER_HOME = root; + + const result = await handleBridgeRequest("/settings/get", {}); + + expect(result).toMatchObject({ + status: "ok", + settings: { + autoRenameMenuEnabled: true, + }, + }); + } finally { + if (previous === undefined) delete process.env.CODEX_HELPER_HOME; + else process.env.CODEX_HELPER_HOME = previous; + } +}); + test("dev bridge accepts auto naming settings", async () => { const previous = process.env.CODEX_HELPER_HOME; const root = mkdtempSync(join(tmpdir(), "codex-helper-routes-")); diff --git a/src/routes.ts b/src/routes.ts index 379ed6b..ae34427 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -45,11 +45,13 @@ type HelperSettings = { portAutoForwardWeb: boolean; portSameLocalPort: boolean; }; +type HelperSettingKey = keyof HelperSettings; +type AutoNamingAliasKey = "autoNamingMinWords" | "autoNamingMaxWords"; const defaultSettings: HelperSettings = { markdownExportEnabled: false, sessionMoveEnabled: false, - autoRenameMenuEnabled: false, + autoRenameMenuEnabled: true, markdownFriendlyFilenameEnabled: true, autoNamingMinChars: 4, autoNamingMaxChars: 10, @@ -129,18 +131,7 @@ function readSettings(): HelperSettings { const next: HelperSettings = { ...defaultSettings }; const object = settings as Record; for (const [key, value] of Object.entries(object)) { - if (key in defaultSettings) { - setSettingValue(next, key, value); - continue; - } - const canonicalAutoNamingKey = canonicalAutoNamingSettingKey(key); - if (canonicalAutoNamingKey) { - if (Object.prototype.hasOwnProperty.call(object, canonicalAutoNamingKey)) { - continue; - } - setSettingValue(next, key, value); - continue; - } + if (applySettingsEntry(next, object, key, value)) continue; if (legacySettingsKeys.has(key)) continue; throw new Error(`Unknown settings key: ${key}`); } @@ -152,16 +143,9 @@ function updateSettings(payload: Record): HelperSettings { const current = readSettings(); const next: HelperSettings = { ...current }; for (const [key, value] of Object.entries(payload)) { - if (!(key in defaultSettings)) { - const canonicalAutoNamingKey = canonicalAutoNamingSettingKey(key); - if (!canonicalAutoNamingKey) { - throw new Error(`Unknown settings key: ${key}`); - } - if (Object.prototype.hasOwnProperty.call(payload, canonicalAutoNamingKey)) { - continue; - } + if (!applySettingsEntry(next, payload, key, value)) { + throw new Error(`Unknown settings key: ${key}`); } - setSettingValue(next, key, value); } validateAutoNamingRange(next); writeFileSync( @@ -172,15 +156,42 @@ function updateSettings(payload: Record): HelperSettings { return next; } -function canonicalAutoNamingSettingKey(key: string): string { +function applySettingsEntry( + settings: HelperSettings, + source: Record, + key: string, + value: JsonValue, +): boolean { + if (isHelperSettingKey(key)) { + setSettingValue(settings, key, value); + return true; + } + if (!isAutoNamingAliasKey(key)) return false; + const canonicalAutoNamingKey = canonicalAutoNamingSettingKey(key); + if (!Object.hasOwn(source, canonicalAutoNamingKey)) { + setSettingValue(settings, key, value); + } + return true; +} + +function isHelperSettingKey(key: string): key is HelperSettingKey { + return Object.hasOwn(defaultSettings, key); +} + +function isAutoNamingAliasKey(key: string): key is AutoNamingAliasKey { + return key === "autoNamingMinWords" || key === "autoNamingMaxWords"; +} + +function canonicalAutoNamingSettingKey( + key: AutoNamingAliasKey, +): HelperSettingKey { if (key === "autoNamingMinWords") return "autoNamingMinChars"; - if (key === "autoNamingMaxWords") return "autoNamingMaxChars"; - return ""; + return "autoNamingMaxChars"; } function setSettingValue( settings: HelperSettings, - key: string, + key: HelperSettingKey | AutoNamingAliasKey, value: JsonValue, ): void { if ( @@ -204,15 +215,29 @@ function setSettingValue( if (typeof value !== "boolean") { throw new Error(`Settings value for ${key} must be a boolean`); } - if (key === "markdownExportEnabled") settings.markdownExportEnabled = value; - else if (key === "sessionMoveEnabled") settings.sessionMoveEnabled = value; - else if (key === "autoRenameMenuEnabled") settings.autoRenameMenuEnabled = value; - else if (key === "markdownFriendlyFilenameEnabled") - settings.markdownFriendlyFilenameEnabled = value; - else if (key === "portForwardingEnabled") settings.portForwardingEnabled = value; - else if (key === "portAutoForwardWeb") settings.portAutoForwardWeb = value; - else if (key === "portSameLocalPort") settings.portSameLocalPort = value; - else throw new Error(`Unknown settings key: ${key}`); + switch (key) { + case "markdownExportEnabled": + settings.markdownExportEnabled = value; + return; + case "sessionMoveEnabled": + settings.sessionMoveEnabled = value; + return; + case "autoRenameMenuEnabled": + settings.autoRenameMenuEnabled = value; + return; + case "markdownFriendlyFilenameEnabled": + settings.markdownFriendlyFilenameEnabled = value; + return; + case "portForwardingEnabled": + settings.portForwardingEnabled = value; + return; + case "portAutoForwardWeb": + settings.portAutoForwardWeb = value; + return; + case "portSameLocalPort": + settings.portSameLocalPort = value; + return; + } } function validateAutoNamingRange(settings: HelperSettings): void { From ba47c368e291e1ebeea16577e61564c93ab584ea Mon Sep 17 00:00:00 2001 From: Loocor Date: Wed, 27 May 2026 14:00:32 +0800 Subject: [PATCH 2/2] fix(sessions): preserve native fork menu actions Append Helper session actions at the native menu model layer so Codex-owned actions such as Fork into local and Fork into worktree keep their original handlers. --- runtime/_test-settings.test.js | 26 ++- runtime/bootstrap.js | 54 +++-- runtime/constants.js | 2 + runtime/sessions.js | 364 +++++++++++++++------------------ 4 files changed, 219 insertions(+), 227 deletions(-) diff --git a/runtime/_test-settings.test.js b/runtime/_test-settings.test.js index 7719020..62208d0 100644 --- a/runtime/_test-settings.test.js +++ b/runtime/_test-settings.test.js @@ -515,9 +515,14 @@ test("startup does not eagerly mount inline General settings page", () => { expect(source).not.toContain("showHelperSettingsPage({ refresh: false })"); }); -test("session context menu extends Codex native electronBridge menu", () => { - expect(source).toContain("showExtendedSessionContextMenu"); - expect(source).toContain("buildCodexSessionNativeMenuItems"); +test("session context menu hooks Codex native menu model", () => { + expect(source).toContain("installSessionContextMenuBridge"); + expect(source).toContain("sessionContextMenuMapRestore"); + expect(source).toContain("Array.prototype.map"); + expect(source).toContain("appendHelperSessionMenuItems"); + expect(source).toContain("buildHelperSessionMenuModelItems"); + expect(source).toContain("prepareSessionContextMenu"); + expect(source).toContain("replaySessionContextMenu"); expect(source).toContain("openProjectForkMenu"); expect(source).toContain("navigateAfterFork(result, target)"); expect(source).toContain("Regenerate chat title"); @@ -527,19 +532,22 @@ test("session context menu extends Codex native electronBridge menu", () => { expect(source).toContain("window.location.assign(path)"); expect(source).toContain("nativeProjectTargets"); expect(source).toContain("helperSessionMenuIcon"); - expect(source).toContain("Fork into Another Project..."); + expect(source).toContain("Fork into another project..."); expect(source).not.toContain("Move Session"); - expect(source).toContain("window.electronBridge"); expect(source).toContain("open-thread-new-window"); expect(source).toContain("loadRemoteProjectMetadataOrEmpty"); expect(source).toContain('logDiagnostic("remote_project_metadata_unavailable"'); expect(source).toContain("codex-helper-session-"); - expect(source).toContain("stopImmediatePropagation"); - expect(source).not.toContain('id: "mark-thread-unread"'); - expect(source).not.toContain('id: "fork-into-local"'); - expect(source).not.toContain('id: "fork-into-worktree"'); + expect(source).toContain("trackSessionContextMenu(row)"); + expect(source).not.toContain("showExtendedSessionContextMenu"); + expect(source).not.toContain("buildCodexSessionNativeMenuItems"); + expect(source).not.toContain("forwardSessionMenuAction"); + expect(source).toContain('id === "mark-thread-unread"'); + expect(source).toContain('id === "fork-into-local"'); + expect(source).toContain('id === "fork-into-worktree"'); expect(source).not.toContain("installSessionContextMenuItems"); expect(source).not.toContain("installElectronContextMenuHook"); + expect(source).not.toContain("showContextMenuWithHelperItems"); expect(source).not.toContain("promptMoveTargetPath"); }); diff --git a/runtime/bootstrap.js b/runtime/bootstrap.js index 4a009e1..fa4a087 100644 --- a/runtime/bootstrap.js +++ b/runtime/bootstrap.js @@ -13,9 +13,6 @@ function installObserver() { const observer = new MutationObserver(() => { maintainPortsPanel(); installNativeHelperSettingsGroup(); - if (helperPageRoot && !helperPageRoot.isConnected) { - clearHelperSettingsPage(); - } if (helperNativeSettingsRoot && !helperNativeSettingsRoot.isConnected) { clearNativeHelperSettingsPage(); } @@ -103,6 +100,26 @@ function onHelperRuntimeClick(event) { }); } +function replaySessionContextMenu(event, target) { + target.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view: window, + clientX: event.clientX, + clientY: event.clientY, + screenX: event.screenX, + screenY: event.screenY, + button: event.button, + buttons: event.buttons, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + }), + ); +} + function onHelperRuntimeContextMenu(event) { const target = event.target; if (!(target instanceof Element)) return; @@ -115,18 +132,26 @@ function onHelperRuntimeContextMenu(event) { } const row = sessionRowFromTarget(target); if (!(row instanceof HTMLElement)) return; + if (!sessionContextMenuReady() && !sessionContextMenuReplayInFlight) { + event.preventDefault(); + event.stopImmediatePropagation(); + sessionContextMenuReplayInFlight = true; + prepareSessionContextMenu() + .catch((error) => { + logDiagnostic("session_menu_prepare_failed", { + error: error?.message || String(error), + }); + }) + .finally(() => { + try { + if (target.isConnected) replaySessionContextMenu(event, target); + } finally { + sessionContextMenuReplayInFlight = false; + } + }); + return; + } trackSessionContextMenu(row); - if (enabledSessionActions().length === 0) return; - event.preventDefault(); - event.stopImmediatePropagation(); - const ref = sessionRefFromRow(row); - if (!ref.session_id) return; - showExtendedSessionContextMenu(row, ref).catch((error) => { - showHelperToast(error?.message || String(error)); - logDiagnostic("session_menu_open_failed", { - error: error?.message || String(error), - }); - }); } function onHelperRuntimeKeydown(event) { @@ -202,6 +227,7 @@ window.__codexHelperRuntimeCleanup = () => { closePortForwardRowMenu(); closePortForwardDialog(); clearNativeHelperSettingsPage(); + if (sessionContextMenuMapRestore) sessionContextMenuMapRestore(); removeHelperRuntimeEventListeners(); pendingPortScan = 0; maintainPortsPanelTimer = 0; diff --git a/runtime/constants.js b/runtime/constants.js index cc81e01..e6311d5 100644 --- a/runtime/constants.js +++ b/runtime/constants.js @@ -63,3 +63,5 @@ let featureSettings = { portSameLocalPort: true, }; let featureSettingsLoaded = false; +let cachedRemoteProjectMetadata = []; +let cachedRemoteProjectMetadataLoaded = false; diff --git a/runtime/sessions.js b/runtime/sessions.js index 8370a08..77c32c7 100644 --- a/runtime/sessions.js +++ b/runtime/sessions.js @@ -51,10 +51,12 @@ function trackSessionContextMenu(row) { const ref = sessionRefFromRow(row); if (!ref.session_id) return; + const openedAt = Date.now(); + installSessionContextMenuBridge(); pendingSessionMenuContext = { row, ref, - openedAt: Date.now(), + openedAt, }; refreshFeatureSettings().catch((error) => { logDiagnostic("session_menu_settings_failed", { @@ -62,24 +64,18 @@ }); }); setTimeout(() => { - if (pendingSessionMenuContext?.ref?.session_id === ref.session_id) { - pendingSessionMenuContext = null; + if ( + pendingSessionMenuContext?.ref?.session_id === ref.session_id && + pendingSessionMenuContext?.openedAt === openedAt + ) { + clearPendingSessionMenuContext(); } - }, 2500); + }, SESSION_CONTEXT_MENU_MAX_AGE_MS); } - function sessionConversationPath(row, ref) { - const href = rowHref(row); - if (href) { - try { - const pathname = new URL(href, window.location.href).pathname; - if (pathname && pathname !== "/") return pathname; - } catch (_) { - /* ignore malformed href */ - } - } - const sessionId = ref.session_id.replace(/^local:/, ""); - return sessionId ? `/local/${sessionId}` : ""; + function clearPendingSessionMenuContext() { + pendingSessionMenuContext = null; + if (sessionContextMenuMapRestore) sessionContextMenuMapRestore(); } function codexAppServerHostId(hostId) { @@ -93,165 +89,155 @@ .replace(/^remote:/, ""); } - function sessionRowIsPinned(row) { - return row.getAttribute("data-app-action-sidebar-thread-pinned") === "true"; - } - - function buildCodexSessionNativeMenuItems(row, _ref) { - const items = []; - const pinned = sessionRowIsPinned(row); - items.push({ - id: pinned ? "unpin-thread" : "pin-thread", - label: pinned ? "Unpin chat" : "Pin chat", - enabled: true, - }); - items.push({ - id: "rename-thread", - label: "Rename chat", - enabled: true, - }); - items.push({ - id: "archive-thread", - label: "Archive chat", - enabled: true, - }); - items.push({ type: "separator" }); - items.push({ - id: "open-thread-folder", - label: "Open in Finder", - enabled: true, - }); - items.push({ - id: "copy-cwd", - label: "Copy working directory", - enabled: true, - }); - items.push({ - id: "copy-session-id", - label: "Copy session ID", - enabled: true, - }); - items.push({ - id: "copy-app-link", - label: "Copy deeplink", - enabled: true, - }); - items.push({ type: "separator" }); - items.push({ - id: "open-thread-new-window", - label: "Open in new window", - enabled: true, - }); + function buildHelperSessionMenuModelItems(actions, context, remoteProjects = []) { + if (actions.length === 0) return []; + const entries = sessionActionEntries(actions, context.row, remoteProjects); + if (entries.length === 0) return []; + const labels = sessionActionMenuLabels(); + const items = [{ type: "separator" }]; + for (const action of entries) { + const item = { + id: helperSessionActionId(action), + nativeLabel: labels[action] || action, + enabled: true, + onSelect: () => { + if (!context?.row?.isConnected || !context?.ref?.session_id) return; + handleSessionAction(action, context.row, context.ref).catch((error) => { + showHelperToast(error?.message || String(error)); + logDiagnostic("session_menu_action_failed", { + action, + session_id: context.ref.session_id, + error: error?.message || String(error), + }); + }); + }, + }; + const icon = helperSessionMenuIcon(action); + if (icon) item.icon = icon; + items.push(item); + } return items; } - async function copyTextToClipboard(value) { - if (!value) throw new Error("Nothing to copy"); - await navigator.clipboard.writeText(value); + function hasHelperSessionMenuItem(items) { + for (const item of items) { + if (item?.type !== "separator" && helperSessionActionFromId(item?.id)) { + return true; + } + } + return false; } - function clickRowAction(row, labels) { - const candidates = Array.from( - row.querySelectorAll("button, [role='button'], a"), - ).filter((node) => { - if (!(node instanceof HTMLElement)) return false; - const rect = node.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return false; - const label = textOf(node); - return labels.some((value) => label === value || label.includes(value)); - }); - return clickElement(candidates[0] instanceof HTMLElement ? candidates[0] : null); + function isCodexSessionMenuItemId(id) { + return ( + id === "toggle-thread-pin" || + id === "pin-thread" || + id === "unpin-thread" || + id === "rename-thread" || + id === "archive-thread" || + id === "mark-thread-unread" || + id === "copy-session-id" || + id === "copy-deeplink" || + id === "copy-app-link" || + id === "copy-conversation-path" || + id === "copy-working-directory" || + id === "copy-cwd" || + id === "copyConversationMarkdown" || + id === "openSideChat" || + id === "open-in-new-window" || + id === "open-thread-new-window" || + id === "open-thread-folder" || + id === "fork-into-local" || + id === "fork-into-same-worktree" || + id === "fork-into-worktree" + ); } - async function forwardSessionMenuAction(id, row, ref) { - const path = sessionConversationPath(row, ref); - if (id === "copy-session-id") { - await copyTextToClipboard(ref.session_id); - showHelperToast("Copied session ID"); - return; - } - if (id === "copy-app-link") { - const link = path - ? `${window.location.origin}${path}` - : ref.session_id; - await copyTextToClipboard(link); - showHelperToast("Copied deeplink"); - return; - } - if (id === "copy-cwd") { - await copyTextToClipboard(sessionProjectContext(row).path); - showHelperToast("Copied working directory"); - return; + function looksLikeCodexSessionMenuItems(items) { + let sessionActionCount = 0; + for (const item of items) { + if (isCodexSessionMenuItemId(item?.id)) sessionActionCount += 1; } - if (id === "rename-thread") { - const title = row.querySelector("[data-thread-title]"); - if (title instanceof HTMLElement) { - title.dispatchEvent( - new MouseEvent("dblclick", { bubbles: true, cancelable: true }), - ); - } - return; - } - if (id === "archive-thread") { + return sessionActionCount >= 2; + } + + function hasNativeSessionMenuLabels(items) { + let nativeLabelCount = 0; + for (const item of items) { if ( - clickRowAction(row, ["Archive", "Archive chat"]) || - clickRowAction(row, ["Delete", "Remove"]) + isCodexSessionMenuItemId(item?.id) && + typeof item?.nativeLabel === "string" ) { - return; + nativeLabelCount += 1; } - throw new Error("Archive control not found for this session"); } - if (id === "pin-thread" || id === "unpin-thread") { - if (clickRowAction(row, ["Pin", "Unpin"])) return; - throw new Error("Pin control not found for this session"); - } - if (id === "open-thread-new-window" && path) { - const bridge = window.electronBridge; - if (typeof bridge?.sendMessageFromView !== "function") { - throw new Error("Open in new window is unavailable"); - } - await bridge.sendMessageFromView({ type: "open-in-new-window", path }); - return; - } - if (id === "open-thread-folder" && path) { - const bridge = window.electronBridge; - if (typeof bridge?.sendMessageFromView !== "function") { - throw new Error("Open in Finder is unavailable"); - } - await bridge.sendMessageFromView({ - type: "open-in-main-window", - path, - }); - return; - } - logDiagnostic("session_menu_forward_unhandled", { id, session_id: ref.session_id }); - showHelperToast(`Action "${id}" is not available from Helper yet`); + return nativeLabelCount >= 2; } - async function showExtendedSessionContextMenu(row, ref) { - await refreshFeatureSettings(); - const actions = enabledSessionActions(); - const bridge = window.electronBridge; - if (actions.length === 0 || typeof bridge?.showContextMenu !== "function") { + function appendHelperSessionMenuItems(items) { + const context = pendingSessionMenuContext; + if ( + !Array.isArray(items) || + !context?.row?.isConnected || + !context?.ref?.session_id || + Date.now() - context.openedAt >= SESSION_CONTEXT_MENU_MAX_AGE_MS + ) { + clearPendingSessionMenuContext(); return; } - const remoteProjects = actions.includes("fork") - ? await loadRemoteProjectMetadataOrEmpty() - : []; - const items = [ - ...buildCodexSessionNativeMenuItems(row, ref), - ...buildHelperSessionNativeMenuItems(actions, row, remoteProjects), - ]; - const result = await bridge.showContextMenu(items); - pendingSessionMenuContext = null; - const helperAction = helperSessionActionFromId(result?.id); - if (helperAction) { - await handleSessionAction(helperAction, row, ref); + const hasHelperItem = hasHelperSessionMenuItem(items); + if (hasHelperItem) { + clearPendingSessionMenuContext(); return; } - if (result?.id) { - await forwardSessionMenuAction(result.id, row, ref); + if ( + !looksLikeCodexSessionMenuItems(items) || + !hasNativeSessionMenuLabels(items) + ) { + return; } + const actions = enabledSessionActions(); + const remoteProjects = cachedRemoteProjectMetadataLoaded + ? cachedRemoteProjectMetadata + : []; + const helperItems = buildHelperSessionMenuModelItems( + actions, + context, + remoteProjects, + ); + if (helperItems.length > 0) items.push(...helperItems); + clearPendingSessionMenuContext(); + } + + function installSessionContextMenuBridge() { + if (sessionContextMenuMapRestore) return; + const originalArrayMap = Array.prototype.map; + const patchedArrayMap = function patchedArrayMap(callback, thisArg) { + try { + appendHelperSessionMenuItems(this); + } catch (error) { + clearPendingSessionMenuContext(); + logDiagnostic("session_menu_patch_failed", { + error: error?.message || String(error), + }); + } + return originalArrayMap.call(this, callback, thisArg); + }; + Object.defineProperty(Array.prototype, "map", { + value: patchedArrayMap, + writable: true, + configurable: true, + }); + sessionContextMenuMapRestore = () => { + if (Array.prototype.map === patchedArrayMap) { + Object.defineProperty(Array.prototype, "map", { + value: originalArrayMap, + writable: true, + configurable: true, + }); + } + sessionContextMenuMapRestore = null; + }; } function displayProjectName(path) { @@ -295,15 +281,29 @@ async function loadRemoteProjectMetadataOrEmpty() { try { - return await loadRemoteProjectMetadata(); + const projects = await loadRemoteProjectMetadata(); + cachedRemoteProjectMetadata = projects; + cachedRemoteProjectMetadataLoaded = true; + return projects; } catch (error) { logDiagnostic("remote_project_metadata_unavailable", { error: error?.message || String(error), }); + cachedRemoteProjectMetadata = []; + cachedRemoteProjectMetadataLoaded = true; return []; } } + function sessionContextMenuReady() { + return featureSettingsLoaded && cachedRemoteProjectMetadataLoaded; + } + + async function prepareSessionContextMenu() { + await refreshFeatureSettings(); + await loadRemoteProjectMetadataOrEmpty(); + } + function remoteProjectMetadataById(remoteProjects) { const map = new Map(); for (const project of remoteProjects || []) { @@ -566,9 +566,9 @@ return { autoRename: "Regenerate chat title", export: "Export Markdown", - forkRemoteProject: "Fork into Remote Project...", - forkLocalProject: "Fork into Local Project...", - forkAnotherProject: "Fork into Another Project...", + forkRemoteProject: "Fork into remote project...", + forkLocalProject: "Fork into local project...", + forkAnotherProject: "Fork into another project...", }; } @@ -600,25 +600,6 @@ return id.slice(helperSessionActionPrefix.length) || null; } - function buildHelperSessionNativeMenuItems(actions, row, remoteProjects = []) { - if (actions.length === 0) return []; - const entries = sessionActionEntries(actions, row, remoteProjects); - if (entries.length === 0) return []; - const labels = sessionActionMenuLabels(); - const items = [{ type: "separator" }]; - for (const action of entries) { - const item = { - id: helperSessionActionId(action), - label: labels[action] || action, - enabled: true, - }; - const icon = helperSessionMenuIcon(action); - if (icon) item.icon = icon; - items.push(item); - } - return items; - } - function setSessionMenuItemLabel(item, label) { const textNodes = []; const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT); @@ -637,31 +618,6 @@ item.appendChild(labelNode); } - function rowHref(row) { - const href = - row.getAttribute("href") || - row.querySelector("a[href]")?.getAttribute("href") || - ""; - if (!href) return ""; - try { - return new URL(href, window.location.href).href; - } catch (_) { - return href; - } - } - - function clickElement(element) { - if (!(element instanceof HTMLElement)) return false; - element.dispatchEvent( - new MouseEvent("pointerdown", { bubbles: true, cancelable: true }), - ); - element.dispatchEvent( - new MouseEvent("mousedown", { bubbles: true, cancelable: true }), - ); - element.click(); - return true; - } - function forkedSessionPath(result, target) { if (target?.hostId) return ""; const sessionId = String(result?.new_session_id || result?.newSessionId || "")