diff --git a/src/web/public/index.html b/src/web/public/index.html index 5e9741c3..7f87cc52 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -114,7 +114,7 @@ - + 0 @@ -1016,6 +1016,13 @@ + + Attachments Button + + + + + Multi-monitor Button diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9ace1aaf..b12c16e1 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -435,11 +435,10 @@ html.mobile-init .file-browser-panel { } /* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar. - Also hide the attachment-history button: the header is kept minimal on phones and - the drawer is a desktop-oriented panel. */ + (The attachments button is opt-in / default-hidden everywhere via its own + --hidden marker, so it needs no mobile-specific rule here.) */ .btn-icon-header.btn-settings, - .btn-icon-header.btn-lifecycle-log, - .btn-icon-header.btn-attachments-history { + .btn-icon-header.btn-lifecycle-log { display: none !important; } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6b92055a..a4ceb53f 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -307,6 +307,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; + document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue'; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; @@ -1362,6 +1363,7 @@ Object.assign(CodemanApp.prototype, { showSystemStats: document.getElementById('appSettingsShowSystemStats').checked, showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked, showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked, + showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked, showMonitor: document.getElementById('appSettingsShowMonitor').checked, showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked, showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked, @@ -1528,6 +1530,7 @@ Object.assign(CodemanApp.prototype, { extendedKeyboardBar: _ekb, skin: _skin, showPlanUsageLimits: _pul, + showAttachmentsButton: _ahb, ...serverSettings } = settings; try { @@ -1681,6 +1684,7 @@ Object.assign(CodemanApp.prototype, { showSubagents: false, showMultiMonitorButton: false, showPlanUsageLimits: false, + showAttachmentsButton: false, // Input gestureControlEnabled: false, // Feature toggles - keep tracking on even on mobile @@ -1783,6 +1787,14 @@ Object.assign(CodemanApp.prototype, { responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer); } + // Hide the attachments (history) button when disabled. Opt-in, default OFF — + // marker class, base is display:inline-flex !important. + const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; + const attachmentsBtn = document.getElementById('attachmentsHistoryBtn'); + if (attachmentsBtn) { + attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton); + } + // Multi-monitor button — hidden by default (App Settings → Display → "Header // Displays"). The server renders the correct initial state on every reload; // this handles a live toggle from a settings save (no reload). Toggle the @@ -2018,7 +2030,7 @@ Object.assign(CodemanApp.prototype, { 'showLifecycleLog', 'showResponseViewer', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', - 'skin', 'showPlanUsageLimits', + 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', ]); // The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop // can show it while mobile stays hidden. It used to sync, so an older diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 0e5a51df..1e469bc1 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -8328,6 +8328,16 @@ kbd { display: none !important; } +/* "Attachments" header button — opt-in (App Settings → Display), hidden by + default. Same pattern as the response viewer: a base inline-flex !important so + an inline style can't override it, and a more-specific marker rule to hide. */ +.btn-attachments-history { + display: inline-flex !important; +} +.btn-attachments-history.btn-attachments-history--hidden { + display: none !important; +} + /* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by default via the marker class below; the server strips it at render when the diff --git a/test/mobile-header-buttons-policy.test.ts b/test/mobile-header-buttons-policy.test.ts index 133014a2..e060e456 100644 --- a/test/mobile-header-buttons-policy.test.ts +++ b/test/mobile-header-buttons-policy.test.ts @@ -35,8 +35,10 @@ const PHONE_WIDTH = 393; const MOBILE_VISIBLE_ALLOWLIST = new Set([]); // Buttons we expect to STAY hidden on phones — an explicit lock so a future edit -// that removes a hide rule fails loudly (not silently). -const KNOWN_PHONE_HIDDEN = ['btn-attachments-history', 'btn-settings', 'btn-lifecycle-log']; +// that removes a hide rule fails loudly (not silently). The attachments button is +// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so +// it's excluded from the default-visible enumeration rather than mobile-hidden. +const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log']; function attrOf(openTag: string, name: string): string { const m = openTag.match(new RegExp(`${name}="([^"]*)"`)); diff --git a/test/mobile/header-buttons.test.ts b/test/mobile/header-buttons.test.ts index 753c25a7..c84ba8b5 100644 --- a/test/mobile/header-buttons.test.ts +++ b/test/mobile/header-buttons.test.ts @@ -1,10 +1,12 @@ -// Port 3208 - Mobile header button visibility (real-browser E2E). +// Port 3208 - Header button visibility (real-browser E2E). // // Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts). -// This one renders the real app in an emulated phone vs. a desktop-class tablet -// and asserts the ACTUAL computed visibility — catching CSS/layout regressions -// the static parser can't see. Regression history: the COD-39 attachments button -// (and earlier the plan-usage chip) shipped visible on the cramped phone header. +// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout +// regressions the static parser can't see. Two behaviours are covered: +// 1. The minimal phone header hides the settings gear + lifecycle log. +// 2. The attachments button (COD-39) is OPT-IN — default-hidden everywhere, shown +// only when App Settings → Display → "Attachments Button" is enabled. It first +// shipped always-visible (mobile, then desktop), which is the regression here. import { describe, it, beforeAll, afterAll } from 'vitest'; import type { WebServer } from '../../src/web/server.js'; import { createTestServer, stopTestServer } from './helpers/server.js'; @@ -16,15 +18,7 @@ import { PORTS, WAIT } from './helpers/constants.js'; const PORT = PORTS.HEADER_BUTTONS; const BASE_URL = `http://localhost:${PORT}`; -// Secondary header buttons that must NOT clutter the minimal phone header -// (settings/case controls live in the mobile toolbar instead). -const PHONE_HIDDEN = [ - '#attachmentsHistoryBtn', // COD-39 — the reported regression - '.btn-icon-header.btn-settings', - '.btn-icon-header.btn-lifecycle-log', -]; - -describe('Mobile header button visibility (E2E)', () => { +describe('Header button visibility (E2E)', () => { let server: WebServer; beforeAll(async () => { @@ -36,19 +30,29 @@ describe('Mobile header button visibility (E2E)', () => { await stopTestServer(server); }); - it('hides secondary header buttons on a standard phone (iPhone 14 Pro, 393px)', async () => { + it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => { const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL); await page.waitForTimeout(WAIT.PAGE_SETTLE); - for (const sel of PHONE_HIDDEN) { - await assertHidden(page, sel); - } + await assertHidden(page, '.btn-icon-header.btn-settings'); + await assertHidden(page, '.btn-icon-header.btn-lifecycle-log'); }); - it('keeps the attachments button visible on a desktop-class tablet', async () => { - // assertHidden also passes when an element is ABSENT, so prove the selector is - // real by asserting the same button IS visible where the phone rule doesn't apply. + it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => { const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL); await page.waitForTimeout(WAIT.PAGE_SETTLE); + await assertHidden(page, '#attachmentsHistoryBtn'); + }); + + it('shows the attachments button once the setting is enabled', async () => { + const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL); + // Desktop-class devices use the non-mobile settings blob. + await page.evaluate(() => { + const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}'); + cur.showAttachmentsButton = true; + localStorage.setItem('codeman-app-settings', JSON.stringify(cur)); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(WAIT.PAGE_SETTLE); await assertVisible(page, '#attachmentsHistoryBtn'); }); });