From 661c89cefd4e3b8735db1a4e826d5567dd26f9c2 Mon Sep 17 00:00:00 2001 From: "Claude (Codeman maintainer)" Date: Sun, 14 Jun 2026 20:15:39 +0200 Subject: [PATCH] fix(plan-usage): make the usage chip per-device, not synced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-usage header chip (5h/7d %) was a SYNCED setting, so enabling it on desktop turned it on for mobile too — even though the user never enabled it there. Make the chip's DISPLAY purely per-device (default OFF) like the response viewer / skin, while keeping telemetry COLLECTION server-side. Three leak sources fixed: - server.ts renderIndexHtml force-revealed the chip from the synced value (pre-paint), pushing the desktop choice onto every device. Removed — the chip now ships hidden and the client reveals it per-device via applyHeaderVisibilitySettings. - settings-ui.js load-merge let the server value win, writing desktop's `true` into the (separate) mobile settings blob. showPlanUsageLimits is now a displayKey AND is dropped from the server payload on load, so a stale server value is never seeded into a device that didn't enable it. It's also stripped from the save payload so a mobile "off" can't clobber the server. - Collection was gated on the same synced flag. Decoupled via a new `statusLineTelemetry` ACTION field (schema + system-routes): sent on ENABLE only and never persisted, so the exporter is injected when a device turns the chip on but is never yanked when another device has it off (it's shared across sibling sessions). Session-create already reads the per-device blob, so that path was already correct. One-time migration clears a stale synced `true` from the mobile blob so existing mobile installs default to OFF without a manual toggle. Verified end-to-end on an isolated server: with showPlanUsageLimits=true persisted, the rendered HTML ships the chip hidden; a fresh browser context (mobile case) keeps it hidden while a context that explicitly enabled it shows it; the PUT accepts statusLineTelemetry and does not persist it. tsc + frontend-syntax + system-routes/index tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/public/settings-ui.js | 46 ++++++++++++++++++++++++++++++--- src/web/routes/system-routes.ts | 24 ++++++++++------- src/web/schemas.ts | 6 +++++ src/web/server.ts | 13 ++++------ 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8800ddaf..6b92055a 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1515,12 +1515,25 @@ Object.assign(CodemanApp.prototype, { // Apply keyboard bar mode KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple'); - // Save to server (includes notification prefs for cross-browser persistence) - // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform - const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, ...serverSettings } = settings; + // Save to server (includes notification prefs for cross-browser persistence). + // Strip device-specific DISPLAY keys so they never sync across devices — + // localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits + // is per-device too (desktop can show the usage chip while mobile stays hidden). + // Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on + // ENABLE only, so a device with the chip OFF never strips the exporter that + // another device's chip depends on — see system-routes settings handler). + const { + localEchoEnabled: _leo, + cjkInputEnabled: _cjk, + extendedKeyboardBar: _ekb, + skin: _skin, + showPlanUsageLimits: _pul, + ...serverSettings + } = settings; try { const res = await this._apiPut('/api/settings', { ...serverSettings, + ...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}), notificationPreferences: notifPrefsToSave, voiceSettings, }); @@ -1972,6 +1985,25 @@ Object.assign(CodemanApp.prototype, { }, async loadAppSettingsFromServer(settingsPromise = null) { + // One-time migration: showPlanUsageLimits became a per-device display setting. + // Before this, it synced from the server, so the (separate) mobile settings blob + // may carry a stale `true` the user never enabled on this device. Clear it once + // so mobile defaults to OFF; the desktop blob is untouched and keeps its value. + try { + if ( + MobileDetection.getDeviceType() === 'mobile' && + !localStorage.getItem('codeman:planUsagePerDeviceMigrated') + ) { + const s = this.loadAppSettingsFromStorage(); + if (s && s.showPlanUsageLimits) { + s.showPlanUsageLimits = false; + this.saveAppSettingsToStorage(s); + } + localStorage.setItem('codeman:planUsagePerDeviceMigrated', '1'); + } + } catch { + /* best-effort migration */ + } try { const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env); if (settings) { @@ -1986,8 +2018,14 @@ Object.assign(CodemanApp.prototype, { 'showLifecycleLog', 'showResponseViewer', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', - 'skin', + 'skin', 'showPlanUsageLimits', ]); + // 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 + // server.json may still carry `true` — drop it so the server value is NEVER + // seeded into a device that didn't explicitly enable it (collection is handled + // separately via the statusLineTelemetry action, not this display flag). + delete appSettings.showPlanUsageLimits; // Merge settings: non-display keys always sync from server, // display keys only seed from server when localStorage has no value // (prevents cross-device overwrite while fixing settings re-enabling on fresh loads) diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 033d2063..d5bce56c 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -531,7 +531,10 @@ export function registerSystemRoutes( } catch { /* ignore */ } - const merged = { ...existing, ...settings }; + // statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter), + // not a stored setting — strip it before persisting so settings.json stays clean. + const { statusLineTelemetry, ...settingsToStore } = settings; + const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); // Handle subagent tracking toggle dynamically @@ -547,19 +550,20 @@ export function registerSystemRoutes( } }); - // Plan-usage chip (App Settings → Display → "Plan Usage Limits"): reconcile - // the statusLine exporter across all ACTIVE Claude sessions' working dirs the - // moment the setting is toggled — server-side and authoritative, so it works - // immediately for every user/session without depending on any client's synced - // state or on creating a new session. Enable injects; disable removes (ours - // only, marker-guarded). Each dir handled once. - if ('showPlanUsageLimits' in settings) { - const enabled = settings.showPlanUsageLimits === true; + // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js). + // Telemetry COLLECTION is server-side and enable-sticky — when a client turns + // the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter + // into every ACTIVE Claude session's working dir so the live % starts flowing + // immediately (no new session needed). We deliberately never auto-REMOVE here: + // the exporter is benign/print-through and a per-repo settings.local.json is + // shared by sibling sessions, so one device's "off" must not yank the exporter + // another device's chip depends on. Each dir handled once. + if (statusLineTelemetry === true) { const dirs = new Set(); for (const session of ctx.sessions.values()) { if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir); } - await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, enabled).catch(() => {}))); + await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {}))); } // Handle tunnel toggle dynamically diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 1c54ed96..6d5c848c 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -369,6 +369,12 @@ export const SettingsUpdateSchema = z showSubagents: z.boolean().optional(), showMultiMonitorButton: z.boolean().optional(), showPlanUsageLimits: z.boolean().optional(), + // Action field (NOT persisted as a setting): when true, (re)injects the + // plan-usage statusLine exporter into active Claude sessions so live usage % + // starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device + // (client-side), but telemetry COLLECTION is server-side, so the per-device + // toggle signals it out-of-band here rather than via showPlanUsageLimits. + statusLineTelemetry: z.boolean().optional(), // Input gestureControlEnabled: z.boolean().optional(), // Claude CLI settings diff --git a/src/web/server.ts b/src/web/server.ts index fa32a1df..cb07d9e4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1147,14 +1147,11 @@ export class WebServer extends EventEmitter { if (settings.showMultiMonitorButton === true) { html = html.replace(' btn-multimonitor--hidden', ''); } - // Plan-usage chip: same pattern. Ships with `header-plan-usage--hidden` (App - // Settings → Display → "Plan Usage Limits", default off); strip it server-side - // when enabled so the chip doesn't flash hidden on every reload before the - // client's applyHeaderVisibilitySettings runs. showPlanUsageLimits is a synced - // (non-display) setting, so readSettings sees the persisted value here. - if (settings.showPlanUsageLimits === true) { - html = html.replace(' header-plan-usage--hidden', ''); - } + // Plan-usage chip: ships hidden (`header-plan-usage--hidden`) and is revealed + // PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It + // used to be server-revealed from a synced setting, but that leaked the desktop + // choice onto mobile — display is now per-device only (like the response viewer). + // Telemetry collection stays server-side via the statusLineTelemetry action. // Detached single-session ("solo") window: inject the target session id so // the client can enter solo mode even if a (network-first) service worker // later serves a cached shell. The client primarily detects solo mode from