fix(plan-usage): make the usage chip per-device, not synced

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) <noreply@anthropic.com>
This commit is contained in:
Claude (Codeman maintainer)
2026-06-14 20:15:39 +02:00
parent a122e867ef
commit 661c89cefd
4 changed files with 67 additions and 22 deletions
+42 -4
View File
@@ -1515,12 +1515,25 @@ Object.assign(CodemanApp.prototype, {
// Apply keyboard bar mode // Apply keyboard bar mode
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple'); KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
// Save to server (includes notification prefs for cross-browser persistence) // Save to server (includes notification prefs for cross-browser persistence).
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform // Strip device-specific DISPLAY keys so they never sync across devices —
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, ...serverSettings } = settings; // 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 { try {
const res = await this._apiPut('/api/settings', { const res = await this._apiPut('/api/settings', {
...serverSettings, ...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave, notificationPreferences: notifPrefsToSave,
voiceSettings, voiceSettings,
}); });
@@ -1972,6 +1985,25 @@ Object.assign(CodemanApp.prototype, {
}, },
async loadAppSettingsFromServer(settingsPromise = null) { 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 { 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); 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) { if (settings) {
@@ -1986,8 +2018,14 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer', 'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', '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, // Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value // display keys only seed from server when localStorage has no value
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads) // (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
+14 -10
View File
@@ -531,7 +531,10 @@ export function registerSystemRoutes(
} catch { } catch {
/* ignore */ /* 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)); await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
// Handle subagent tracking toggle dynamically // Handle subagent tracking toggle dynamically
@@ -547,19 +550,20 @@ export function registerSystemRoutes(
} }
}); });
// Plan-usage chip (App Settings → Display → "Plan Usage Limits"): reconcile // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
// the statusLine exporter across all ACTIVE Claude sessions' working dirs the // Telemetry COLLECTION is server-side and enable-sticky — when a client turns
// moment the setting is toggled — server-side and authoritative, so it works // the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
// immediately for every user/session without depending on any client's synced // into every ACTIVE Claude session's working dir so the live % starts flowing
// state or on creating a new session. Enable injects; disable removes (ours // immediately (no new session needed). We deliberately never auto-REMOVE here:
// only, marker-guarded). Each dir handled once. // the exporter is benign/print-through and a per-repo settings.local.json is
if ('showPlanUsageLimits' in settings) { // shared by sibling sessions, so one device's "off" must not yank the exporter
const enabled = settings.showPlanUsageLimits === true; // another device's chip depends on. Each dir handled once.
if (statusLineTelemetry === true) {
const dirs = new Set<string>(); const dirs = new Set<string>();
for (const session of ctx.sessions.values()) { for (const session of ctx.sessions.values()) {
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir); 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 // Handle tunnel toggle dynamically
+6
View File
@@ -369,6 +369,12 @@ export const SettingsUpdateSchema = z
showSubagents: z.boolean().optional(), showSubagents: z.boolean().optional(),
showMultiMonitorButton: z.boolean().optional(), showMultiMonitorButton: z.boolean().optional(),
showPlanUsageLimits: 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 // Input
gestureControlEnabled: z.boolean().optional(), gestureControlEnabled: z.boolean().optional(),
// Claude CLI settings // Claude CLI settings
+5 -8
View File
@@ -1147,14 +1147,11 @@ export class WebServer extends EventEmitter {
if (settings.showMultiMonitorButton === true) { if (settings.showMultiMonitorButton === true) {
html = html.replace(' btn-multimonitor--hidden', ''); html = html.replace(' btn-multimonitor--hidden', '');
} }
// Plan-usage chip: same pattern. Ships with `header-plan-usage--hidden` (App // Plan-usage chip: ships hidden (`header-plan-usage--hidden`) and is revealed
// Settings → Display → "Plan Usage Limits", default off); strip it server-side // PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It
// when enabled so the chip doesn't flash hidden on every reload before the // used to be server-revealed from a synced setting, but that leaked the desktop
// client's applyHeaderVisibilitySettings runs. showPlanUsageLimits is a synced // choice onto mobile — display is now per-device only (like the response viewer).
// (non-display) setting, so readSettings sees the persisted value here. // Telemetry collection stays server-side via the statusLineTelemetry action.
if (settings.showPlanUsageLimits === true) {
html = html.replace(' header-plan-usage--hidden', '');
}
// Detached single-session ("solo") window: inject the target session id so // Detached single-session ("solo") window: inject the target session id so
// the client can enter solo mode even if a (network-first) service worker // 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 // later serves a cached shell. The client primarily detects solo mode from