# Console Canonical Single Source Completion Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Complete the browser console migration so `ConsoleAppState` is the only user-visible source of session, preview, draft, annotation, and handoff state.

**Architecture:** Browser DOM handlers dispatch commands into the canonical store, reducers return fenced effects, adapters execute those effects with explicit session context, and renderers consume selector view models. Browser-internal legacy state ownership is removed; server-side persisted JSON compatibility remains unchanged.

**Tech Stack:** Plain browser JavaScript bundled from `fixthis-mcp/src/main/console`, Node `node:test` harnesses under `scripts/`, Kotlin HTTP route tests under `fixthis-mcp/src/test/kotlin`, Gradle, local console assets.

---

## File Structure

### Domain and Application

- Modify `fixthis-mcp/src/main/console/domain/consoleAppState.js`: extend canonical state fields only when reducer tests require them.
- Modify `fixthis-mcp/src/main/console/domain/workspaceState.js`: keep workspace constructors and add focused/recovery helpers needed by selectors.
- Modify `fixthis-mcp/src/main/console/domain/consoleReducer.js`: own all session, preview, draft, saved item, boundary, SSE, and handoff transitions.
- Modify `fixthis-mcp/src/main/console/domain/consoleSelectors.js`: derive history, canvas, inspector, overlay, prompt, toolbar, boundary, and recovery models.
- Modify `fixthis-mcp/src/main/console/domain/consoleInvariants.js`: reject draft/session drift, saved/draft focus coexistence, unfenced pending boundary, and missing context.
- Modify `fixthis-mcp/src/main/console/application/consoleStore.js`: keep store small; no domain policy here.
- Modify `fixthis-mcp/src/main/console/application/consoleEffects.js`: execute canonical effects through ports and dispatch fenced results.

### Browser Adapters and UI

- Modify `fixthis-mcp/src/main/console/adapters/browserPorts.js`: call explicit session-scoped endpoints and remove `/api/feedback/items`.
- Modify `fixthis-mcp/src/main/console/adapters/browserRenderer.js`: render selector models only.
- Modify `fixthis-mcp/src/main/console/events.js`: convert SSE handlers to dispatch-only handlers.
- Modify `fixthis-mcp/src/main/console/history.js`: convert session/history controls to dispatch-only handlers and selector rendering.
- Modify `fixthis-mcp/src/main/console/preview.js`: convert preview capture, navigation, image URLs, and canvas interactions to canonical commands/effects.
- Modify `fixthis-mcp/src/main/console/annotations.js`: convert draft add/delete/focus/comment/saved item operations to canonical commands.
- Modify `fixthis-mcp/src/main/console/prompt.js`: convert copy/save handoff operations to canonical commands/effects.
- Modify `fixthis-mcp/src/main/console/rendering.js`: remove compatibility render paths that read legacy globals.
- Modify `fixthis-mcp/src/main/console/state.js`: remove user-visible legacy state and keep only bootstrap constants or delete if empty.
- Modify `fixthis-mcp/src/main/resources/console/app.js` and `.map`: generated by asset build only.

### Kotlin Routes

- Modify `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/PreviewRoutes.kt`: accept optional `sessionId` on preview and navigation routes.
- Modify `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/FeedbackConsolePreviewModels.kt` only if a response needs session id added.
- Modify `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsolePreviewRoutesTest.kt`: verify explicit session scoping.
- Modify `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleNavigationRoutesTest.kt`: verify navigation uses explicit session id.

### Tests

- Modify `scripts/consoleCanonicalState-test.mjs`: reducer transition coverage.
- Modify `scripts/consoleCanonicalWorkflow-test.mjs`: end-to-end canonical event sequences.
- Modify `scripts/consoleSelectors-test.mjs`: selector consistency and explicit URL models.
- Modify `scripts/consoleEffects-test.mjs`: effect endpoint and context fencing tests.
- Modify `scripts/sessionScopedRequests-test.mjs`: static browser request checks.
- Create `scripts/consoleCanonicalLegacyRemoval-test.mjs`: hard fail on production legacy state writes and direct request calls from UI handlers.
- Modify `scripts/console-harness.mjs` or scenario fixtures only if the existing harness needs canonical state helpers.
- Modify `scripts/console-tests.json`: include the new static/harness test.

## Task 1: Server Preview and Navigation Session Scope

**Files:**
- Modify: `fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/PreviewRoutes.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsolePreviewRoutesTest.kt`
- Test: `fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleNavigationRoutesTest.kt`

- [ ] **Step 1: Write failing Kotlin tests for explicit preview session id**

Add tests that open two sessions, request preview for the non-current session with `?sessionId=<id>`, and assert the service receives that explicit id. Use the existing fake bridge/server fixture style in `ConsolePreviewRoutesTest.kt`.

```kotlin
@Test
fun `preview capture uses explicit session id when provided`() {
    val bridge = FakeFixThisBridge()
    ConsoleRouteTestFixtures.newConsoleSessionFixture(
        bridge = bridge,
        idGenerator = FakeIds("session-a", "session-b", "preview-screen").next,
    ).use { fixture ->
        val sessionA = fixture.service.openSession(null, newSession = true)
        val sessionB = fixture.service.openSession(null, newSession = true)

        val response = fixture.client.get("/api/preview?sessionId=${sessionA.sessionId}")

        assertTrue(response.contains("preview-"))
        assertEquals(sessionB.sessionId, fixture.service.requireCurrentSession().sessionId)
        assertEquals(sessionA.sessionId, bridge.lastCaptureSessionId)
    }
}
```

Add the navigation equivalent in `ConsoleNavigationRoutesTest.kt`.

```kotlin
@Test
fun `navigation uses explicit session id when provided`() {
    val bridge = FakeFixThisBridge()
    ConsoleRouteTestFixtures.newConsoleSessionFixture(
        bridge = bridge,
        idGenerator = FakeIds("session-a", "session-b", "nav-screen").next,
    ).use { fixture ->
        val sessionA = fixture.service.openSession(null, newSession = true)
        val sessionB = fixture.service.openSession(null, newSession = true)

        val connection = fixture.client.connection("/api/navigation?sessionId=${sessionA.sessionId}")
        connection.requestMethod = "POST"
        connection.doOutput = true
        connection.setRequestProperty("Content-Type", "application/json")
        connection.outputStream.use { it.write("""{"action":"back","captureAfter":true}""".toByteArray()) }

        assertEquals(200, connection.responseCode)
        assertEquals(sessionB.sessionId, fixture.service.requireCurrentSession().sessionId)
        assertEquals(sessionA.sessionId, bridge.lastCaptureSessionId)
    }
}
```

- [ ] **Step 2: Run route tests and verify failure**

Run:

```bash
./gradlew :fixthis-mcp:test --tests '*ConsolePreviewRoutesTest*' --tests '*ConsoleNavigationRoutesTest*'
```

Expected: FAIL because `/api/preview` and `/api/navigation` ignore query `sessionId`.

- [ ] **Step 3: Implement explicit session id routing**

In `PreviewRoutes.kt`, change preview and navigation session resolution to:

```kotlin
private fun HttpExchange.explicitOrCurrentSessionId(): String =
    queryParameter("sessionId")?.takeIf { it.isNotBlank() }
        ?: service.requireCurrentSession().sessionId
```

Use it in preview:

```kotlin
"/api/preview" -> exchange.requireMethod("GET") {
    val sessionId = exchange.explicitOrCurrentSessionId()
    val preview = runBlocking { service.capturePreview(sessionId) }
    eventBus.emit(
        "preview-ready",
        buildJsonObject {
            put("sessionId", sessionId)
            put("preview", fixThisJson.encodeToJsonElement(FeedbackPreviewSnapshot.serializer(), preview).jsonObject)
        },
    )
    exchange.sendJson(200, preview)
}
```

Use it in navigation:

```kotlin
"/api/navigation" -> exchange.requireMethod("POST") {
    val request = exchange.decodeNavigationBody()
    val sessionId = exchange.explicitOrCurrentSessionId()
    val result = try {
        runBlocking { service.navigate(sessionId, request) }
    } catch (error: IllegalArgumentException) {
        throw FeedbackConsoleHttpException(400, error.message ?: "Invalid navigation request")
    }
    eventBus.emit(
        "navigation-updated",
        buildJsonObject {
            put("sessionId", sessionId)
            put("navigation", fixThisJson.encodeToJsonElement(FeedbackNavigationResult.serializer(), result).jsonObject)
        },
    )
    exchange.sendJson(200, result)
}
```

- [ ] **Step 4: Run route tests and verify pass**

Run:

```bash
./gradlew :fixthis-mcp:test --tests '*ConsolePreviewRoutesTest*' --tests '*ConsoleNavigationRoutesTest*'
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add fixthis-mcp/src/main/kotlin/io/github/beyondwin/fixthis/mcp/console/PreviewRoutes.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsolePreviewRoutesTest.kt \
  fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/console/ConsoleNavigationRoutesTest.kt
git commit -m "fix(console): scope preview and navigation routes by session"
```

## Task 2: Canonical Reducer Coverage for Reported Drift

**Files:**
- Modify: `fixthis-mcp/src/main/console/domain/consoleReducer.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleInvariants.js`
- Modify: `fixthis-mcp/src/main/console/domain/workspaceState.js`
- Test: `scripts/consoleCanonicalState-test.mjs`
- Test: `scripts/consoleCanonicalWorkflow-test.mjs`

- [ ] **Step 1: Add failing reducer tests for SSE and delete drift**

Append to `scripts/consoleCanonicalState-test.mjs`:

```js
test('non-active SSE session update cannot replace active draft workspace', () => {
  let state = m.createInitialConsoleAppState({
    activeSessionId: 'session-a',
    sessions: [session('session-a'), session('session-b')],
  });
  state = m.reduceConsoleAppState(state, {
    type: 'DRAFT_STARTED_FROM_PREVIEW',
    sessionId: 'session-a',
    preview: preview('preview-a', 'screen-a'),
  }).state;

  const result = m.reduceConsoleAppState(state, {
    type: 'SSE_SESSION_UPDATED',
    session: { ...session('session-b'), status: 'closed', items: [{ itemId: 'b-item', screenId: 'b-screen' }] },
  });

  assert.equal(result.state.activeSessionId, 'session-a');
  assert.equal(result.state.workspace.kind, m.WorkspaceKind.DRAFT);
  assert.equal(result.state.workspace.context.sessionId, 'session-a');
  assert.equal(result.state.workspace.context.previewId, 'preview-a');
  assert.equal(result.state.sessionsById['session-b'].status, 'closed');
  m.assertConsoleInvariants(result.state);
});
```

Append to `scripts/consoleCanonicalWorkflow-test.mjs`:

```js
test('active session delete with draft creates boundary and preserves draft', () => {
  let state = m.createInitialConsoleAppState({
    activeSessionId: 'session-a',
    sessions: [{ sessionId: 'session-a' }, { sessionId: 'session-b' }],
  });
  state = m.reduceConsoleAppState(state, {
    type: 'DRAFT_STARTED_FROM_PREVIEW',
    sessionId: 'session-a',
    preview: preview(1),
  }).state;
  const result = m.reduceConsoleAppState(state, {
    type: 'DELETE_SESSION_CLICKED',
    sessionId: 'session-a',
  });

  assert.equal(result.state.activeSessionId, 'session-a');
  assert.equal(result.state.workspace.kind, m.WorkspaceKind.DRAFT);
  assert.equal(result.state.pendingBoundary.kind, 'session-delete');
  assert.equal(result.effects.length, 0);
});
```

- [ ] **Step 2: Run reducer tests and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalState-test.mjs scripts/consoleCanonicalWorkflow-test.mjs
```

Expected: FAIL because `SSE_SESSION_UPDATED` and `DELETE_SESSION_CLICKED` are not handled.

- [ ] **Step 3: Add reducer cases**

In `consoleReducer.js`, add dispatch cases:

```js
case 'SSE_SESSION_UPDATED':
  result = reduceSseSessionUpdated(state, event);
  break;
case 'DELETE_SESSION_CLICKED':
  result = reduceDeleteSessionClicked(state, event);
  break;
case 'SESSION_DELETE_SUCCEEDED':
  result = reduceSessionDeleteSucceeded(state, event);
  break;
```

Add implementations:

```js
function reduceSseSessionUpdated(state, event) {
  const session = event.session;
  if (!session?.sessionId) return { state, effects: [] };
  const next = upsertSession(state, session);
  return { state: next, effects: [] };
}

function reduceDeleteSessionClicked(state, event) {
  const sessionId = event.sessionId || null;
  if (!sessionId) return { state, effects: [] };
  if (isDraftWorkspace(state.workspace) && state.workspace.context.sessionId === sessionId) {
    return {
      state: replaceConsoleState(state, {
        pendingBoundary: Object.freeze({
          kind: 'session-delete',
          fromSessionId: sessionId,
          targetSessionId: null,
          draftSummary: Object.freeze({
            itemCount: state.workspace.items.length,
            missingCommentCount: state.workspace.items.filter((item) => !String(item.comment || '').trim()).length,
            previewId: state.workspace.context.previewId,
          }),
        }),
      }),
      effects: [],
    };
  }
  const generation = nextGeneration(state);
  return {
    state: replaceConsoleState(state, { effectsGeneration: generation }),
    effects: [Object.freeze({
      kind: 'deleteSession',
      requestId: nextRequestId('delete-session', generation),
      sessionId,
      generation,
    })],
  };
}

function reduceSessionDeleteSucceeded(state, event) {
  if (event.generation !== state.effectsGeneration) return { state, effects: [] };
  const deletedSessionId = event.sessionId || event.session?.sessionId || null;
  if (!deletedSessionId) return { state, effects: [] };
  const sessionsById = { ...state.sessionsById };
  if (event.session?.sessionId) sessionsById[event.session.sessionId] = cloneConsoleValue(event.session);
  const sessionOrder = state.sessionOrder.filter((id) => id !== deletedSessionId);
  if (deletedSessionId !== state.activeSessionId) {
    return { state: replaceConsoleState(state, { sessionsById: Object.freeze(sessionsById), sessionOrder: Object.freeze(sessionOrder) }), effects: [] };
  }
  const nextActiveSessionId = event.nextActiveSessionId || sessionOrder[0] || null;
  return {
    state: replaceConsoleState(state, {
      activeSessionId: nextActiveSessionId,
      sessionsById: Object.freeze(sessionsById),
      sessionOrder: Object.freeze(sessionOrder),
      workspace: nextActiveSessionId ? livePreviewWorkspace(nextActiveSessionId, null) : emptyWorkspace(),
      pendingBoundary: null,
      tool: Object.freeze({ mode: 'select' }),
    }),
    effects: nextActiveSessionId
      ? [Object.freeze({ kind: 'openSession', requestId: nextRequestId('open-session', event.generation), sessionId: nextActiveSessionId, generation: event.generation })]
      : [],
  };
}
```

- [ ] **Step 4: Strengthen invariants**

In `consoleInvariants.js`, add:

```js
if (state.pendingBoundary?.kind === 'session-delete' && !state.pendingBoundary.fromSessionId) {
  throw new Error('session-delete boundary requires fromSessionId');
}
if (workspace.kind === WorkspaceKind.LIVE_PREVIEW && workspace.sessionId && state.activeSessionId !== workspace.sessionId) {
  throw new Error('Live preview workspace session must match activeSessionId');
}
```

- [ ] **Step 5: Run reducer tests and verify pass**

Run:

```bash
node --test scripts/consoleCanonicalState-test.mjs scripts/consoleCanonicalWorkflow-test.mjs
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add fixthis-mcp/src/main/console/domain/consoleReducer.js \
  fixthis-mcp/src/main/console/domain/consoleInvariants.js \
  fixthis-mcp/src/main/console/domain/workspaceState.js \
  scripts/consoleCanonicalState-test.mjs \
  scripts/consoleCanonicalWorkflow-test.mjs
git commit -m "test(console): cover canonical session drift boundaries"
```

## Task 3: Canonical Effect Adapters Use Real Session-Scoped APIs

**Files:**
- Modify: `fixthis-mcp/src/main/console/application/consoleEffects.js`
- Modify: `fixthis-mcp/src/main/console/adapters/browserPorts.js`
- Test: `scripts/consoleEffects-test.mjs`
- Test: `scripts/sessionScopedRequests-test.mjs`

- [ ] **Step 1: Add failing effect tests for endpoints**

Append to `scripts/consoleEffects-test.mjs`:

```js
test('saveDraft effect uses preview batch API with immutable draft context', async () => {
  const calls = [];
  const dispatched = [];
  await m.runConsoleEffect(
    {
      kind: 'saveDraft',
      requestId: 'save-1',
      sessionId: 'session-a',
      workspaceId: 'workspace-a',
      previewId: 'preview-a',
      screenId: 'screen-a',
      frozenFingerprint: 'fp-a',
      items: [{ draftItemId: 'draft-1', comment: 'Fix CTA' }],
      generation: 4,
    },
    {
      dispatch: (event) => dispatched.push(event),
      ports: {
        sessionApi: {
          saveDraft: async (request) => {
            calls.push(request);
            return { sessionId: request.sessionId, items: [{ itemId: 'item-1' }], screens: [] };
          },
        },
      },
    },
  );

  assert.deepEqual(calls, [{
    sessionId: 'session-a',
    workspaceId: 'workspace-a',
    previewId: 'preview-a',
    screenId: 'screen-a',
    frozenFingerprint: 'fp-a',
    items: [{ draftItemId: 'draft-1', comment: 'Fix CTA' }],
  }]);
  assert.equal(dispatched[0].type, 'DRAFT_SAVE_SUCCEEDED');
  assert.equal(dispatched[0].workspaceId, 'workspace-a');
});
```

Append to `scripts/sessionScopedRequests-test.mjs`:

```js
test('browser ports use session-scoped preview and batch endpoints', () => {
  assert.match(browserPortsSource, /\/api\/preview\?sessionId=/);
  assert.match(browserPortsSource, /\/api\/navigation\?sessionId=/);
  assert.match(browserPortsSource, /\/api\/items\/batch/);
  assert.doesNotMatch(browserPortsSource, /\/api\/feedback\/items/);
});
```

If `browserPortsSource` is not loaded in that file, add:

```js
const browserPortsSource = readFileSync(resolve(root, 'fixthis-mcp/src/main/console/adapters/browserPorts.js'), 'utf8');
```

- [ ] **Step 2: Run tests and verify failure**

Run:

```bash
node --test scripts/consoleEffects-test.mjs scripts/sessionScopedRequests-test.mjs
```

Expected: FAIL because `saveDraft` currently receives `(sessionId, items)` and browser ports call `/api/feedback/items`.

- [ ] **Step 3: Update browser ports**

In `browserPorts.js`, change ports to:

```js
function querySession(sessionId) {
  return sessionId ? '?sessionId=' + encodeURIComponent(sessionId) : '';
}

function createBrowserConsolePorts(options = {}) {
  const requestJson_ = options.requestJson;
  const localStorage_ = options.localStorage || localStorage;
  return Object.freeze({
    sessionApi: Object.freeze({
      openSession: async ({ sessionId = null, newSession = false } = {}) => requestJson_('/api/session/open', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId, newSession }),
      }),
      closeSession: async (sessionId) => requestJson_('/api/session/close', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId }),
      }),
      deleteSession: async (sessionId) => requestJson_('/api/session/close', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId }),
      }),
      listSessions: async () => requestJson_('/api/sessions'),
      currentSession: async () => requestJson_('/api/session'),
      saveDraft: async (request) => requestJson_('/api/items/batch', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          sessionId: request.sessionId,
          previewId: request.previewId,
          items: request.items,
          frozenFingerprint: request.frozenFingerprint,
        }),
      }),
      handoffPreview: async (sessionId, itemIds) => requestJson_('/api/sessions/' + encodeURIComponent(sessionId) + '/handoff-preview', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ itemIds }),
      }),
      saveHandoff: async (sessionId, itemIds) => requestJson_('/api/agent-handoffs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId, itemIds }),
      }),
    }),
    previewApi: Object.freeze({
      capturePreview: async (sessionId) => requestJson_('/api/preview' + querySession(sessionId)),
      navigate: async (sessionId, body) => requestJson_('/api/navigation' + querySession(sessionId), {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      }),
    }),
    draftStorage: Object.freeze({
      saveRecovery: async (sessionId, workspace) => {
        localStorage_.setItem('fixthis.recovery.' + sessionId, JSON.stringify(workspace));
      },
      deleteRecovery: async (sessionId, workspaceId) => {
        const prefix = 'fixthis.draftWorkspace.' + sessionId + '.';
        if (workspaceId) localStorage_.removeItem(prefix + workspaceId);
        localStorage_.removeItem('fixthis.recovery.' + sessionId);
      },
    }),
    clipboard: Object.freeze({
      writeText: async (text) => navigator.clipboard.writeText(text),
    }),
    timer: Object.freeze({
      setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
      clearTimeout: (id) => clearTimeout(id),
    }),
    clock: Object.freeze({ now: () => Date.now() }),
  });
}
```

- [ ] **Step 4: Update effect runner**

In `consoleEffects.js`, call the new port shapes:

```js
case 'openSession': {
  const session = await ports.sessionApi.openSession({
    sessionId: effect.sessionId || null,
    newSession: effect.newSession === true,
  });
  dispatch({ type: 'SESSION_OPEN_SUCCEEDED', requestId: effect.requestId, sessionId: session.sessionId || effect.sessionId, generation: effect.generation, session });
  return;
}
case 'capturePreview': {
  const preview = await ports.previewApi.capturePreview(effect.sessionId);
  dispatch({ type: 'PREVIEW_CAPTURE_SUCCEEDED', requestId: effect.requestId, sessionId: effect.sessionId, generation: effect.generation, preview });
  return;
}
case 'saveDraft': {
  const result = await ports.sessionApi.saveDraft({
    sessionId: effect.sessionId,
    workspaceId: effect.workspaceId,
    previewId: effect.previewId,
    screenId: effect.screenId,
    frozenFingerprint: effect.frozenFingerprint == null ? null : effect.frozenFingerprint,
    items: effect.items || [],
  });
  dispatch({ type: 'DRAFT_SAVE_SUCCEEDED', requestId: effect.requestId, sessionId: effect.sessionId, targetSessionId: effect.targetSessionId, workspaceId: effect.workspaceId, generation: effect.generation, session: result?.session || result });
  return;
}
```

Add cases for `deleteSession`, `navigate`, and `saveHandoff`:

```js
case 'deleteSession': {
  const session = await ports.sessionApi.deleteSession(effect.sessionId);
  dispatch({ type: 'SESSION_DELETE_SUCCEEDED', requestId: effect.requestId, sessionId: effect.sessionId, generation: effect.generation, session });
  return;
}
case 'navigate': {
  const result = await ports.previewApi.navigate(effect.sessionId, {
    action: effect.action,
    captureAfter: effect.captureAfter === true,
    ...(effect.point || {}),
    direction: effect.direction,
    distance: effect.distance,
  });
  dispatch({ type: 'NAVIGATION_SUCCEEDED', requestId: effect.requestId, sessionId: effect.sessionId, generation: effect.generation, result });
  return;
}
case 'saveHandoff': {
  const result = await ports.sessionApi.saveHandoff(effect.sessionId, effect.itemIds || []);
  dispatch({ type: 'HANDOFF_SUCCEEDED', requestId: effect.requestId, sessionId: effect.sessionId, itemIds: effect.itemIds || [], generation: effect.generation, session: result?.session || result });
  return;
}
```

- [ ] **Step 5: Run tests and verify pass**

Run:

```bash
node --test scripts/consoleEffects-test.mjs scripts/sessionScopedRequests-test.mjs
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add fixthis-mcp/src/main/console/application/consoleEffects.js \
  fixthis-mcp/src/main/console/adapters/browserPorts.js \
  scripts/consoleEffects-test.mjs \
  scripts/sessionScopedRequests-test.mjs
git commit -m "fix(console): route canonical effects through scoped APIs"
```

## Task 4: Selector Models Own Canvas, Overlay, Inspector, and Prompt

**Files:**
- Modify: `fixthis-mcp/src/main/console/domain/consoleSelectors.js`
- Modify: `fixthis-mcp/src/main/console/domain/workspaceState.js`
- Test: `scripts/consoleSelectors-test.mjs`

- [ ] **Step 1: Add failing selector tests**

Append to `scripts/consoleSelectors-test.mjs`:

```js
test('draft canvas, overlay, inspector, and prompt share the same item ids', () => {
  let state = m.createInitialConsoleAppState({ activeSessionId: 'session-a', sessions: [{ sessionId: 'session-a' }] });
  state = m.reduceConsoleAppState(state, { type: 'DRAFT_STARTED_FROM_PREVIEW', sessionId: 'session-a', preview: preview() }).state;
  state = m.reduceConsoleAppState(state, {
    type: 'DRAFT_TARGET_SELECTED',
    itemId: 'draft-1',
    selection: { bounds: { left: 1, top: 1, right: 20, bottom: 20 }, label: 'CTA' },
  }).state;
  state = m.reduceConsoleAppState(state, { type: 'DRAFT_COMMENT_CHANGED', itemId: 'draft-1', comment: 'Fix CTA' }).state;

  const canvas = m.selectCanvasModel(state);
  const inspector = m.selectInspectorModel(state);
  const prompt = m.selectPromptReadiness(state);

  assert.deepEqual(canvas.pins.map((item) => item.itemId), ['draft-1']);
  assert.deepEqual(inspector.rows.map((item) => item.itemId), ['draft-1']);
  assert.equal(prompt.disabled, false);
});
```

Add URL model test:

```js
test('canvas model exposes explicit session-scoped screenshot URL', () => {
  let state = m.createInitialConsoleAppState({ activeSessionId: 'session-a', sessions: [{ sessionId: 'session-a' }] });
  state = m.reduceConsoleAppState(state, { type: 'DRAFT_STARTED_FROM_PREVIEW', sessionId: 'session-a', preview: preview() }).state;
  const canvas = m.selectCanvasModel(state);
  assert.match(canvas.screenshotUrl, /sessionId=session-a/);
});
```

- [ ] **Step 2: Export selector functions in test factory**

In `scripts/consoleSelectors-test.mjs`, ensure the factory returns:

```js
return {
  createInitialConsoleAppState,
  reduceConsoleAppState,
  selectCanvasModel,
  selectInspectorModel,
  selectPromptReadiness,
  selectHistoryModel,
  selectToolbarModel,
  selectBoundarySheet,
  WorkspaceKind
};
```

- [ ] **Step 3: Run selector tests and verify failure**

Run:

```bash
node --test scripts/consoleSelectors-test.mjs
```

Expected: FAIL because draft item shape and screenshot URL are not selector-complete.

- [ ] **Step 4: Implement selector item normalization**

In `consoleSelectors.js`, add:

```js
function itemBounds(item) {
  return item?.selection?.bounds || item?.bounds || item?.target?.bounds || item?.target?.boundsInWindow || null;
}

function draftPinModel(item, index) {
  return Object.freeze({
    ...item,
    itemId: item.itemId || item.annotationId || `draft-${index + 1}`,
    displayNumber: item.sequenceNumber == null ? index + 1 : item.sequenceNumber,
    bounds: itemBounds(item),
  });
}

function sessionScopedPreviewUrl(previewId, sessionId) {
  return '/api/preview/' + encodeURIComponent(previewId) + '/screenshot/full?sessionId=' + encodeURIComponent(sessionId);
}

function sessionScopedScreenUrl(screenId, sessionId) {
  return '/api/screens/' + encodeURIComponent(screenId) + '/screenshot/full?sessionId=' + encodeURIComponent(sessionId);
}
```

Update draft canvas:

```js
if (workspace.kind === WorkspaceKind.DRAFT) {
  const pins = Object.freeze(workspace.items.map(draftPinModel));
  return Object.freeze({
    mode: 'frozenDraft',
    sessionId: workspace.context.sessionId,
    screen: workspace.screen,
    screenshotUrl: workspace.screenshotUrl || sessionScopedPreviewUrl(workspace.context.previewId, workspace.context.sessionId),
    pins,
    lockLabel: 'Locked: Session ' + workspace.context.sessionId + ' · Preview ' + workspace.context.previewId,
  });
}
```

Update inspector draft rows to use the same `draftPinModel`:

```js
rows: Object.freeze(workspace.items.map(draftPinModel)),
```

- [ ] **Step 5: Run selector tests and verify pass**

Run:

```bash
node --test scripts/consoleSelectors-test.mjs
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add fixthis-mcp/src/main/console/domain/consoleSelectors.js \
  fixthis-mcp/src/main/console/domain/workspaceState.js \
  scripts/consoleSelectors-test.mjs
git commit -m "feat(console): derive visible models from canonical state"
```

## Task 5: SSE Becomes Dispatch-Only

**Files:**
- Modify: `fixthis-mcp/src/main/console/events.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleReducer.js`
- Test: `scripts/consoleCanonicalWorkflow-test.mjs`
- Test: `scripts/consoleCanonicalLegacyRemoval-test.mjs`

- [ ] **Step 1: Create static legacy removal test file**

Create `scripts/consoleCanonicalLegacyRemoval-test.mjs`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const consoleRoot = resolve(root, 'fixthis-mcp/src/main/console');

function source(file) {
  return readFileSync(resolve(consoleRoot, file), 'utf8');
}

test('SSE handlers dispatch canonical events instead of rebinding legacy session or preview', () => {
  const events = source('events.js');
  assert.doesNotMatch(events, /setConsoleSession\(/);
  assert.doesNotMatch(events, /setConsolePreview\(/);
  assert.match(events, /dispatch\(\{\s*type:\s*'SSE_SESSION_UPDATED'/);
  assert.match(events, /dispatch\(\{\s*type:\s*'SSE_PREVIEW_READY'/);
});
```

- [ ] **Step 2: Run static test and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: FAIL because `events.js` still calls `setConsoleSession` and `setConsolePreview`.

- [ ] **Step 3: Update `startConsoleEvents` signature**

In `events.js`, make the module use the existing `store` in lexical scope:

```js
let consoleEventsSource = null;
function startConsoleEvents() {
  if (typeof EventSource === 'undefined' || consoleEventsSource) return;
  const source = consoleEventsSource = new EventSource('/api/events');
  const json = (event) => {
    try { return JSON.parse(event.data || '{}'); } catch (_) { return {}; }
  };
  const on = (name, fn) => source.addEventListener(name, (event) => fn(json(event)));

  on('snapshot', (data) => {
    store.dispatch({ type: 'CONSOLE_SNAPSHOT_RECEIVED', snapshot: data });
  });
  on('session-updated', (data) => {
    if (data.session) store.dispatch({ type: 'SSE_SESSION_UPDATED', session: data.session });
  });
  on('sessions-updated', (data) => {
    store.dispatch({ type: 'SSE_SESSIONS_UPDATED', sessions: data.sessions?.sessions || null });
  });
  on('preview-ready', (data) => {
    store.dispatch({ type: 'SSE_PREVIEW_READY', sessionId: data.sessionId || null, preview: data.preview || null });
  });
  on('devices-updated', (data) => {
    store.dispatch({ type: 'DEVICES_UPDATED', devices: data.devices || data });
  });
  on('connection-updated', (data) => {
    if (data.connection) store.dispatch({ type: 'CONNECTION_UPDATED', connection: data.connection });
  });
  source.addEventListener('replay-overflow', () => {
    store.dispatch({ type: 'CONSOLE_REPLAY_OVERFLOW' });
  });
  source.onerror = () => {
    store.dispatch({ type: 'SSE_ERROR' });
  };
}
```

- [ ] **Step 4: Add reducer handling for SSE preview**

In `consoleReducer.js`:

```js
case 'SSE_PREVIEW_READY':
  result = reduceSsePreviewReady(state, event);
  break;
case 'SSE_SESSIONS_UPDATED':
  result = reduceSseSessionsUpdated(state, event);
  break;
```

Implement:

```js
function reduceSsePreviewReady(state, event) {
  if (!event.preview || !event.sessionId) return { state, effects: [] };
  if (event.sessionId !== state.activeSessionId) return { state, effects: [] };
  if (isDraftWorkspace(state.workspace)) return { state, effects: [] };
  return {
    state: replaceConsoleState(state, {
      workspace: livePreviewWorkspace(event.sessionId, event.preview),
    }),
    effects: [],
  };
}

function reduceSseSessionsUpdated(state, event) {
  if (!Array.isArray(event.sessions)) {
    const generation = nextGeneration(state);
    return {
      state: replaceConsoleState(state, { effectsGeneration: generation }),
      effects: [Object.freeze({ kind: 'listSessions', requestId: nextRequestId('list-sessions', generation), generation })],
    };
  }
  return { state: replaceSessions(state, event.sessions), effects: [] };
}
```

- [ ] **Step 5: Run tests and verify pass**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs scripts/consoleCanonicalWorkflow-test.mjs
```

Expected: PASS.

- [ ] **Step 6: Register new test**

Add `scripts/consoleCanonicalLegacyRemoval-test.mjs` to `scripts/console-tests.json` using the same entry shape as other console JS tests in that file.

- [ ] **Step 7: Commit**

```bash
git add fixthis-mcp/src/main/console/events.js \
  fixthis-mcp/src/main/console/domain/consoleReducer.js \
  scripts/consoleCanonicalWorkflow-test.mjs \
  scripts/consoleCanonicalLegacyRemoval-test.mjs \
  scripts/console-tests.json
git commit -m "fix(console): route SSE updates through canonical state"
```

## Task 6: Session and History DOM Handlers Dispatch Only

**Files:**
- Modify: `fixthis-mcp/src/main/console/history.js`
- Modify: `fixthis-mcp/src/main/console/rendering.js`
- Modify: `fixthis-mcp/src/main/console/adapters/browserRenderer.js`
- Test: `scripts/consoleCanonicalLegacyRemoval-test.mjs`
- Test: `scripts/consoleCanonicalWorkflow-test.mjs`

- [ ] **Step 1: Extend static test for direct session mutation**

Add to `scripts/consoleCanonicalLegacyRemoval-test.mjs`:

```js
test('history handlers do not call requestJson or legacy session setters', () => {
  const history = source('history.js');
  assert.doesNotMatch(history, /setConsoleSession\(/);
  assert.doesNotMatch(history, /clearPreview\(/);
  assert.doesNotMatch(history, /resetComposer\(/);
  assert.doesNotMatch(history, /requestJson\('/);
  assert.match(history, /store\.dispatch\(\{\s*type:\s*'SESSION_ROW_CLICKED'/);
  assert.match(history, /store\.dispatch\(\{\s*type:\s*'DELETE_SESSION_CLICKED'/);
});
```

- [ ] **Step 2: Run static test and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: FAIL because `history.js` still uses legacy mutation and request calls.

- [ ] **Step 3: Replace `openSession` body**

In `history.js`, replace `openSession(sessionId)` with:

```js
function openSession(sessionId) {
  store.dispatch({ type: 'SESSION_ROW_CLICKED', sessionId });
}
```

Replace `newSession`:

```js
function newSession() {
  store.dispatch({ type: 'NEW_SESSION_CLICKED' });
}
```

Replace `closeSession`:

```js
function closeSession() {
  store.dispatch({ type: 'CLOSE_ACTIVE_SESSION_CLICKED' });
}
```

Replace `deleteHistorySession`:

```js
function deleteHistorySession(sessionId) {
  store.dispatch({ type: 'DELETE_SESSION_CLICKED', sessionId });
}
```

- [ ] **Step 4: Move history rendering to model input**

Keep `renderSessionsListFromPayload` only as a renderer that accepts selector session models. If it still writes `state.sessionSummaries`, remove that write. The active id comes from the model:

```js
function renderHistoryModel(model) {
  const activeId = model.activeSessionId;
  const activeSummaries = model.sessionIds
    .map((id) => model.sessionsById[id])
    .filter((session) => session && session.status !== 'closed');
  renderSessionsListFromModel({ ...model, activeId, sessions: activeSummaries });
}
```

In `browserRenderer.js`, call:

```js
bindings.renderHistory?.(model.history, state);
```

In `rendering.js`, make `renderCanonicalHistoryModel(model)` call the model renderer:

```js
function renderCanonicalHistoryModel(model) {
  renderHistoryModel(model);
}
```

- [ ] **Step 5: Run tests and verify pass**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs scripts/consoleCanonicalWorkflow-test.mjs
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add fixthis-mcp/src/main/console/history.js \
  fixthis-mcp/src/main/console/rendering.js \
  fixthis-mcp/src/main/console/adapters/browserRenderer.js \
  scripts/consoleCanonicalLegacyRemoval-test.mjs \
  scripts/consoleCanonicalWorkflow-test.mjs
git commit -m "refactor(console): make history controls dispatch canonical commands"
```

## Task 7: Preview and Canvas Interactions Dispatch Only

**Files:**
- Modify: `fixthis-mcp/src/main/console/preview.js`
- Modify: `fixthis-mcp/src/main/console/rendering.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleReducer.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleSelectors.js`
- Test: `scripts/consoleCanonicalLegacyRemoval-test.mjs`
- Test: `scripts/consoleCanonicalWorkflow-test.mjs`

- [ ] **Step 1: Add static test for preview legacy paths**

Add:

```js
test('preview handlers do not mutate legacy preview or call requestJson directly', () => {
  const preview = source('preview.js');
  assert.doesNotMatch(preview, /setConsolePreview\(/);
  assert.doesNotMatch(preview, /requestJson\('/);
  assert.doesNotMatch(preview, /state\.preview/);
  assert.match(preview, /store\.dispatch\(\{\s*type:\s*'LIVE_PREVIEW_TICK'/);
  assert.match(preview, /store\.dispatch\(\{\s*type:\s*'PREVIEW_TAP'/);
});
```

- [ ] **Step 2: Run static test and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: FAIL because preview code still reads/writes legacy preview state.

- [ ] **Step 3: Add reducer handling for live preview tick and navigation**

In `consoleReducer.js`:

```js
case 'LIVE_PREVIEW_TICK':
  result = reduceLivePreviewTick(state);
  break;
case 'PREVIEW_TAP':
  result = reducePreviewTap(state, event);
  break;
case 'NAVIGATION_SUCCEEDED':
  result = reduceNavigationSucceeded(state, event);
  break;
```

Implement:

```js
function reduceLivePreviewTick(state) {
  if (!state.activeSessionId || isDraftWorkspace(state.workspace)) return { state, effects: [] };
  const generation = nextGeneration(state);
  return {
    state: replaceConsoleState(state, { effectsGeneration: generation }),
    effects: [Object.freeze({ kind: 'capturePreview', requestId: nextRequestId('preview', generation), sessionId: state.activeSessionId, generation })],
  };
}

function reducePreviewTap(state, event) {
  if (!state.activeSessionId || isDraftWorkspace(state.workspace)) return { state, effects: [] };
  const generation = nextGeneration(state);
  return {
    state: replaceConsoleState(state, { effectsGeneration: generation }),
    effects: [Object.freeze({
      kind: 'navigate',
      requestId: nextRequestId('navigate', generation),
      sessionId: state.activeSessionId,
      action: 'tap',
      point: cloneConsoleValue(event.point || {}),
      captureAfter: false,
      generation,
    })],
  };
}

function reduceNavigationSucceeded(state, event) {
  if (event.generation !== state.effectsGeneration) return { state, effects: [] };
  if (event.sessionId !== state.activeSessionId) return { state, effects: [] };
  return reduceLivePreviewTick(state);
}
```

- [ ] **Step 4: Replace preview functions with dispatch wrappers**

In `preview.js`, use selector canvas models for rendering and dispatch for behavior:

```js
function refreshPreview() {
  store.dispatch({ type: 'LIVE_PREVIEW_TICK' });
}

function navigate(action, extras = {}) {
  if (action === 'tap') {
    store.dispatch({ type: 'PREVIEW_TAP', point: { x: extras.x, y: extras.y } });
  } else {
    store.dispatch({ type: 'PREVIEW_NAVIGATE', action, extras });
  }
}
```

Update `screenImageUrl` to accept selector canvas model instead of reading `state.preview`:

```js
function canvasImageUrl(model) {
  if (!model) return '';
  if (model.screenshotUrl) return model.screenshotUrl;
  if (model.preview?.previewId && model.sessionId) return previewScreenshotUrl(model.preview.previewId, model.sessionId);
  if (model.screenId && model.sessionId) return screenScreenshotUrl(model.screenId, model.sessionId);
  return '';
}
```

- [ ] **Step 5: Update snapshot click handler**

In `rendering.js`, change click handling:

```js
image.addEventListener('click', event => {
  const canvas = selectCanvasModel(store.getState());
  if (canvas.mode === 'livePreview') {
    const point = naturalPointFromEvent(event, image);
    store.dispatch({ type: 'PREVIEW_TAP', point });
    return;
  }
  if (canvas.mode === 'frozenDraft') {
    store.dispatch({ type: 'DRAFT_CANVAS_CLICKED', point: naturalPointFromEvent(event, image) });
  }
});
```

- [ ] **Step 6: Run tests**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs scripts/consoleCanonicalWorkflow-test.mjs scripts/consoleSelectors-test.mjs
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add fixthis-mcp/src/main/console/preview.js \
  fixthis-mcp/src/main/console/rendering.js \
  fixthis-mcp/src/main/console/domain/consoleReducer.js \
  fixthis-mcp/src/main/console/domain/consoleSelectors.js \
  scripts/consoleCanonicalLegacyRemoval-test.mjs \
  scripts/consoleCanonicalWorkflow-test.mjs
git commit -m "refactor(console): drive preview interactions from canonical state"
```

## Task 8: Draft Annotation Add, Delete, Focus, and Comment Canonicalization

**Files:**
- Modify: `fixthis-mcp/src/main/console/annotations.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleReducer.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleSelectors.js`
- Modify: `fixthis-mcp/src/main/console/rendering.js`
- Test: `scripts/consoleCanonicalState-test.mjs`
- Test: `scripts/consoleSelectors-test.mjs`
- Test: `scripts/consoleCanonicalLegacyRemoval-test.mjs`

- [ ] **Step 1: Add failing reducer test for add-delete consistency**

Append:

```js
test('draft add and delete keep one canonical item source', () => {
  let state = m.createInitialConsoleAppState({ activeSessionId: 'session-a', sessions: [session('session-a')] });
  state = m.reduceConsoleAppState(state, { type: 'DRAFT_STARTED_FROM_PREVIEW', sessionId: 'session-a', preview: preview('a') }).state;
  state = m.reduceConsoleAppState(state, {
    type: 'DRAFT_TARGET_SELECTED',
    itemId: 'draft-1',
    selection: { bounds: { left: 1, top: 1, right: 10, bottom: 10 }, label: 'One' },
  }).state;
  state = m.reduceConsoleAppState(state, {
    type: 'DRAFT_TARGET_SELECTED',
    itemId: 'draft-2',
    selection: { bounds: { left: 20, top: 20, right: 30, bottom: 30 }, label: 'Two' },
  }).state;
  state = m.reduceConsoleAppState(state, { type: 'DRAFT_ITEM_DELETED', itemId: 'draft-1' }).state;

  assert.deepEqual(state.workspace.items.map((item) => item.itemId), ['draft-2']);
  assert.equal(state.workspace.focusedItemId, null);
  m.assertConsoleInvariants(state);
});
```

- [ ] **Step 2: Extend static test for annotation legacy writes**

Add:

```js
test('annotation handlers dispatch canonical draft commands', () => {
  const annotations = source('annotations.js');
  assert.doesNotMatch(annotations, /replaceDraftWorkspace\(/);
  assert.doesNotMatch(annotations, /draftWorkspace\s*=/);
  assert.doesNotMatch(annotations, /setDraftFocusIndex\(/);
  assert.match(annotations, /store\.dispatch\(\{\s*type:\s*'DRAFT_TARGET_SELECTED'/);
  assert.match(annotations, /store\.dispatch\(\{\s*type:\s*'DRAFT_ITEM_DELETED'/);
  assert.match(annotations, /store\.dispatch\(\{\s*type:\s*'DRAFT_COMMENT_CHANGED'/);
});
```

- [ ] **Step 3: Run tests and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalState-test.mjs scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: FAIL because annotations still mutate legacy draft state.

- [ ] **Step 4: Normalize reducer draft item shape**

In `consoleReducer.js`, update `reduceDraftTargetSelected`:

```js
function reduceDraftTargetSelected(state, event) {
  if (!isDraftWorkspace(state.workspace)) return { state, effects: [] };
  const nextIndex = state.workspace.items.length + 1;
  const item = Object.freeze({
    itemId: event.itemId || event.annotationId || `draft-${nextIndex}`,
    annotationId: event.annotationId || event.itemId || `draft-${nextIndex}`,
    sequenceNumber: nextIndex,
    comment: event.comment || '',
    label: event.selection?.label || event.label || null,
    severity: event.severity || 'med',
    status: event.status || 'open',
    selection: cloneConsoleValue(event.selection || null),
    bounds: cloneConsoleValue(event.selection?.bounds || event.bounds || null),
    targetEvidence: cloneConsoleValue(event.targetEvidence || null),
  });
  return {
    state: replaceConsoleState(state, {
      workspace: draftWorkspaceWithPatch(state.workspace, {
        items: [...state.workspace.items, item],
        focusedItemId: item.itemId,
        currentSelection: null,
      }),
      tool: Object.freeze({ mode: 'annotate', hoveredTarget: null, drag: null }),
    }),
    effects: [Object.freeze({ kind: 'persistRecovery', sessionId: state.workspace.context.sessionId, workspace: draftWorkspaceWithPatch(state.workspace, { items: [...state.workspace.items, item], focusedItemId: item.itemId, currentSelection: null }) })],
  };
}
```

Update `reduceDraftItemDeleted`:

```js
function reduceDraftItemDeleted(state, event) {
  if (!isDraftWorkspace(state.workspace)) return { state, effects: [] };
  const removed = state.workspace.items.find((item) => item.itemId === event.itemId || item.annotationId === event.itemId);
  if (!removed) return { state, effects: [] };
  const items = state.workspace.items.filter((item) => item.itemId !== removed.itemId && item.annotationId !== removed.annotationId);
  const workspace = draftWorkspaceWithPatch(state.workspace, {
    items,
    focusedItemId: state.workspace.focusedItemId === removed.itemId ? null : state.workspace.focusedItemId,
    currentSelection: null,
  });
  return {
    state: replaceConsoleState(state, { workspace }),
    effects: items.length
      ? [Object.freeze({ kind: 'persistRecovery', sessionId: workspace.context.sessionId, workspace })]
      : [Object.freeze({ kind: 'deleteRecovery', sessionId: workspace.context.sessionId, workspaceId: workspace.context.workspaceId })],
  };
}
```

- [ ] **Step 5: Replace annotation mutation functions with dispatch**

In `annotations.js`:

```js
function createAnnotationFromSelection(selection) {
  if (!selection) throw new Error('Select a component or area first.');
  store.dispatch({
    type: 'DRAFT_TARGET_SELECTED',
    selection,
    itemId: 'draft-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2),
  });
}

function deletePendingFeedbackItem(index) {
  const model = selectInspectorModel(store.getState());
  const item = model.rows[index];
  if (!item) return;
  store.dispatch({ type: 'DRAFT_ITEM_DELETED', itemId: item.itemId });
}

function focusPendingFeedbackItem(index) {
  const model = selectInspectorModel(store.getState());
  const item = model.rows[index];
  store.dispatch({ type: 'DRAFT_ITEM_FOCUSED', itemId: item?.itemId || null });
}

function updateSelectedAnnotationComment() {
  const model = selectInspectorModel(store.getState());
  const item = model.rows.find((row) => row.itemId === store.getState().workspace.focusedItemId);
  if (!item) return;
  store.dispatch({ type: 'DRAFT_COMMENT_CHANGED', itemId: item.itemId, comment: comment.value });
}
```

- [ ] **Step 6: Render overlay from canvas model**

In `rendering.js`, change overlay rendering to accept model pins:

```js
function renderSelectionOverlayFromModel(model) {
  const overlay = document.getElementById('selectionOverlay');
  const image = document.getElementById('snapshotImage');
  if (!overlay || !image || !image.naturalWidth || !image.naturalHeight) return;
  overlay.innerHTML = '';
  (model.pins || []).forEach((item, index) => {
    const displayNumber = item.displayNumber == null ? index + 1 : item.displayNumber;
    renderOverlayBox(overlay, image, item.bounds, String(displayNumber), false, false, index, '', severityColor(item.severity || 'med'));
  });
}
```

Call it from canonical canvas render path:

```js
const canvas = selectCanvasModel(store.getState());
renderSelectionOverlayFromModel(canvas);
```

- [ ] **Step 7: Run tests**

Run:

```bash
node --test scripts/consoleCanonicalState-test.mjs scripts/consoleSelectors-test.mjs scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add fixthis-mcp/src/main/console/annotations.js \
  fixthis-mcp/src/main/console/domain/consoleReducer.js \
  fixthis-mcp/src/main/console/domain/consoleSelectors.js \
  fixthis-mcp/src/main/console/rendering.js \
  scripts/consoleCanonicalState-test.mjs \
  scripts/consoleSelectors-test.mjs \
  scripts/consoleCanonicalLegacyRemoval-test.mjs
git commit -m "refactor(console): keep draft annotations in canonical workspace"
```

## Task 9: Prompt, Copy, and Save to MCP Canonicalization

**Files:**
- Modify: `fixthis-mcp/src/main/console/prompt.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleReducer.js`
- Modify: `fixthis-mcp/src/main/console/domain/consoleSelectors.js`
- Modify: `fixthis-mcp/src/main/console/application/consoleEffects.js`
- Test: `scripts/consoleCanonicalWorkflow-test.mjs`
- Test: `scripts/consoleEffects-test.mjs`
- Test: `scripts/consoleCanonicalLegacyRemoval-test.mjs`

- [ ] **Step 1: Add static test for prompt legacy operations**

Add:

```js
test('prompt handlers dispatch canonical handoff commands', () => {
  const prompt = source('prompt.js');
  assert.doesNotMatch(prompt, /persistPendingFeedbackItems\(/);
  assert.doesNotMatch(prompt, /setConsoleSession\(/);
  assert.doesNotMatch(prompt, /resetComposer\(/);
  assert.doesNotMatch(prompt, /clearPreview\(/);
  assert.match(prompt, /store\.dispatch\(\{\s*type:\s*'COPY_PROMPT_CLICKED'/);
  assert.match(prompt, /store\.dispatch\(\{\s*type:\s*'SAVE_TO_MCP_CLICKED'/);
});
```

- [ ] **Step 2: Add workflow test for save source session**

Append:

```js
test('save to MCP uses draft source session and clears draft after success', () => {
  let state = m.createInitialConsoleAppState({ activeSessionId: 'session-a', sessions: [{ sessionId: 'session-a' }] });
  state = m.reduceConsoleAppState(state, { type: 'DRAFT_STARTED_FROM_PREVIEW', sessionId: 'session-a', preview: preview(1) }).state;
  state = m.reduceConsoleAppState(state, {
    type: 'DRAFT_TARGET_SELECTED',
    itemId: 'draft-1',
    selection: { label: 'CTA', bounds: { left: 1, top: 1, right: 10, bottom: 10 } },
    comment: 'Fix CTA',
  }).state;
  const save = m.reduceConsoleAppState(state, { type: 'SAVE_TO_MCP_CLICKED' });
  assert.equal(save.effects[0].kind, 'saveDraft');
  assert.equal(save.effects[0].sessionId, 'session-a');
  assert.equal(save.effects[0].previewId, 'preview-1');

  const done = m.reduceConsoleAppState(save.state, {
    type: 'DRAFT_SAVE_SUCCEEDED',
    requestId: save.effects[0].requestId,
    sessionId: 'session-a',
    workspaceId: save.effects[0].workspaceId,
    generation: save.effects[0].generation,
    session: { sessionId: 'session-a', items: [{ itemId: 'item-1', screenId: 'screen-1' }], screens: [] },
  }).state;

  assert.equal(done.workspace.kind, m.WorkspaceKind.LIVE_PREVIEW);
  assert.equal(done.workspace.sessionId, 'session-a');
});
```

- [ ] **Step 3: Run tests and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalWorkflow-test.mjs scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: FAIL because prompt still runs legacy persistence and reducer save effect lacks full draft context.

- [ ] **Step 4: Update `reduceSaveDraftClicked`**

In `consoleReducer.js`:

```js
function reduceSaveDraftClicked(state) {
  if (!isDraftWorkspace(state.workspace)) return { state, effects: [] };
  const readyItems = state.workspace.items.filter((item) => String(item.comment || '').trim());
  if (!readyItems.length) {
    return {
      state: replaceConsoleState(state, {
        status: Object.freeze({ message: 'Add a comment to at least one annotation before saving.', variant: 'error', assertive: true }),
      }),
      effects: [],
    };
  }
  const generation = nextGeneration(state);
  const targetSessionId = state.pendingBoundary?.targetSessionId || state.activeSessionId;
  return {
    state: replaceConsoleState(state, {
      effectsGeneration: generation,
      promptAction: Object.freeze({ inFlight: true }),
    }),
    effects: [Object.freeze({
      kind: 'saveDraft',
      requestId: nextRequestId('save-draft', generation),
      sessionId: state.workspace.context.sessionId,
      targetSessionId,
      workspaceId: state.workspace.context.workspaceId,
      previewId: state.workspace.context.previewId,
      screenId: state.workspace.context.screenId,
      frozenFingerprint: state.workspace.context.screenFingerprint,
      items: cloneConsoleValue(readyItems),
      generation,
    })],
  };
}
```

- [ ] **Step 5: Replace prompt handlers**

In `prompt.js`:

```js
function copyPrompt() {
  store.dispatch({ type: 'COPY_PROMPT_CLICKED' });
}

function sendAgentPrompt() {
  store.dispatch({ type: 'SAVE_TO_MCP_CLICKED' });
}
```

Move any visible success label into reducer status/effect handling. Do not mutate the button text with independent timers.

- [ ] **Step 6: Add handoff effect if Copy Prompt needs markdown**

If copy prompt must still fetch compact markdown before clipboard write, model it as:

```js
case 'prepareCopyPrompt': {
  const markdown = await ports.sessionApi.handoffPreview(effect.sessionId, effect.itemIds || []);
  await ports.clipboard.writeText(markdown);
  dispatch({ type: 'PROMPT_COPY_SUCCEEDED', requestId: effect.requestId, sessionId: effect.sessionId, workspaceId: effect.workspaceId, generation: effect.generation });
  return;
}
```

Update reducer `reduceCopyPromptClicked` to emit `prepareCopyPrompt` with ready item ids when items are already persisted. For draft-only copy, keep using `saveDraft` first and then a follow-up `prepareCopyPrompt` after `DRAFT_SAVE_SUCCEEDED` if the implementation adds a `pendingAfterSaveAction: "copy"` field to `promptAction`.

- [ ] **Step 7: Run tests**

Run:

```bash
node --test scripts/consoleCanonicalWorkflow-test.mjs scripts/consoleEffects-test.mjs scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add fixthis-mcp/src/main/console/prompt.js \
  fixthis-mcp/src/main/console/domain/consoleReducer.js \
  fixthis-mcp/src/main/console/domain/consoleSelectors.js \
  fixthis-mcp/src/main/console/application/consoleEffects.js \
  scripts/consoleCanonicalWorkflow-test.mjs \
  scripts/consoleEffects-test.mjs \
  scripts/consoleCanonicalLegacyRemoval-test.mjs
git commit -m "refactor(console): run handoff through canonical effects"
```

## Task 10: Remove Legacy State Globals and Compatibility Render Paths

**Files:**
- Modify: `fixthis-mcp/src/main/console/state.js`
- Modify: `fixthis-mcp/src/main/console/main.js`
- Modify: `fixthis-mcp/src/main/console/rendering.js`
- Modify: `fixthis-mcp/src/main/console/annotations.js`
- Modify: `fixthis-mcp/src/main/console/history.js`
- Modify: `fixthis-mcp/src/main/console/preview.js`
- Modify: `fixthis-mcp/src/main/console/prompt.js`
- Test: `scripts/consoleCanonicalLegacyRemoval-test.mjs`
- Test: `scripts/consoleCanonicalRuntimeContract-test.mjs`

- [ ] **Step 1: Extend static test for removed global names**

Add:

```js
test('production console no longer owns removed legacy runtime state', () => {
  const productionFiles = [
    'state.js',
    'main.js',
    'history.js',
    'annotations.js',
    'preview.js',
    'prompt.js',
    'rendering.js',
  ];
  for (const file of productionFiles) {
    const content = source(file);
    assert.doesNotMatch(content, /let\s+draftWorkspace\b/, `${file} must not declare draftWorkspace`);
    assert.doesNotMatch(content, /let\s+draftFlowState\b/, `${file} must not declare draftFlowState`);
    assert.doesNotMatch(content, /let\s+draftPinsState\b/, `${file} must not declare draftPinsState`);
    assert.doesNotMatch(content, /let\s+draftFocusIndexState\b/, `${file} must not declare draftFocusIndexState`);
    assert.doesNotMatch(content, /setConsoleSession\(/, `${file} must not call setConsoleSession`);
    assert.doesNotMatch(content, /setConsolePreview\(/, `${file} must not call setConsolePreview`);
  }
});
```

- [ ] **Step 2: Run test and verify failure**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs
```

Expected: FAIL until legacy variables and setters are removed.

- [ ] **Step 3: Simplify `state.js`**

Replace visible runtime state with constants and boot-only exports:

```js
const DefaultLivePreviewIntervalMs = 1000;
const MinLivePreviewIntervalMs = 1000;
const PreviewIntervalStorageKey = 'fixthis.previewIntervalMs.v2';
const PreviewZoomMin = 0.5;
const PreviewZoomMax = 2;
const PreviewZoomStep = 0.1;
```

Remove `state`, `setConsoleSession`, and `setConsolePreview`.

- [ ] **Step 4: Simplify `main.js` store boot**

Keep:

```js
const canonicalPorts = createBrowserConsolePorts({
  requestJson,
  localStorage,
  navigator,
});
const canonicalRenderer = createBrowserRenderer({
  renderHistory: renderCanonicalHistoryModel,
  renderCanvas: renderCanonicalCanvasModel,
  renderInspector: renderCanonicalInspectorModel,
  renderPrompt: renderCanonicalPromptModel,
  renderBoundary: renderCanonicalBoundaryModel,
  renderDraftLock: renderCanonicalDraftLockModel,
  renderToolbar: renderCanonicalToolbarModel,
});
const store = createConsoleStore({
  render: canonicalRenderer.render,
  onEffects: (effects) => {
    for (const effect of effects) {
      runConsoleEffect(effect, { ports: canonicalPorts, dispatch: store.dispatch }).catch((cause) => {
        store.dispatch({ type: 'CONSOLE_EFFECT_FAILED', effect, error: cause?.message || String(cause) });
      });
    }
  },
});
store.dispatch({ type: 'CONSOLE_BOOTSTRAPPED' });
```

Remove local `draftWorkspace`, `draftFlowState`, `draftPinsState`, and their compatibility accessors.

- [ ] **Step 5: Remove compatibility render calls**

In `rendering.js`, canonical renderers should not call `renderPreviewOnly`, `renderInspectorRegion`, or `renderCurrentSessionList` if those functions still read legacy state. Replace with model-specific renderers:

```js
function renderCanonicalCanvasModel(model) {
  renderCanvasFromModel(model);
}

function renderCanonicalInspectorModel(model) {
  renderInspectorFromModel(model);
}

function renderCanonicalHistoryModel(model) {
  renderHistoryModel(model);
}
```

- [ ] **Step 6: Run static and runtime contract tests**

Run:

```bash
node --test scripts/consoleCanonicalLegacyRemoval-test.mjs scripts/consoleCanonicalRuntimeContract-test.mjs
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add fixthis-mcp/src/main/console/state.js \
  fixthis-mcp/src/main/console/main.js \
  fixthis-mcp/src/main/console/rendering.js \
  fixthis-mcp/src/main/console/annotations.js \
  fixthis-mcp/src/main/console/history.js \
  fixthis-mcp/src/main/console/preview.js \
  fixthis-mcp/src/main/console/prompt.js \
  scripts/consoleCanonicalLegacyRemoval-test.mjs \
  scripts/consoleCanonicalRuntimeContract-test.mjs
git commit -m "refactor(console): remove legacy browser state ownership"
```

## Task 11: Browser Harness Coverage for Three Reported Mismatch Classes

**Files:**
- Modify: `scripts/console-harness.mjs`
- Modify: `scripts/console-harness.test.mjs`
- Modify: `scripts/console-fixture/fakeBridgeServer.mjs`
- Modify: `scripts/console-tests.json`

- [ ] **Step 1: Add harness scenario for annotation add/delete consistency**

In `scripts/console-harness.test.mjs`, add:

```js
test('canonical draft add and delete keeps canvas overlay and inspector aligned', async () => {
  const harness = await createConsoleHarness();
  await harness.open();
  await harness.createSession('session-a');
  await harness.injectPreview('session-a', { previewId: 'preview-a', screenId: 'screen-a' });
  await harness.click('[data-tool="annotate"]');
  await harness.clickCanvasAt(20, 20);
  await harness.type('[data-testid="annotation-comment"]', 'Fix first');
  await harness.clickCanvasAt(40, 40);
  await harness.type('[data-testid="annotation-comment"]', 'Fix second');
  await harness.click('[data-testid="draft-row-1"] [data-delete-current]');

  assert.equal(await harness.count('[data-testid="overlay-pin"]'), 1);
  assert.equal(await harness.count('[data-testid="draft-row"]'), 1);
  assert.match(await harness.text('[data-testid="prompt-readiness"]'), /1 ready/);
});
```

- [ ] **Step 2: Add harness scenario for session boundary**

Add:

```js
test('session switch with draft shows boundary and preserves source preview', async () => {
  const harness = await createConsoleHarness();
  await harness.open();
  await harness.createSession('session-a');
  await harness.createSession('session-b');
  await harness.activateSession('session-a');
  await harness.injectPreview('session-a', { previewId: 'preview-a', screenId: 'screen-a' });
  await harness.click('[data-tool="annotate"]');
  await harness.clickCanvasAt(20, 20);

  await harness.click('[data-session-id="session-b"]');

  assert.equal(await harness.attr('[data-testid="canvas"]', 'data-session-id'), 'session-a');
  assert.match(await harness.text('[data-testid="boundary-sheet"]'), /Save draft/);
  assert.equal(await harness.count('[data-testid="overlay-pin"]'), 1);
});
```

- [ ] **Step 3: Add harness scenario for non-active delete SSE**

Add:

```js
test('non-active session delete update does not rebind active workspace', async () => {
  const harness = await createConsoleHarness();
  await harness.open();
  await harness.createSession('session-a');
  await harness.createSession('session-b');
  await harness.activateSession('session-a');
  await harness.injectPreview('session-a', { previewId: 'preview-a', screenId: 'screen-a' });

  await harness.emitSse('session-updated', { session: { sessionId: 'session-b', status: 'closed', items: [], screens: [] } });

  assert.equal(await harness.attr('[data-testid="canvas"]', 'data-session-id'), 'session-a');
  assert.equal(await harness.attr('[data-testid="history-row-session-a"]', 'data-active'), 'true');
});
```

- [ ] **Step 4: Run harness tests and verify failure**

Run:

```bash
node --test scripts/console-harness.test.mjs
```

Expected: FAIL until harness helpers and UI data attributes are updated.

- [ ] **Step 5: Add stable data attributes in renderers**

In canonical renderers, add:

```html
data-testid="canvas"
data-session-id="<sessionId>"
data-testid="overlay-pin"
data-testid="draft-row"
data-testid="prompt-readiness"
data-testid="boundary-sheet"
data-testid="history-row-<sessionId>"
data-active="true|false"
```

Use escaped values through the existing `escapeHtml` helper.

- [ ] **Step 6: Update harness helpers**

In `scripts/console-harness.mjs`, add helpers:

```js
async function emitSse(name, payload) {
  await page.evaluate(({ name, payload }) => {
    window.__fixThisHarnessEmitSse?.(name, payload);
  }, { name, payload });
}

async function injectPreview(sessionId, preview) {
  await emitSse('preview-ready', {
    sessionId,
    preview: {
      previewId: preview.previewId,
      screen: { screenId: preview.screenId, fingerprint: 'fp-' + preview.screenId, roots: [] },
      screenshotUrl: '/api/preview/' + preview.previewId + '/screenshot/full?sessionId=' + sessionId,
      frozenAtEpochMillis: Date.now(),
    },
  });
}
```

- [ ] **Step 7: Run harness tests and verify pass**

Run:

```bash
node --test scripts/console-harness.test.mjs
```

Expected: PASS.

- [ ] **Step 8: Register harness coverage if needed**

Ensure `scripts/console-tests.json` includes the harness test command already. If it does not, add an entry matching the repository's existing JSON shape for `scripts/console-harness.test.mjs`.

- [ ] **Step 9: Commit**

```bash
git add scripts/console-harness.mjs \
  scripts/console-harness.test.mjs \
  scripts/console-fixture/fakeBridgeServer.mjs \
  scripts/console-tests.json \
  fixthis-mcp/src/main/console/rendering.js
git commit -m "test(console): cover canonical session annotation drift scenarios"
```

## Task 12: Bundle, Full Verification, and Documentation Check

**Files:**
- Modify: `fixthis-mcp/src/main/resources/console/app.js`
- Modify: `fixthis-mcp/src/main/resources/console/app.js.map`
- Modify: `fixthis-mcp/src/main/resources/console/console-build-meta.json`
- Optional modify: `docs/reference/feedback-console-contract.md`

- [ ] **Step 1: Rebuild console assets**

Run:

```bash
node scripts/build-console-assets.mjs
```

Expected: exits 0 and updates console resource bundle files.

- [ ] **Step 2: Run JS console test suite**

Run:

```bash
node scripts/run-console-tests.mjs
```

Expected: PASS for all configured console JS tests.

- [ ] **Step 3: Run targeted Kotlin console tests**

Run:

```bash
./gradlew :fixthis-mcp:test --tests '*Console*RoutesTest*' --tests '*Console*ContractTest*'
```

Expected: PASS.

- [ ] **Step 4: Run asset contract tests**

Run:

```bash
./gradlew :fixthis-mcp:test --tests '*ConsoleAssetContractTest*' --tests '*ConsoleBundleStalenessConsistencyTest*'
```

Expected: PASS.

- [ ] **Step 5: Run status check**

Run:

```bash
git status --short
```

Expected: only intentional source, test, doc, and generated console asset files are modified.

- [ ] **Step 6: Update feedback console contract if behavior text changed**

If the implementation changes user-visible boundary wording or removes documented legacy behavior, update `docs/reference/feedback-console-contract.md` with this exact contract sentence:

```markdown
The browser console uses `ConsoleAppState` as the single visible state owner. Session updates from SSE update cached session details; they do not switch the active workspace unless the update belongs to the active session and the reducer accepts it.
```

Run:

```bash
rg -n "ConsoleAppState|session-updated|Save draft|Keep in recovery" docs/reference/feedback-console-contract.md
```

Expected: the contract text describes canonical state ownership and boundary actions.

- [ ] **Step 7: Commit final verification changes**

```bash
git add fixthis-mcp/src/main/resources/console/app.js \
  fixthis-mcp/src/main/resources/console/app.js.map \
  fixthis-mcp/src/main/resources/console/console-build-meta.json \
  docs/reference/feedback-console-contract.md
git commit -m "chore(console): rebuild canonical state console assets"
```

If `docs/reference/feedback-console-contract.md` did not change, omit it from `git add`.

## Self-Review

### Spec coverage

- Session deletion/close drift is covered by Tasks 2, 5, 6, and 11.
- Session navigation boundary drift is covered by Tasks 2, 6, and 11.
- Annotation add/delete overlay and inspector drift is covered by Tasks 4, 8, and 11.
- Explicit session-scoped preview/navigation/artifact behavior is covered by Tasks 1, 3, 4, and 7.
- Legacy browser state removal is covered by Tasks 5, 6, 7, 8, 9, and 10.
- SSE advisory-only behavior is covered by Tasks 2, 5, and 11.
- Prompt/handoff canonical behavior is covered by Tasks 3 and 9.
- Generated asset verification is covered by Task 12.

### Placeholder scan

This plan intentionally avoids unspecified implementation work. Each task includes concrete file paths, failing tests, implementation snippets, commands, expected outcomes, and commit instructions.

### Type consistency

The plan consistently uses `ConsoleAppState`, `WorkspaceKind`, `workspace.context.sessionId`, `workspace.context.previewId`, `workspace.context.screenId`, `workspace.context.workspaceId`, `effectsGeneration`, `requestId`, and `generation`. Effect names in reducer snippets match the effect runner snippets: `openSession`, `deleteSession`, `capturePreview`, `navigate`, `saveDraft`, `saveHandoff`, `persistRecovery`, and `deleteRecovery`.
