# Inbox Composer Mode Design

## Context

Inbox already knows a selected row's canonical conversation kind before
navigation, so composer semantics are decidable at click time. Before this
change, each destination route instead re-derived composer presence from
asynchronous detail data (channel membership, the authoritative DM, the
thread parent message), and the leaf coupled presence with send
capability — so the composer arrived after the rest of the conversation frame
and "composable" had several incompatible definitions. This document specifies
the contract that replaced them.

## Goals

1. Determine the composer slot synchronously from Inbox conversation semantics.
2. Model composer presence, editor availability, and send availability as
   separate facts.
3. Replace route-local boolean gates with one finite-state contract.
4. Keep one stable composer slot from the first frame for every conversational
   surface.
5. Keep Service Account feeds and the Approval system surface non-interactive
   from the first frame.
6. Make pending, read-only, join-required, and unavailable states explicit and
   explain them to the user.
7. Remove old condition chains rather than layering more conditions over them.
8. Make composer presence a zero-network decision even when cached messages
   and cold capability queries resolve at different times.

## Non-goals

- Moving UI presentation policy into backend DTOs.
- Changing message write authorization on the server.
- Making Service Account feeds or Approval notifications writable.
- Replacing the existing message, thread, or task composer implementations.

## Domain model

### Interaction model

The interaction model is synchronous presentation semantics derived once at
the Inbox row/descriptor boundary.

```ts
export type ConversationInteractionModel =
  | "conversation"
  | "non_interactive"
```

Mapping:

| Inbox kind | Interaction model |
| --- | --- |
| `channel` | `conversation` |
| `humanDm` | `conversation` |
| `agentDm` | `conversation` |
| ordinary `serviceDm` | `conversation` |
| `replyThread` | `conversation` |
| `task` | `conversation` |
| `serviceAccount` | `non_interactive` |
| `serviceDm` with service slug `agentteam-approval` | `non_interactive` |

The Approval distinction uses the stable service identity already carried in
the conversation projection, not translated display text.

### Capability evidence

Asynchronous queries provide capability evidence. They do not decide whether a
conversation owns a composer slot.

```ts
export type ConversationComposerReason =
  | "resolving_access"
  | "read_only"
  | "archived"
  | "forbidden"
  | "not_found"
  | "error"

export type ConversationComposerCapability =
  | { kind: "pending" }
  | { kind: "enabled" }
  | { kind: "disabled"; reason: ConversationComposerReason }
  | { kind: "join_required"; channelName: string }
  | { kind: "unavailable"; reason: ConversationComposerReason }
```

For the same scope, a transient refresh must not replace a definitive
capability with `pending`. New definitive evidence may still change
`enabled`, `disabled`, `join_required`, or `unavailable`.

### Render mode

A pure resolver combines the two axes:

```ts
export type ConversationComposerMode =
  | { kind: "absent" }
  | ConversationComposerCapability
```

The `kind` alone determines editor and send availability: `pending` keeps the
editor draftable with send disabled; `enabled` turns both on;
`disabled`/`unavailable` lock both; `join_required` replaces the slot with the
Join CTA. `non_interactive` always resolves to `absent`.

Reason values are stable machine codes. The leaf renderer owns localization,
inline copy, tooltip text, and accessible descriptions; routes must not pass
translated UI strings into policy.

## Rendering contract

- `absent`: do not render a composer slot. This is limited to Service Account
  and Approval surfaces.
- `pending`: render the ordinary editor immediately. Drafting remains
  available; the send action is disabled and exposes the pending reason.
- `enabled`: render the ordinary enabled composer.
- `disabled`: keep the composer slot and editor visible, disable interaction,
  and expose the reason through inline text and accessible tooltip/description.
- `join_required`: replace the stable composer slot with the existing Join CTA.
- `unavailable`: keep the slot and render an explicit unavailable/read-only
  state instead of navigating away or leaving blank space.

The leaf composer must not derive presence from `canSend`. Editor disabling and
send-action disabling are separate props.

## Data flow

1. `ConversationItem` is selected.
2. `conversationPresentationFromItem()` derives and stores
   `interactionModel` with the existing route presentation descriptor.
3. The destination route renders from that descriptor on its first frame and
   supplies `pending` capability if no authoritative evidence is available.
4. Channel, DM, and reply-thread queries publish capability evidence only.
5. `resolveConversationComposerMode()` produces the render state.
6. The leaf message composer renders the state without route-specific
   policy.

Message, identity, membership, and parent-message queries may
revalidate in the background. Their loading state must not gate composer
mounting. Query freshness remains a security/data-consistency decision:
`refetchOnMount: "always"` may remain where timely access revocation requires
it, provided it does not block the first-frame mode.

Direct URLs use route-derived conversation semantics where possible. Until
identity is resolved, a direct conversational URL uses `conversation +
pending`; it must not default to an absent slot.

## Route evidence

- Channel: `enabled` when joined; `join_required` for a known joinable channel;
  `disabled` for archived/read-only membership; `unavailable` for explicit
  inaccessible/not-found evidence.
- DM: `enabled` for an authoritative accessible DM; `unavailable` for explicit
  403/404. Revocation must purge inaccessible message/identity cache before
  showing the in-place unavailable state; remaining on the route must not keep
  sensitive cached content visible.
- Reply thread: `pending` until parent access is known; `enabled` when valid;
  `disabled` or `unavailable` when explicitly read-only/inaccessible.
- Task surfaces keep their dedicated discussion composer, which is never
  presence-gated by this contract (out of scope).
- Service Account/Approval: `absent` synchronously and permanently.

## Testing

Coverage that exists today:

1. Pure policy matrix for all seven Inbox kinds plus the Approval service,
   including the capability transition rules and the six-state
   message-timeline mapping.
2. Route evidence table tests for Channel and DM facts.
3. Render-probe behavior tests for `useScopeStableComposerCapability`:
   content-equal evidence rebuilds cannot agentteam, definitive replacements
   apply on their first render, and scope switches start fresh.
4. Leaf DOM matrix: pending renders a draftable composer with send
   blocked; disabled/unavailable keep the slot with the editor locked;
   absent renders no slot.
5. Cold→settled seam test (manual createRoot): pending→enabled keeps the
   composer form/editor DOM identity, the draft DOM text, and focus, with
   the editor remaining enabled. A source assertion guards the
   send-capability wiring (`canSubmit && composerSendEnabled`); real-typing
   send behavior is not DOM-asserted under happy-dom.
6. Message-timeline query-boundary behavior tests through the real
   `useThreadScroll` hook.

Route-level cold-frame DOM tests for the full ChannelRoute/DMChannelRoute
mounts and revocation (403/404) purge behavior are covered at the
evidence/hook/leaf seams above rather than by full-route harnesses.

## Acceptance criteria

- Routes map facts through the shared evidence mappers; none re-derives
  composer presence from a readiness boolean.
- `ConversationMessages` does not couple send capability with composer
  presence; its hook inputs are named for the facts they gate
  (`timelineEnabled`, `editorEnabled`).
- Channel, DM, and Reply Thread consume the shared mode contract. Task
  discussion keeps its dedicated composer (out of scope).
- The Channel cold frame and terminal access states stay inside the same
  stable surface when a presentation identity exists; only no-identity
  deep links render full-page panes.
