# Design & Migration Plan — GENC-1390 (Interaction-widget lifecycle API)

> Status: **IMPLEMENTED (platform)** 2026-07-20 — verified: package build + 268 unit tests + lint
> green; branch build `@genesislcap 14.491.0-GENC-1390.1` published. Delivered as a **clean break**
> (no deprecated-getters transition — see below): the flat `data`/`resolved`/`shouldAutoFocus`
> surface was removed outright. The platform's own showcase client-app (9 widgets) is migrated.
> Consuming-app (`genesis-create`) widget migration is tracked separately in
> `genesis-create/data/genc-1390/GENC-1390-WIDGET-UPGRADE-PLAN.md` (in progress). Both surfaces here
> (`AiChatWidget`, `AiDriver`, interaction types) are `@beta`; these are deliberate breaking changes.
>
> NOTE: the "Soft-migration path (deprecated getters)" section below was NOT taken — we broke cleanly.
> Consuming widgets migrate by reading `this.interaction` (or a local `@volatile get data()` +
> `interactionChanged`-synced `resolved`), not via host-set legacy fields.

## Summary

Interaction widgets (`planning-question`, `sample-data-review`, `sample-data-generation`, the
progress cards, …) are **destroyed and recreated** — remounted — as a normal fact of life:

1. **Layout reflow.** `@genesislcap/foundation-layout` (GoldenLayout) rebuilds/reparents its
   content tree on panel add/remove **and on drag**. A moved custom element fires
   `disconnectedCallback` → `connectedCallback` — a full remount. This is not fixable at the
   layout API (drag reparents regardless), and a future move to a React shell + a
   side-effect-free layout lib is far off.
2. **Transcript re-render.** The chat transcript renders interactions through a **keyless**,
   index-reconciled FAST `repeat`; on any `history-updated` append (or a cog-menu filter
   toggle) the recycled `ai-chat-interaction-wrapper` rebinds and `renderComponent()` tears
   down and re-`createElement`s the inner widget.

Because remounts are permanent, the fix is **not** to prevent them — it is to make widgets
**robust to** them. Today each widget hand-rolls that robustness (module-level "session"
singletons keyed by a stable id, open-once guards, a one-rAF socket-open defer, a
`normalize()` shim). Those work but are ad-hoc, inconsistent, and easy to get wrong — a
missed open-once guard is exactly the `RangeError: Maximum call stack size exceeded` recursion
in GENC-1368.

This plan adds the two primitives the interface is missing, so the robustness is provided
once, uniformly:

- **A discriminated `AiChatInteraction` descriptor** replacing the flat
  `data` / `resolved` / `shouldAutoFocus` triple, so illegal states (a resolved widget that
  still has `shouldAutoFocus`, a pending widget read as resolved) are unrepresentable.
- **An `InteractionContext`** — a live, per-interaction object owned by the driver (not the
  DOM), keyed by `interactionId`, that survives remounts and is disposed on **resolve**, not
  on unmount. It is where a widget keeps anything that must outlive its own element.

Neither the double-mount fix (GENC-1410) nor the single-element popout (GENC-1351) addressed
this: those collapsed *two* instances to *one*, but moving one element is still a
disconnect+reconnect. In fact GENC-1351 had to *add* an `if (this.resolved) return;` guard to
several widgets — evidence the underlying remount was never the thing being fixed.

---

## Part 1 — `AiChatInteraction`: model the widget's state as a discriminated union

### The problem with today's type

`src/types/ai-chat-widget.ts`:

```ts
export interface AiChatWidget {
  data: unknown;
  resolved?: InteractionResult<unknown>;
  shouldAutoFocus?: boolean;
}
```

This implies all combinations are legal. They are not. A widget is only ever in one of two
states:

- **pending** — has `data`, a meaningful `shouldAutoFocus`, and a live `interactionContext`;
  no result yet.
- **resolved** — has `data` and a `result`; `shouldAutoFocus` is meaningless (a history
  re-render must never steal focus) and there is **no** live `interactionContext` (it is
  disposed *on* resolve).

`data` is present in both (resolved templates still read `data.handlerName` etc.);
`interactionId` is common to both; `interactionContext` falls out as pending-only, which
matches its lifetime exactly.

### Why not make `AiChatWidget` itself a union

TypeScript does not allow `class Foo … implements <unionType>`. Every widget is
`class X extends GenesisElement implements AiChatWidget`, so a union interface would break the
one thing they all rely on. The clean move is to push the discriminant into a **field the
widget holds** — a single `interaction` descriptor that *is* the union — and keep the
interface a plain object.

### Proposed types

`@genesislcap/ai-assistant` — `src/types/ai-chat-widget.ts`:

```ts
import type { InteractionResult } from '@genesislcap/foundation-ai';
import type { InteractionContext } from './interaction-context';

/**
 * The two mutually-exclusive states an interaction widget is ever in. The host
 * sets `interaction` on mount and REPLACES it (pending → resolved) on completion.
 *
 * @beta
 */
export type AiChatInteraction<TData = unknown, TResult = unknown> =
  | {
      status: 'pending';
      /** Immutable inputs for the interaction. */
      data: TData;
      /** Grab focus on mount? Meaningful ONLY while pending. */
      shouldAutoFocus: boolean;
      /** Live, remount-surviving scratch. Exists ONLY while pending — disposed on resolve. */
      interactionContext: InteractionContext;
    }
  | {
      status: 'resolved';
      /** Same inputs, still needed for the resolved display. */
      data: TData;
      /** The outcome. */
      result: InteractionResult<TResult>;
    };

/**
 * Interface an AI inline interaction component implements.
 * The `AiChatInteractionWrapper` sets `interactionId` and `interaction` on the element.
 *
 * @beta
 */
export interface AiChatWidget<TData = unknown, TResult = unknown> {
  /** Stable per-interaction UUID; constant across remounts, present in both states. */
  interactionId?: string;
  interaction: AiChatInteraction<TData, TResult>;
}
```

Widgets then narrow instead of guessing:

```ts
const i = this.interaction;
if (i.status === 'resolved') {
  return this.renderResult(i.result);        // `interactionContext` doesn't exist here
}
i.interactionContext.once('open-panel', …);  // `result` doesn't exist here
```

Bonus: `data` regains real per-widget typing (`implements AiChatWidget<PlanningQuestionData, { answer: string }>`),
which retires most of the reason `planning-question`'s `normalize()` shim exists.

> The persisted model type `ChatInteraction` (`@genesislcap/foundation-ai`,
> `src/types/chat.types.ts`) has the same flat shape. We do **not** change the persisted
> format (that would break session restore). The wrapper projects `ChatInteraction` →
> `AiChatInteraction` at the render boundary. The on-disk shape stays `{ interactionId,
> componentName, data, resolved?, presentation? }`.

---

## Part 2 — `InteractionContext`: the live, remount-surviving primitive

### Shape

`@genesislcap/ai-assistant` — new file `src/types/interaction-context.ts`:

```ts
/**
 * A live, per-interaction scratch space owned by the chat host (keyed by
 * `interactionId`) and handed to a widget on every (re)mount.
 *
 * WHY: a widget element is destroyed+recreated on layout reflow (add/remove AND drag),
 * dock/undock, and transcript re-render. The element cannot hold anything live across
 * that; this object can, because the host keeps it alive for the interaction's lifetime,
 * independent of the element.
 *
 * LIVE-ONLY — never persisted. A reload / project switch / session restore wipes it; the
 * interaction is then rebuilt purely from the serialisable `data` + `result`. Never store
 * here anything that must survive a reload: durable inputs go in `data`, the outcome in the
 * resolved payload.
 *
 * LIFETIME — created lazily on first render of an interaction, reused for every remount of
 * that same `interactionId`. Torn down (its `onDispose` callbacks fire once) when the
 * interaction RESOLVES or is evicted — NOT when the element unmounts. That inversion is the
 * point: a transient unmount (drag, re-render) must leave context-owned resources (sockets,
 * timers) untouched; only completion ends them.
 *
 * @beta
 */
export interface InteractionContext<TState extends object = Record<string, unknown>> {
  /** Stable id of the interaction this context belongs to. */
  readonly interactionId: string;

  /** Durable, remount-surviving source of truth (a socket handle, the current phase, a draft). */
  readonly state: TState;

  /**
   * Run `factory` at most once for this interaction's lifetime and memoise the result;
   * every later call with the same `key` (including after a remount) returns the stored
   * value. Covers one-shot side effects and create-once live resources alike.
   *
   *   ctx.once('open-panel', () => this.dispatchEvent(openPanelEvent)); // fires once, ever
   *   const ws = ctx.once('ws', () => new TestDataWebSocketService());  // same instance each remount
   *
   * Returns `null` (WITHOUT running `factory`) once the interaction has ENDED — after the context
   * is disposed (resolve/timeout/evict), and on the inert context the wrapper substitutes when the
   * driver has no live context for the id (see the wrapper note below). That is what makes `once` a
   * safe open-once guard for the widget's WHOLE lifetime, not just while pending: a remount that
   * briefly re-enters the pending path after resolve gets `null`, never a re-fire.
   */
  once<T>(key: string, factory: () => T): T | null;

  /** Signal that `state` changed → subscribers re-read. NOT a renderer; just a change ping. */
  notify(): void;

  /** Observe changes (subscribe in connectedCallback); returns unsubscribe (call in disconnectedCallback). */
  subscribe(listener: () => void): () => void;

  /** Register teardown run once when the interaction ends (resolve/evict). Home for closing sockets/timers. */
  onDispose(fn: () => void): void;
}
```

### Not tied to FAST

`subscribe`/`notify` are a plain observer list (a `Set<listener>` + `forEach`), not a
reactivity engine and not a FAST dependency. `notify()` does not render anything — it tells
the currently-mounted element "state changed, re-read", and the element updates its own
observables and re-renders with whatever primitives it has. This keeps the primitive
framework-agnostic for the apps moving off FAST. The implementation is ~15 lines; it mirrors
the existing `createInteractionPanelSession` in the consuming app, which this replaces.

### Where the state lives (verified against source)

Two DOM-independent homes, split along the two remount classes:

| Data | Home | Survives remount? | Survives reload? |
| --- | --- | --- | --- |
| `data`, `resolved` (serialisable) | Redux session store (`store.aiAssistant.messages`) | yes | yes (persisted) |
| `InteractionContext` (live: sockets, once-cache, subscribers, dispose fns) | `ChatDriver` (via the module `driver-registry`) | yes | no (correct) |

`ChatDriver extends EventTarget` (`src/components/chat-driver/chat-driver.ts:199`) — a plain
object, never in the DOM, no connect/disconnect. It is obtained from a **module-level**
`Map<sessionKey, driver>` in `src/state/driver-registry.ts` (`getOrCreateDriver`), created once
per session key and only dropped by `deleteDriver` / `clearDriverRegistry` (agent-swap /
session teardown) or a page reload. The element merely holds a *reference* it drops on
disconnect — the driver lives on. So the driver is the correct home for live per-interaction
state, exactly as it already is for `pendingInteractions`.

### Wiring changes

**`ChatDriver` (`src/components/chat-driver/chat-driver.ts`)** — add an
`interactionContexts` map alongside `pendingInteractions` (:204). At most one is live at a time
(the driver already throws if a second interaction is requested while one is pending, :1210),
but a `Map` keyed by `interactionId` keeps the shape general.

- New: `getInteractionContext(interactionId): InteractionContext | undefined` — returns the
  live context for a pending interaction, or `undefined` for a resolved/evicted one.
- `requestInteraction` (:1202) — when it appends the interaction to history (:1257), also
  create + register the context for `interactionId`.
- `resolveInteraction` (:1274) — after settling `pendingInteractions` (:1323), run the
  context's `onDispose` callbacks and delete it from `interactionContexts`. This is the
  dispose-on-resolve inversion.
- `dispose()` (:553) / `cancelPendingInteractions` (:1337) — drop all contexts (each already
  routes cancellation through `resolveInteraction`, so disposal falls out for free).
- Sub-agents delegate up via `hostInteractionRequester` (:1207), so contexts are always
  created on the host driver where `pendingInteractions` lives — no child-driver contexts.

**`AiChatInteractionWrapper` (`src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts`)** —
`renderComponent()` (:114) currently sets `element.data`, `element.resolved`,
`element.shouldAutoFocus` (:156-162). Replace with a single `element.interaction` built from
the wrapper's `interactionId` + `data` + `resolved`, plus `element.interactionId`:

- If `this.resolved` → `{ status: 'resolved', data, result: this.resolved }`.
- Else → `{ status: 'pending', data, shouldAutoFocus: !this.resolved && !!host?.isEngaged,
  interactionContext: driver.getInteractionContext(this.interactionId)! }`.

  The wrapper reaches the driver the same way it reaches the host today (across its shadow
  boundary, :134-139). The `§5.11c` TODO block (:141-152) is resolved by this change and its
  comment should be removed.

  The `!` on `getInteractionContext(this.interactionId)!` is optimistic: the driver creates the
  context before the widget mounts, so it exists for a genuinely-pending interaction. But at the
  resolve boundary the driver disposes + drops the context while the wrapper's `resolved` binding
  can still lag a render behind, so the pending branch can run with NO live context. The fallback
  for that case must be an **inert** context — created then disposed up front, so `once` refuses —
  NOT a live throwaway. A live throwaway re-fires a widget's open-once side effect on the
  post-resolve remount; for a widget that opens a side panel this re-opens the just-closed panel,
  whose layout rebuild re-mounts the widget, which re-opens it… a tab-locking loop (GENC-1390). "No
  live context" reliably means "not a live pending interaction" (resolved/evicted, or no host at
  all — e.g. a unit test), and in none of those cases may a `once`-guarded effect run.

**`AiDriver` interface (`src/components/ai-driver/ai-driver.ts`)** — add
`getInteractionContext(interactionId: string): InteractionContext | undefined`. Custom driver
implementations must provide it (see breaking changes).

---

## How a consuming app navigates the breaking changes

### Who is affected

- **Hosts that only embed `<foundation-ai-assistant>`** (with/without the bubble): unaffected.
- **Widget authors** (anything `implements AiChatWidget`): affected — the property surface
  changes from `data`/`resolved`/`shouldAutoFocus` to `interactionId`/`interaction`.
- **Custom `AiDriver` implementations**: affected — must implement `getInteractionContext`.

### Soft-migration path (deprecated getters)

To avoid a big-bang rewrite, ship a base mixin (or documented getters) that re-derive the old
flat props from `interaction`, so existing widgets compile unchanged on day one:

```ts
// transitional — delete once every widget reads `this.interaction`
get data() { return this.interaction.data; }
/** @deprecated read `this.interaction` */
get resolved() {
  return this.interaction.status === 'resolved' ? this.interaction.result : undefined;
}
/** @deprecated */
get shouldAutoFocus() {
  return this.interaction.status === 'pending' && this.interaction.shouldAutoFocus;
}
```

Sequence: (1) land the wrapper/driver changes so `element.interaction` is set and the getters
delegate; (2) migrate widgets off the getters one at a time; (3) delete the getters.

### Before / After per widget pattern

**A panel-opening bubble** — replace the module-level open-once + stable-id session with
`once`. The recursion is structurally impossible because `once` is idempotent across remounts.

Before:
```ts
private started = false;
connectedCallback() {
  super.connectedCallback();
  if (this.resolved) return;                 // KEEP — spec
  if (!this.started) { this.started = true; void this.openPanel(); } // hand-rolled once
}
private async openPanel() {
  const sessionId = this.data.sessionId || createSessionId();  // stable-id threading
  if (!isComplete(sessionId) && markOpened(sessionId)) {       // module-level open-once
    this.dispatchEvent(new CustomEvent('layout-add-component', { /* … */ }));
  }
  this.resolved = await waitFor(sessionId);
  this.$emit('interaction-completed', this.resolved);
}
```

After:
```ts
connectedCallback() {
  super.connectedCallback();
  const i = this.interaction;
  if (i.status === 'resolved') return;        // KEEP — spec
  i.interactionContext.once('open-panel', () =>
    this.dispatchEvent(new CustomEvent('layout-add-component', { /* … */ })));
  // result still arrives over the bubble↔panel channel; resolve as before
}
```

**A live-stream widget** (WebSocket/SSE) — own the stream on the context; dispose on resolve,
never on unmount; keep progress on `state`; re-derive the view on `subscribe`.

Before:
```ts
connectedCallback() {
  super.connectedCallback();
  if (this.resolved) return;
  requestAnimationFrame(() => {              // rAF defer — guards a transient mount→unmount
    if (!this.isConnected || this.resolved) return;
    this.start();                            // opens a per-instance socket
  });
}
disconnectedCallback() {
  this.ws.close();                           // WRONG: a transient unmount kills an in-flight stream
  super.disconnectedCallback();
}
```

After:
```ts
connectedCallback() {
  super.connectedCallback();
  const i = this.interaction;
  if (i.status === 'resolved') return;
  const ctx = i.interactionContext;
  this.applyFromState(ctx.state);                                 // seed view from durable state
  this._unsub = ctx.subscribe(() => this.applyFromState(ctx.state));
  ctx.once('stream', () => this.openStream(ctx));                 // opened once; handlers write ctx.state + ctx.notify()
  ctx.onDispose(() => (ctx.state.stream as StreamHandle)?.close()); // closed on RESOLVE, not unmount
}
disconnectedCallback() {
  this._unsub?.();                            // symmetric; no stream teardown here
  super.disconnectedCallback();
}
```
The stream handlers close over the stable `ctx`, never `this`, so there is no dead-instance
write. The one-rAF defer is deleted — the transient churn it guarded is gone (a transient
unmount no longer opens or closes anything).

**A draft-input widget** — keep the half-typed value on `state` so a remount recovers it;
leave pure view ephemera (focus, textarea auto-grow height) on the element (it *should* reset).

**An auto-resolve / display-only widget** — no behavioural change. Read `this.interaction`
instead of `this.data`/`this.resolved`; the `if (i.status === 'resolved') return;` guard is
the same spec guard as today.

### Custom `AiDriver`

Implement `getInteractionContext(interactionId)`. If your driver has no interactions, return
`undefined`. (`registerHost`/`claimSideEffect` were already removed in GENC-1351.)

---

## Non-goals / explicitly deferred

- **No change to `@genesislcap/foundation-layout`.** Making add/remove surgical would not
  survive a drag, so remount-prevention is out of scope. This plan makes remounts *harmless*.
- **No keyed transcript `repeat`.** Giving the transcript stable per-`interactionId` identity
  would remove the transcript-re-render remount, but FAST `repeat` has no key function; that
  is a separate (larger) change and is not required — widgets are remount-robust either way.
  Once done it would let more `resolved` guards go, but that is a follow-up.
- **No change to the persisted session format.** `ChatInteraction` on disk is unchanged; the
  descriptor is a render-boundary projection.
- **Bubble↔side-panel channel** (`interaction-panel-session` in the app) stays as its own
  cross-element pub/sub — the side panel is a layout component, not a chat widget, so it gets
  no `interactionContext`. Not a platform concern. (The Create plan opts to re-key that channel
  on `interactionId` to retire its hand-minted `sessionId`, but that is app-side tidy-up.)

---

## Quick reference checklist (platform)

- [ ] Add `src/types/interaction-context.ts` (`InteractionContext`).
- [ ] Rewrite `src/types/ai-chat-widget.ts` → `AiChatInteraction` union + `AiChatWidget`
      (`interactionId` + `interaction`), generic over `TData`/`TResult`.
- [ ] `ChatDriver`: add `interactionContexts` map + `getInteractionContext`; create in
      `requestInteraction`; dispose+delete in `resolveInteraction`; drop-all in `dispose`.
- [ ] `AiDriver` interface: add `getInteractionContext`.
- [ ] `AiChatInteractionWrapper.renderComponent`: set `element.interactionId` +
      `element.interaction` (projected from `data`/`resolved`); delete the `§5.11c` TODO.
- [ ] Provide the transitional deprecated getters (base mixin) for a phased app migration.
- [ ] Update `chat-interaction-wrapper.test.ts` + `chat-driver.test.ts`.
- [ ] Bump `@beta` surface; note in the package changelog.
