AGENTS.md@packages/client/src/components/chat · git:20261028.82b767f · 2026-10-28 · sha256 db5843462faf5300

AGENTS.md@packages/client/src/components/chat git:20261028.82b767fA

Immutable. This exact content never changes and is served at /api/v1/blob/db5843462faf5300.

# DOX — packages/client/src/components/chat

Files in this directory. One row per source file.

| File | Purpose |
|------|---------|
| `BashOutputCard.tsx` | Renders `!`/`!!`/slash-exec bash output card (command header, exit badge, output pre). → see `BashOutputCard.tsx.AGENTS.md` |
| `ChatEnter.tsx` | `<ChatEnter entering …div props>`: a `div` that adds `chat-motion-enter` iff `entering` was true at THIS instance's first render (latched `useState`); false → plain `div`. Wraps pending card, steering cards, streaming answer, live reasoning in `ChatView`. See change: add-fluid-chat-motion. |
| `ChatView.tsx` | → see `ChatView.tsx.AGENTS.md` Merges a default `fileLink` into `toolContext` once (`useMemo`) and passes it to ALL consumers incl. `ToolBurstGroup`/`CollapsedToolGroup`. See change: cleanup-import-cycles. Third pending-prompt render arm for `status=="failed"` (`data-testid="pending-prompt-failed"`, text preserved, never the emerald tick). See change: fix-optimistic-prompt-stuck-sending. Owns D7's auto-load trigger. ONE `programmaticScrollUntilRef` stamp (`SETTLE_MS` 120) is taken by EVERY programmatic scroll writer — `onChange` bottom-pin + ascent re-issue, `scrollToBottom`, `scrollToTop`, restore + its offset rAF, restore fallback, near-bottom fallback, streaming bottom-pin, the selection compensator, and `scrollToTurn` — rather than a per-writer ref list, because enumerating writers is how the previous revision failed. `handleScroll` only RECORDS (intent + restart timer) and never evaluates; evaluation runs at the settle timer's expiry, so a momentum stream evaluates once. A suppressed evaluation is DEFERRED, not consumed. `scrollToTop` sets intent because activation IS intent. Intent is cleared on issue, at mount, and on session change. Also hosts the `history-gap-live-region` (`aria-live="polite"`), OUTSIDE the virtualized list, and scopes it to AUTOMATIC loads via `autoLoadEarlier`/`lastLoadWasAutoRef`; the announced count is the RENDERED row delta, not the response event count. See change: add-tail-only-replay-window (D7, D7a). Adds the `role:"custom"` render branch (`CustomEntryRow`, gated per-group in BOTH `isRowVisible` and the branch — `prefs.customEventGroups[msg.groupId ?? "other"]`, render-time gate, rows stay in state) and threads `prefs.reasoningInlineFlow` into all ThinkingBlock mounts. See changes: render-inline-reasoning-and-custom-entries, add-custom-event-group-filters. Participates in below-floor allocation with declared floor 64px, bound 16px, weight 3. See change: define-chat-pane-below-floor-allocation. Adds prop `retainedTranscript?: "complete"\|"incomplete"\|"absent"`; ONLY `incomplete` renders — a `retained-transcript-incomplete` notice (`role=status`, warning tokens, i18n `session.retainedTranscriptIncomplete`) ABOVE the empty state and regardless of message count, because a partial transfer that delivered messages is the case that looks complete. `absent` keeps the ordinary empty state. See change: serve-retained-remote-transcripts (task 2.2).  `InlineTerminalCard` lazy behind a card-local `Suspense`. See change: add-lazy-terminal-diff-bootstrap. Lazy `InlineTerminalCard` wrapped in `ErrorBoundary` + `Suspense` (card-height fallback; failed import contained). Delegates the `role:"custom"` render branch to `<CustomEntryRow msg sessionId>` (the shared container — plugin claim → fallback, own per-session group gate) and threads a registry-backed `isTransparentCustomType` predicate into `groupToolBursts` (claimed custom rows stop splitting bursts; memoized on the claim-list identity). See change: add-custom-entry-renderer-slot. |
| `ChatViewMenu.tsx` | Discord-style ⚙ View popover mounted in chat toolbar. Edits per-session `displayPrefsOverride` via… → see `ChatViewMenu.tsx.AGENTS.md` Adds the `reasoningInlineFlow` row and — replacing the removed single `customEntryFallback` toggle — one row per configured custom event group (labels from `useCustomEventGroups()`, configured order, `other` last; absent id falls to the group's configured default; per-group `isCustomGroupOverridden` dot; patch merges `customEventGroups` field-by-field). See changes: render-inline-reasoning-and-custom-entries, add-custom-event-group-filters. Mounted in the session header after the layout switch; prop `iconOnly` = mobile 44 px trigger, label hides below 760 px container. See change: reorganize-session-header. |
| `collapse-summary.tsx` | Shared PROCESS-subcard summary primitives: `splitOverflow<T>` (pure head/overflow-tail sort-then-slice split) + `<CollapseSummary>` (chevron toggle button owning `aria-expanded`+click→onToggle). See change: stable-process-line. |
| `CollapsedToolGroup.tsx` | Renders collapsed group of repeated tool calls. Exports `CollapsedToolGroup`. → see `CollapsedToolGroup.tsx.AGENTS.md` Absorbed `role:"custom"` rows render through `<CustomEntryRow>`; when the per-tool gate empties the group, a visible absorbed custom row still renders instead of `return null`. See change: add-custom-entry-renderer-slot. |
| `CommandFeedbackCard.tsx` | Inline card showing slash-command execution feedback. Exports `CommandFeedbackCard`. Status map `started`/`completed`/`error` → icon + color + label. Shows `message` only on `error`. |
| `CommandInput.tsx` | Chat composer textarea + autocomplete. Exports `CommandInput`, `parseViewCommand`, `shouldWalkFileQuery`,… → see `CommandInput.tsx.AGENTS.md` `pendingPrompt` prop is now the status union, not a boolean; only `"sending"` gates the composer. See change: fix-optimistic-prompt-stuck-sending. New `modelRefreshErrors?` prop forwarded to `ModelSelector.refreshErrors`. See change: upgrade-model-selector-primitives. Below-floor allocation lower bound 72px and shrink. See change: define-chat-pane-below-floor-allocation. Change: Model chip uses `useRolePick` (`allowRoles`, one-shot resolve, `via @role`, `role-pick-notice`). See change: add-role-aware-model-refs. Role-pick notice renders via `RolePickNoticeCallout`. See change: retro-ux-model-selector. Spawn/sessionless extras: `commandsOrigin` (`scan` → `command-dropdown-origin` "from disk" footer), `sendLabel`, `modelTag`/`thinkingTag`, `unsetLabel` (`pi default`), `toolbarExtra` (settings-row slot, e.g. run-location). See change: redesign-directory-home-split. |
| `ChangesLane.tsx` | `useChangesLane({session,working,allSessions,onShutdownSession,mergeIsPrimary,onReview,onOpenFile,onOpenDiff,onShowOtherChanges})` → Changes `DockLane` or `null`. Lane for a worktree session or `diff.count>0`. Worktree header = `GitIdentity` + `N files +A −D`; main checkout `N files changed`; `200+` at the server cap; count only without totals. Actions (siblings): Review, `WorktreeActionsMenu` (segments). Body mounted on expand (refetches under the coalescing rule): file rows via `ComposerDockRow` with signed deltas, `via bash`, in-cwd targets only: activatable file name + `Open` (`composer-dock-file-open`, `onOpenFile(rel)`) + `Diff` (`composer-dock-file-diff`, `onOpenDiff(rel)`), none for out-of-cwd or without handlers; Open/Diff/Review call `useDockPeek().endPeek()`; `N other changes` footer, inline `Couldn't refresh changes` + Retry. `testId` `composer-git-container`. See change: add-composer-dock (4.1, 4.2). See change: composer-dock-collapse-and-file-open. |
| `ContextLane.tsx` | `useContextLane({session,working})` → Context `DockLane` or `null`. Hosts `ComposerContextGroupSlot` (never working-gated) + Status `ToolbarGroup` (`fieldset disabled={working}` around `SessionCardBadgeSlot`) in `actions`; not expandable; `hideWhenEmpty`. See change: add-composer-dock (6.2). |
| `composer-dock-model.ts` | Lane model. Exports `DockLane` `{key,label,icon,title,meta?,summary,summaryText,actions?,renderBody?,expandable,announce?,testId?,hideWhenEmpty?}`, `DockLaneKey`, `DOCK_LANE_ORDER` (processes, changes, openspec, queue, context), `orderLanes`, `dockLaneLabel`, `useDockAccordion(bindKey)` (one `openLane`, reset to `null` on bind change, derived in render). `renderBody` is lazy: called only while the body shows. `testId` overrides the lane group's test id (kept `composer-git-container`, `composer-openspec-container`). `hideWhenEmpty` (row layout) hides the lane, and the whole dock when every lane is hidden, while its `actions` render nothing visible (claims returning `null`). See change: add-composer-dock. |
| `ComposerDeliveryMenu.tsx` | Toolbar delivery control. Exports `DeliveryChip` (`delivery-control` menu button + portaled `role=menu`), `DeliveryRadioItems` (`delivery-steer`/`delivery-queue` `menuitemradio`s with description + key, shared with the `+` menu), `DELIVERY_ITEM_CLASS`, `DeliveryMode`, `useMenuKeys` (focus-on-open, arrows/Home/End/Escape/Tab for a portaled menu). Text label drops below `@[40rem]`. See change: add-composer-dock. |
| `ComposerDock.tsx` | Dock above the composer card (sibling of `CommandInput`, never inside it). Exports `ComposerDock`, `ComposerDockProps` `{session,readOnly,cwd,openLane,onOpenLaneChange,columnEl,processes,lanes,yoloStore,live}`. Owns row layout (`role=group` lanes, stretched disclosure toggle, actions as siblings), controlled accordion render, compact chips + sheet below 32.5rem column, YOLO footer, one sr-only polite live region (counts only, sibling of lanes + footer). Processes built in (`useProcessesLane`); other lanes arrive as `DockLane` via `lanes`. No dock for `readOnly` or when no lane and no YOLO. Compact flip keeps `openLane`; focus moves to chip/toggle only if it was inside the open lane. Lanes beyond Processes come from `useComposerDockLanes`. `hideWhenEmpty` lanes watch their actions region (MutationObserver) and set `hidden`. Mounted by `ComposerDockHost` in `App.tsx` and bare (footer only) in `DirectoryHomeView`. Root carries `data-pane-row` = `composer-dock` / `composer-dock:footer-only` (chat-pane row table key, by lane presence) and an inline `flexShrink`/`minHeight` from that entry (min = footer 32 + 25 chrome with YOLO, else 0). Collapse (live dock, ≥1 visible lane): Hide handle `composer-dock-hide` on the top edge → lane region `composer-dock-scroll` (stable id, stays mounted, `hidden`) + one-line `composer-dock-bar` (44 px, `summaryText` per lane, Processes working dot, `aria-expanded=false`); bit per session via `useDockCollapsed`; row keys `composer-dock:collapsed[-yolo]`, minHeight bound + 25. Peek (`useDockHoverIntent`): region becomes `absolute bottom-full` overlay (no reflow, `data-peek`), Pin `composer-dock-pin`; transient, reset on rebind; Escape (menu first, then peek, focus→bar); modal open (`aria-modal` dialog or body scroll lock) closes it; `live=false` = read-only stored state. Root no longer clips (rounded clip on the region; footer-only keeps `overflow-hidden`). See change: add-composer-dock. See change: composer-dock-collapse-and-file-open. Connected-panel join: root `mx-5` (8px inside card edges, drawer look) + `-mb-6 pb-6` (tucks 12px under card top); `DOCK_CHROME_PX` 25. Peek join: overlay `z-popover` + upward-only shadow owns top corners; root drops `z-0` + `rounded-t-xl` (→ `rounded-t-none border-t-transparent`) while peeking; bar loses hover fill. See change: fix-dock-peek-join-and-view-menu-close. |
| `ComposerDockHost.tsx` | Hook boundary App mounts for the dock (`renderSessionDetail` has no hooks). Exports `ComposerDockHost`, `ComposerDockHostProps` (= `ComposerDockLanesInput` + `state`, `cwd`, `openLane`, `onOpenLaneChange`, `columnEl`, `onKillProcess`, `onShowTool`, `onNavigateToSession`, `live`). Calls `useComposerDockLanes` + `useInflightBashTools(state)`, renders `ComposerDock`. Accordion stays controlled by App (`useDockAccordion`). Live boundary: `sessionLive = live ∧ useOptionalSplitWorkspace().sessionId === session.id`; only then passes `openInSplit`/`openDiffTab` as `onOpenFile`/`onOpenDiff` and `live` to the dock. See change: add-composer-dock (8.1). See change: composer-dock-collapse-and-file-open. |
| `dock-peek-context.ts` | `DockPeekContext` + `useDockPeek()` → `{endPeek}`; `ComposerDock` provides it around the lane region so a lane body (Changes row targets, Review) can return a peeked dock to collapsed. Default no-op. See change: composer-dock-collapse-and-file-open. |
| `HistoryGapDivider.tsx` | Click-to-load interstitial disclosing a windowed replay's elided middle. Sits BETWEEN two loaded regions (the gap is bounded on both sides) — not a top-anchored "load older" header. States: A1 idle (`history-gap-count` + `history-gap-load`), A2 `history-gap-loading` (`aria-busy`), A4 `history-gap-error` + `history-gap-retry`, A5 `history-gap-unavailable` (tombstone, no retry — nothing failed, events were trimmed); divider removed entirely once filled. Every protocol `error` code collapses to ONE plain sentence — codes never reach the user. Tokens: `--text-secondary`/`--text-primary` only (`--text-muted` fails AA; `--text-tertiary` is 4.477:1 on light and is a non-text border token). Pill 44px below 480px, 32px above. See change: lazy-load-session-history. Now has TWO shapes, selected by the announced `windowShape`: `head-tail` stays a click-to-load INTERSTITIAL spliced out when drained; `tail-only` is a HEADER that auto-loads and RESOLVES TO A TERMINUS (`history-gap-session-start` / `history-gap-not-retained`) instead of disappearing — with no head above it, removing the row would leave a transcript that silently starts mid-conversation. `TerminusRow` is extracted so the state selection stays under the cognitive-complexity threshold. The auto-load `aria-live` region is deliberately NOT here: this is a virtualized row the virtualizer unmounts after a splice, so a region hosted here would not be in the DOM when its text changed — `ChatView` owns it. See change: add-tail-only-replay-window (D6). |
| `MultiAskPanel.tsx` | Grouped multi-ask panel (`data-testid="multi-ask-panel"`) for concurrently-pending free-floating asks. Vertical stack of independently-answerable cards; each reuses `getInteractiveRenderer(method)` and resolves its OWN `requestId` (no atomic submit). `method:"batch"` renders its BatchRenderer wizard in one slot. Renders null when empty. Fed by `derivePendingFreeFloating` from `ChatView`; hidden inline via `isRowVisible` while pending. See change: surface-concurrent-ask-user-prompts. |
| `MissingToolInlineError.tsx` | Inline chat error for missing shell binary. `[Install <tool> →]` flags `requestToolInstall` then navigates `/settings?tab=general`. See change: register-bash-and-tool-install-help. |
| `NestedToolCallList.tsx` | Nested-call list inside a root tool card. Collapsed to count; expanded rows indented by `parentId` depth (`nestedDepth`); `unfinished` neutral (no spinner/error); `argumentsBytes` → "arguments omitted (N bytes)"; `complete:false` → "nested-call record incomplete" (also for an empty record); truncated nested result → encoded `/tool-result` full fetch. Rendered by `ToolCallStep`. See change: render-nested-tool-calls. |
| `CustomEntryCard.tsx` | Bounded generic fallback card for non-`flow-event` custom content (pi.sendMessage custom messages + pi.appendEntry entries): `customType` label + PLAIN-TEXT `<pre>` body (never markdown — untrusted extension input), body visible by default in a `max-h-[240px]` region. Props `{customType, body, timestamp}`; the reducer truncates at row creation. Since add-custom-entry-renderer-slot it is the LAST link in `role:"custom"`'s resolution chain (plugin claim → this card), rendered by `CustomEntryRow`. An untruncated JSON-object body renders as a bounded `<dl>` (`FallbackKeyValueList`, memoized `parseFallbackObject(body)`) wrapped in an `ErrorBoundary` whose fallback is the `<pre>`; all other bodies stay the plain `<pre>`. See change: render-inline-reasoning-and-custom-entries, render-unclaimed-custom-entries. Neutral header: mono `--text-primary` type + `extension entry` meta (`customEntry.extensionEntry`), icon `aria-hidden`, 12px time; root `mx-4` (`mx-2` on mobile via `useMobile`) like `ToolCallStep`. See change: retro-ux-chat-rendering. |
| `CustomEntryRow.tsx` | Shared `role:"custom"` render container — the ONE resolution chain used by ChatView AND both absorption sites (`ToolBurstGroup.BurstBodyItem`, `CollapsedToolGroup`). Owns: group-visibility gate (`prefs.customEventGroups[msg.groupId ?? "other"] !== false`, so an absorbed row is gated identically), `forCustomType` claim lookup with `shouldRender` failing CLOSED, per-claim `ErrorBoundary` → `CustomEntryCard`, and collapsed-first expand + `useCustomEntryPayload`. Props `{msg, sessionId}`. See change: add-custom-entry-renderer-slot. Plugin component wrapped in `Suspense` with the default `CustomEntryCard` as fallback (lazy claims). See change: lazy-plugin-claim-modules. |
| `FallbackKeyValueList.tsx` | Key/value `<dl>` for the generic custom-entry fallback. Props `{view: FallbackObjectView}`. Text nodes only (keys/values untrusted — no markdown, no links); `max-h-[240px]` scroll region; "N more key(s)" notice via i18n `customEntry.moreKeysOne`/`moreKeysOther`. See change: render-unclaimed-custom-entries. Keys `--text-secondary`, values `--text-primary`, "N more keys" `--text-secondary`. See change: retro-ux-chat-rendering. |
| `parse-fallback-object.ts` | Pure `parseFallbackObject(body)` → `{entries:[key,display][], omitted}` or `null`. `null` for marker-prefixed (`/^«\d+ earlier lines hidden»\n/`), `body.length >= CUSTOM_BODY_MAX_CHARS`, bad JSON, non-object, `{}`. Caps keys at 50; nested values compact `JSON.stringify`; all formatting inside a catch-all (never throws). See change: render-unclaimed-custom-entries. |
| `OwnedEventGroupCard.tsx` | Collapsed row for a plugin-owned event group whose owner is unavailable: "N <type> events — <plugin> plugin unavailable" (`chat.ownedGroup.unavailable`); payloads (last 50) as `<pre>` per payload on expand; `aria-expanded` button. Rendered only by `ChatView` `rawEvent` branch. See change: decouple-plugins-from-core. |
| `ProcessesLane.tsx` | `useProcessesLane({session,commands,onKill,onShowTool,onNavigateToSession})` → `{lane,mounts}`. `mounts` = always-mounted zero-DOM summary instances of `composer-dock-process-group` claims; `summaries` map per claim, dropped on unmount. Lane exists for any positive total. Header: single group names itself, mixed reads `N running` + `a · b · c`; `longest` is an `aria-hidden` leaf. Kill progress kept here: Stopping… → row gone, or Still running after 3 scans (scan = new `session.processes` identity). See change: add-composer-dock (3.1, 3.2). |
| `ProcessesLaneBody.tsx` | Expanded Processes body: plugin groups (rows mode), Commands (Show → `onShowTool`), Background (Open for sub-session, Kill → `kill_process`, never aborts). Group headings only with ≥2 groups. Rows via `ComposerDockRow`. See change: add-composer-dock. Constant / same-origin navigation sink carries a `client-sinks: allow — <reason>` annotation. See change: harden-client-sinks. |
| `QueueLane.tsx` | `buildQueueLane({followUp,onEdit,onRemove,onPromote,onClearAll})` → Queue `DockLane` or `null` (empty). Header `N queued follow-up(s)` + `delivered when the agent finishes the turn`, Clear all from 2 (`queue-followup-clear-all`); body = `QueuePanel embedded`. See change: add-composer-dock (6.1). |
| `RawEventCard.tsx` | Collapsible card showing one raw event in the event log. Exports `RawEventCard`. → see `RawEventCard.tsx.AGENTS.md` |
| `SkillInvocationCard.tsx` | Collapsible card rendering a `<skill>` user invocation. Purple-tinted, wrench icon, default-collapsed body… → see `SkillInvocationCard.tsx.AGENTS.md` |
| `SlowLoadNotice.tsx` | "Still loading history · Ns" + Retry above chat skeleton once `SLOW_LOAD_MS` elapsed. Owns `useNow(true)` → only it ticks, never ChatView. `role="status"` static text; seconds `aria-hidden`. Test ids `chat-history-slow-notice`, `chat-history-slow-retry`. See change: show-session-history-load-state. |
| `ThinkingBlock.tsx` | Exports `ThinkingBlock`. Collapsible reasoning panel; props `content`, `isStreaming`, `defaultExpanded`,… → see `ThinkingBlock.tsx.AGENTS.md` New `inlineFlow?: boolean` prop: body drops `max-h-[400px] overflow-y-auto` (keeps `overflow-x-auto`) — HEIGHT ONLY, collapse machinery untouched. See change: render-inline-reasoning-and-custom-entries. |
| `ToolBurstGroup.tsx` | Renders temporal BURST group (data from `lib/group-tool-bursts.ts`). → see `ToolBurstGroup.tsx.AGENTS.md` Absorbed-thinking ThinkingBlock gains `inlineFlow={prefs.reasoningInlineFlow}`. See change: render-inline-reasoning-and-custom-entries. Absorbed `role:"custom"` rows render through `<CustomEntryRow>`; the container survives when every tool member is gated off but a visible custom row remains. See change: add-custom-entry-renderer-slot. On mobile (`useMobile()`) a running group never auto-expands: `autoOpen = (toolGroupDefaultCollapsed \|\| isMobile) ? false : isRunning`; tap override still wins; desktop unchanged. See change: harden-ios-safari-memory-and-ws-diagnostics. |
| `ToolCallStep.tsx` | Renders tool-call card. Adds `showResultBody?: boolean` prop (default `true`); when `false` hides result body… → see `ToolCallStep.tsx.AGENTS.md` Plugin `tool-renderer` component wrapped in `Suspense fallback={null}` (lazy claims). See change: lazy-plugin-claim-modules. |
| `ToolStopControl.tsx` | Exports `useToolStopState({active,onAbort,onForceKill})→StopController \| null`, `ToolStopControl({controller,testIdPrefix,labeled})`, `FORCE_STOP_ARM_MS=600`. States idle→arming(disabled, 600ms)→aborting→killing(disabled); guarded transitions; timer cleared on inactive+unmount. Real `<button>`s, testids `<prefix>-{stop,arming,force-stop,killing}-button`, glyphs mdiStop/mdiTimerSand/mdiAlert/mdiLoading, severity tokens only, 24px desktop / 44px mobile glyph-only. See change: fix-chat-burst-tool-stop. |
| `TrailingEdge.tsx` | Soft trailing edge of the paced reveal: `<TrailingEdge text revealing>` renders a pointer-transparent `aria-hidden` gradient overlay (≈2.5em, `var(--bg-tertiary)`) positioned by `transform` at the last text node's last rect (`Range`). Hidden over code/diagram/math, inline `code`, `direction: rtl`; fades out when the backlog is empty (`data-edge` visible/hidden). Never touches the markdown tree. Exports `lastTextNode`, `edgeSkipReason`. See change: add-fluid-chat-motion. |
| `useComposerDockLanes.ts` | `useComposerDockLanes(input)` → `[changes, openspec, queue, context]` lanes for `ComposerDock`'s `lanes`. Decides Merge emphasis ONCE (`isMergePrimary` over `useOpenSpecAttachment`) for the Changes and OpenSpec lanes; normalises `pendingQueues.followUp`; forwards `onOpenFile` + `onOpenDiff` to the Changes lane. Needs plugin-context + OpenSpec-map providers. App passes session data + callbacks only. See change: add-composer-dock. See change: composer-dock-collapse-and-file-open. |
| `useDockCompactMode.ts` | `useDockCompactMode(columnEl, onBeforeFlip?)` → compact when column < `DOCK_COMPACT_REM` (32.5) × root font. `ResizeObserver` on the composer column, ≤1 evaluation per animation frame, width 0 never flips, no hysteresis. See change: add-composer-dock (D2). |
| `useDockHoverIntent.ts` | `createDockHoverIntent(doc, host)` + `useDockHoverIntent({enabled,peeking,host})`: ONE `document` `pointermove` listener (refs only, no state per move) for the collapsed bar. Dwell 300 ms + path speed < 0.4 px/ms over 100 ms (`session-hover-intent.ts` constants); the bar button itself arms; grace 300 ms after leaving bar ∪ overlay; a held peek (open `[aria-haspopup][aria-expanded=true]` or focus in overlay/Pin) polls every 100 ms and starts the grace on release; `pointerout` with no `relatedTarget` counts as leaving; `pointerLeftBar` clears the post-Hide/Escape cooldown. See change: composer-dock-collapse-and-file-open. |