diff --git a/.changeset/restore-session-settings.md b/.changeset/restore-session-settings.md new file mode 100644 index 000000000..757a1fdee --- /dev/null +++ b/.changeset/restore-session-settings.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A session's tab colour, pin and image watcher now survive a Codeman restart or crash. Recovery rebuilt each surviving pane with its name, auto-clear, auto-compact and auto-resume settings but not those three, so they reset to default and the next save overwrote the stored values. diff --git a/src/web/public/gesture/gesture-codeman.js b/src/web/public/gesture/gesture-codeman.js index 9c048eff9..aaf636206 100644 --- a/src/web/public/gesture/gesture-codeman.js +++ b/src/web/public/gesture/gesture-codeman.js @@ -2,7 +2,7 @@ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); -// node_modules/@mediapipe/tasks-vision/vision_bundle.mjs +// ../wt-cli/node_modules/@mediapipe/tasks-vision/vision_bundle.mjs var t = "undefined" != typeof self ? self : {}; function e() { throw Error("Invalid UTF8"); diff --git a/src/web/server.ts b/src/web/server.ts index bb159e16f..4498b578d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3682,6 +3682,17 @@ export class WebServer extends EventEmitter { if (savedState.flickerFilterEnabled !== undefined) { session.flickerFilterEnabled = savedState.flickerFilterEnabled; } + // Tab colour, pin and the image watcher are user-set and persisted like the settings + // above, but this path never read them back: after a restart every surviving pane came + // back uncoloured and unpinned with its image watcher off, and the next persist + // overwrote the saved values. `reapplyPersistedSessionState()` (the reboot-restore + // path) already restores all three. The watcher flag has to be set before + // `setupSessionListeners()` below, which reads it to decide whether to start one. + if (savedState.color) session.setColor(savedState.color); + if (savedState.pinned) session.restorePin(true, savedState.pinnedAt); + if (savedState.imageWatcherEnabled !== undefined) { + session.imageWatcherEnabled = savedState.imageWatcherEnabled; + } // Respawn controller (not supported for external-CLI sessions) if (!isExternalCliMode(session.mode) && savedState.respawnEnabled && savedState.respawnConfig) { try { diff --git a/test/session-settings-recovery.test.ts b/test/session-settings-recovery.test.ts new file mode 100644 index 000000000..881c64fa7 --- /dev/null +++ b/test/session-settings-recovery.test.ts @@ -0,0 +1,86 @@ +/** + * @fileoverview The settings a user puts on a session survive a Codeman restart. + * + * `restoreMuxSessions()` (server.ts) rebuilds a Session around a surviving tmux pane from + * `state.json`, and `reapplyPersistedSessionState()` does the same for a reboot restore. A + * setting one of them forgets is silently reset on the next persist, which writes `toState()` + * wholesale. Found live: after a restart a session came back with its name, auto-clear and + * auto-compact but with its tab colour, pin and image watcher gone. + * + * `restoreMuxSessions()` cannot be reached under vitest (see test/session-model-recovery.test.ts), + * so the wiring is pinned by a source check and the setters it relies on are driven for real. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { Session } from '../src/session.js'; + +const SRC = fileURLToPath(new URL('../src', import.meta.url)); +const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8'); + +function functionBody(name: string): string { + const start = server.indexOf(`async ${name}(`); + expect(start, `${name} not found`).toBeGreaterThan(-1); + const next = server.indexOf('\n /**', start); + return server.slice(start, next === -1 ? undefined : next); +} + +describe('restoreMuxSessions() hands back every persisted user setting', () => { + const body = functionBody('restoreMuxSessions'); + + it.each([ + ['name', /name:\s*sessionName/], + ['name source', /nameSource:\s*savedState\?\.nameSource/], + ['colour', /session\.setColor\(savedState\.color\)/], + ['pin', /session\.restorePin\(true,\s*savedState\.pinnedAt\)/], + ['image watcher', /session\.imageWatcherEnabled\s*=\s*savedState\.imageWatcherEnabled/], + ['flicker filter', /session\.flickerFilterEnabled\s*=\s*savedState\.flickerFilterEnabled/], + ['auto-clear', /session\.setAutoClear\(/], + ['auto-compact', /session\.setAutoCompact\(/], + ['auto-resume', /session\.restoreAutoResume\(/], + ['nice priority', /session\.setNice\(/], + ])('%s', (_label, pattern) => { + expect(body).toMatch(pattern); + }); + + it('sets the image-watcher flag before the listeners read it', () => { + expect(body.indexOf('session.imageWatcherEnabled = savedState.imageWatcherEnabled')).toBeLessThan( + body.indexOf('await this.setupSessionListeners(session)') + ); + }); + + it('restores everything the reboot-restore path restores', () => { + const reapply = functionBody('reapplyPersistedSessionState'); + // Each persisted setting that path re-applies must also be re-applied here. + for (const call of ['setColor(', 'restorePin(', 'imageWatcherEnabled =', 'flickerFilterEnabled =']) { + expect(reapply, `reapply lacks ${call}`).toContain(call); + expect(body, `restoreMuxSessions lacks ${call}`).toContain(call); + } + }); +}); + +describe('the setters round-trip through toState()', () => { + it('keeps colour, pin, image watcher and name source', () => { + const original = new Session({ workingDir: '/tmp', mode: 'shell', name: 'custom name', nameSource: 'manual' }); + original.setColor('red'); + original.setPinned(true); + original.imageWatcherEnabled = true; + const state = original.toState(); + + const rebuilt = new Session({ workingDir: '/tmp', mode: 'shell', name: state.name, nameSource: state.nameSource }); + rebuilt.setColor(state.color!); + rebuilt.restorePin(true, state.pinnedAt); + rebuilt.imageWatcherEnabled = state.imageWatcherEnabled!; + const after = rebuilt.toState(); + + expect(after).toMatchObject({ + name: 'custom name', + nameSource: 'manual', + color: 'red', + pinned: true, + pinnedAt: state.pinnedAt, + imageWatcherEnabled: true, + }); + }); +});