Composer and Chat UI

September 5, 2026 · View on GitHub

The chat UI lives under apps/desktop/src/renderer/components/chat/. It is composed of a pane (AgentChatPane), a message list (AgentChatMessageList), a composer (AgentChatComposer), and a constellation of side panels (tasks, scheduled work, file changes, subagents, computer use). The pane derives all visible state from the AgentChatEventEnvelope stream plus session metadata.

Source file map

PathRole
AgentChatPane.tsxTop-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. It persists a per-session ade.chat.lastViewed.v1:<sessionId> timestamp in renderer localStorage; scheduled turns that fired since that timestamp produce a small dismissible while-you-were-away card floating above the composer; it shows the wake count, reviews the first wake divider, and omits raw turn output from the notice. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration, and composer text is keyed by session id or lane draft key so switching draft lanes does not reuse another draft's text. Accepts an optional draftContextTargetId prop so the Work sidebar can target an unsaved draft composer for context insertions (attachments, iOS/App Control/browser selections, draft text) even before a chat session exists; window event handlers match on either sessionId or draftTargetId. When auto-creating a lane the draft resolves the primary lane for the onLaneChange callback so the sidebar lane context stays in sync. Composer draft state (text, model, reasoning, attachments, context items) is persisted to localStorage under the ade.chat.composerDraft.v1 key family and restored on scope change through ComposerDraftStorageSnapshot. Pending-steer Edit uses cancelSteer({ requireQueued: true }), then merges the queued text, file attachments, and context attachments into the captured composer draft; if the message already left the queue, the cancel fails and the draft is left unchanged. Draft launches are tracked through root-store-backed DraftLaunchJob state machines with multi-step progress (creating-lane -> starting-session -> sending-prompt -> ready / failed; auto-create names the lane deterministically up front and renames to the AI name in the background, so there is no blocking naming-lane phase); jobs live in the root store (not the per-project store) so an in-flight launch survives a remote project switch that tears down the originating project surface. The detached launch chain captures the originating OpenProjectBinding, passes it as a pin to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback to that binding, and caps each step at 90 s (withDraftLaunchTimeout). The composer is cleared optimistically at job start, stale active rows gain a hide-status escape hatch, failed jobs expose Restore in the job strip and matching error banner, and the DraftLaunchSnapshot captures the full control state so the async launch uses frozen settings. It also owns the transcript-resilience rules described in Transcript and turns: resolveChatHistoryMissAction (a history miss never blanks a rendered transcript), resolveSnapshotHistoryCursor (hasOlderHistory is authoritative over tailStartOffset), the bounded silent retry ladder OLDER_HISTORY_RETRY_DELAYS_MS = [800, 2400], the syncPendingBySession flag that surfaces as data-chat-sync-pending + a 2 px catch-up hairline under the header (a fading static rule, never a continuous animation; the fade is motion-safe:), and a minimal static cold-chat skeleton (data-chat-cold-skeleton) so a chat with no cached view reads as loading rather than empty. resolveRenderedChatSessionId picks the session to paint from the incoming props rather than the effect-synced selectedSessionId, which otherwise paints the outgoing chat's transcript for one frame after the pane is pointed elsewhere. The module-level view cache holds 8 entries / 128 MB total (32 MB per session, matching the resident ceiling) and stores a reference to the array the pane already holds; a detached view — an older transcript prefix whose live tail was dropped to stay under the resident cap after paging back — is skipped rather than evicted, so a later restore can never render an old slice as if it were current. The active-turn recovery loop is a stall detector, not the transport: it re-reads the transcript on a jittered ACTIVE_TURN_RECOVERY_INTERVAL_MS (10 s) tick and skips entirely when the live subscription delivered anything inside that window. Subscription ownership is handed to chatSessionRetention.ts when the pane hides. Left/right floating-pane reserve is applied only while a selected session surface renders those panes; an empty draft never reserves a hidden PR or Chat Actions pane, so its hero composer remains centered.
useDraftMachineRouting.ts, draftAttachmentTransfer.tsDraft machine selection and machine-safe attachment movement. Routing reconciles the machine restored by the current project/tab before the composer becomes sendable, resolves its OpenProjectBinding, and keeps lane selection scoped to that machine. On a user-requested machine change within one composer scope, useDraftAttachmentTransfer preserves portable image URLs and copies local/pasted image bytes from the attachment-owning runtime to the target runtime via pinned getImageDataUrl and saveTempAttachment calls. It removes non-image files and linked iOS/App Control/built-in-browser context because those machine-owned references are not portable. Pending transfer disables send. If copying fails, the source image references remain visible and sending stays blocked until the user returns to the source machine or removes the images. A project/tab scope change resets ownership only after machine selection has reconciled, so restoring a remote draft cannot be mistaken for an explicit local-to-remote switch.
apps/desktop/src/renderer/components/usage/ActivityModule.tsx, ActivityHeatmap.tsx, activityIntensity.tsTabbed cross-client activity/tokens/code/clients module. AgentChatPane mounts the self-fetching WorkActivityModule (compact variant) beneath the empty Work draft composer when no app panel is open; the component persists the chosen tab and day/week/month/year range under ade.activity.module.v1. ActivityHeatmap owns the responsive seven-row grid, viewport fitting, and the intensity ramp, while activityIntensity provides the shared daily activity score, non-zero quartile buckets, and leading-inactive-day trimming used by the grid and summary counts. The score (scoreActivityDays) is series-relative: each of seven dimensions — tokens, sessions, interactions, commits, PRs, changed lines, changed files, with local and GitHub counterparts summed — is scaled against its own maximum across the visible series before the weighted sum, because the dimensions are not in the same units. A raw sum made every non-token term smaller than the rounding noise of daily token counts, so the "activity" heatmap was a token heatmap under another name. isActiveDay stays unweighted so one commit still colours a day. describeActivityInsight derives the single sentence rendered above the grid from the same scores — busiest-day record, week-over-week trend, or peak day, in that priority — so the callout and the grid can never disagree. Buckets are quartiles over the non-zero days only, GitHub-contribution-graph style: a linear value/max ramp is useless when one 35.9B-token day is normal, because that outlier flattens every other day into the same near-floor tone. The ramp itself is explicit light/dark pairs rather than one hue at five opacities — an opacity ramp of a single hue is only a lightness ramp, which inverts its ordering between a dark and a light card — so hue and saturation both climb with the level and the scale reads in either theme. It deliberately avoids --color-accent, which is violet in dark and green in light. Under prefers-contrast: more (renderer/hooks/usePrefersMoreContrast.ts) every tile also gains a hairline border so the steps stay separable on a forced-contrast display. A "Less → More" key renders alongside the grid so the ramp explains itself.
apps/desktop/src/renderer/lib/draftLaunchJobs.tsPure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: DRAFT_LAUNCH_TIMEOUT_MS (90 s) + withDraftLaunchTimeout (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and LAUNCH_PROJECT_CHANGED_MESSAGE (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch).
apps/desktop/src/renderer/lib/handoffLaunchJobs.tsPure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. AgentChatPane writes these jobs into the root store while TerminalsPage passes matching jobs into the Work session sidebar. The local handoff surface offers a brief summarized handoff or a fork whenever the source provider is fork-capable (providerSupportsHandoffFork: Claude, Codex, OpenCode, Droid, Cursor). Fork keeps the new chat on the same provider and lane while allowing the target model to change; Claude forks the SDK session pointer, Codex the app-server thread (thread/fork), OpenCode session.fork, and Droid forkSession(). Cursor has no fork surface, so ADE starts a new Cursor agent and replays the full source transcript into it; providerForkReplaysTranscript selects the matching panel copy.
apps/desktop/src/renderer/lib/aiDiscoveryCache.tsRuntime-binding-scoped AI integration-status and provider-model cache shared across renderer surfaces. Local and remote checkouts with the same project identity cannot share model/auth state. getAiStatusCached uses a 10-second freshness window and deduplicates concurrent ade.ai.getStatus requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops.
CrossMachineHandoffModal.tsx, crossMachineHandoffPresentation.tsxModal state and user flow for Send to machine. It takes a runtimePin naming the machine the source chat runs on (null = this tab's bound machine), and every source-side call is pinned to it: the lane list, git.getSyncStatus, git.getOriginRemote, git.push, git.pull, agentChat.prepareCrossMachineHandoff, validateCrossMachineSource, and markCrossMachineHandoff. Destination dispatch already routes by target id and is unaffected. The pin is held in a ref and frozen once per operation so every await inside one handoff reaches the same runtime — reading it fresh after an await could cross a lane-index change and split one handoff across two machines. It verifies the source lane on the pinned machine, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through BlockedReasons / BlockedActionButton instead of silently disabling Continue. The pure half — stage/mode types, SourceCheck, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, CheckRow — lives in crossMachineHandoffPresentation.tsx so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no forkHandoffSupport, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click send as brief that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent).
ChatRuntimeScope.tsxWhich machine THIS chat is on, and what its lane looks like there — the single derivation every chat-scoped panel reads instead of a global store selector. useChatRuntimeScope() returns { pin, binding, laneId, lane, laneWorktreePath, rootPath, isRemote, machineName, online } from context, useChatRuntimeScopeForPin(pin, laneId, bindingOverride?) derives the same from a pin passed as a prop (for surfaces mounted outside a chat pane), useChatScopeDerivation({...}) answers it from a session for AgentChatPane, and ChatRuntimeScopeProvider carries the resolved scope down the panel/drawer subtree. pin === null means, and only means, "this chat lives on the tab's binding". ESLint bans useAppStore / useRootAppStore reads of projectBinding / lanes, project.rootPath reads, and selectActiveProjectRoot imports inside components/chat/** so no panel can quietly go back to reading the tab's machine. Full contract in the chat README.
AgentChatMessageList.tsxVirtualized message list. The virtualizer is hand-rolled, not @tanstack/react-virtual: a measuredHeights row-key → height Map feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in MeasuredEventRow, whose ResizeObserver reports its real height through handleMeasurereconcileMeasuredScrollTop so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a Woke on schedule divider before every synthetic scheduled turn and inline SubagentSpawnCard / SubagentResultCard / BackgroundJobLine rows (from SubagentActivityCards.tsx) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the compact while-you-were-away card and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth, late virtual-height measurements, and a shrinking transcript viewport (a growing composer must not unstick the thread). A Claude queue_recovery: available row renders one eight-second Undo card; later restored/expired rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at 360px) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as Goal set, Goal paused, and Goal cleared. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to agentChat.recoverCodexTurn; terminal provider capacity/usage-limit errors render ProviderFailureRecoveryCard with same-thread retry and model-selection actions. User messages marked metadata.hideFullPrompt render and copy only their displayText, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the handoff_fork provider origin and the list draws a single Forked from the previous chat — full history above divider (computeForkHistoryDividerRowKey pins it to the first live row after the seeded history) instead of one marker per seeded row. WorkingIndicator is the in-flight turn's status line — <activity> · working for <elapsed>, plus a taking longer than usual marker past LONG_RUNNING_TURN_SECONDS. The activity half comes from resolveWorkingIndicatorLabel: ACTIVITY_LABELS is keyed against the activity union in shared/types/chat.ts so a new runtime value is a compile error rather than a raw web_searching on screen, and an editing_file activity is named with its target (Editing laneService.ts) by walking back to the most recent unfinished write entry in the turn — activity events carry the tool name, not the file. Its elapsed is written imperatively (textContent on a ref) rather than through state, so the once-per-second tick never commits a render on the message list. The line swaps a bare <span> for an expander <button> the instant the turn's first tool entry arrives, which remounts the timer node, so the ref is a callback ref: it repaints the counter in the same commit it attaches, and the ticker re-reads the ref every tick. An element captured once when the ticker started would be detached by that swap and the display would sit frozen at 0s while the long-running marker still appeared. ChatInfoHostContext also lives here — a boolean context reporting whether the owning host listens for ade:chat:open-info. AgentChatPane provides true (it owns the chat actions pane); PersonalChatsPage mounts the same transcript without it, so the BackgroundJobLine open button is absent there rather than dispatching into nothing. It must stay a context rather than a module-level registry: App renders every ProjectSurface and only toggles active, so each AgentChatPane stays mounted while Personal Chats is open, and a global "is any host alive" flag would read true on exactly the surface that has no pane.
AgentChatComposer.tsx, ComposerPromptStash.tsx, ComposerSmartLinkMenu.tsx, smartLinkChipMark.tsText input, attachments, model selector, compact title-only permission controls (per-provider PermissionModePickerOption tables fed into the shared components/shared/PermissionModePicker, which the composer owns the option data for but not the control), slash commands, desktop prompt stashes, smart-link chips (smartLinkChipMark.ts returns the inline currentColor SVG brand mark each chip renders), chat-context chips (select assistant output → Add to chat; the chip label is Chat context, serializes an <ade-chat-context> block so the agent sees the highlighted passage, and click/backspace copy or remove it like a smart-link chip), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. A running chat's toolbar also shows a read-only amber tower plus the owning machine name beside the model and thinking controls; it identifies where the chat executes and directs moves to Chat actions → Handoff → Continue on another machine. A draft omits that label because its launch shelf owns the machine choice. ComposerPromptStash keeps its command surface mounted when the Appearance preference hides the bookmark, so Cmd/Ctrl+S remains available, but the visible bookmark stays out of the toolbar while both the composer and stash list are empty. Its menu is rendered in a viewport-clamped body portal with a bounded, scrolling list so composer overflow and short windows cannot crop it. A save first copies up to ten attached images into the owning project runtime, commits the text plus image references, then clears only the unchanged composer snapshot; restore reapplies both text and images before consuming the stash. The list renders an image thumbnail when the active runtime owns the bytes. On another synced runtime, the row retains its text and image count, labels the images as living on another machine, and refuses restore until the composer is connected to the origin runtime. Machine-bound context and non-image files are not stashed. Completed URLs are non-editable inline chips whose data-composer-chip-text preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming data-composer-chip attribute, and a scoped selectionchange effect marks intersecting chips with data-composer-chip-selected so the native selection paints continuously across them (overlay styling lives in apps/desktop/src/renderer/index.css). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. A separate Claude split Stop control selects among the four-mode matrix — Turn only, Turn + queue (the default), Turn + background (N jobs), and Turn + queue + background (N jobs) — persists that choice per chat, and dismisses its custom popover immediately after selection. Default Stop stops the turn and clears the queue but leaves background jobs running; per-task square stops on Chat Info and spawn cards kill one job. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by launchPromptClipboardNoticeEnabled, separate from the launchPromptClipboardEnabled copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (workDescription, filesHint, dependsOn) before rendering the selection card. It holds the last @// popover dismissal in a ref (it gates the next open, never a render) and has three distinct close semantics: closeCommandMenu() closes and forgets the dismissal, for a trigger that is resolved (a selection was made, the trigger is gone, the composer locked or reset); dismissCommandMenu(trigger) closes and keeps it closed while the user extends this query, used for Escape (an explicit dismissal — typing the rest of the token must not bring the menu back), for Enter/Tab with no matching row, and for the menu's own dead-query report; and closeCommandMenuKeepingDismissal() is the third and the one that is easy to write by accident — this trigger cannot open a menu right now (it is already a confirmed token, or still covered by an earlier dismissal), but nothing is resolved and nothing new was dismissed, so clearing the dismissal here would reopen the menu the user just escaped and recording one would suppress the menu for a trigger the user never dismissed.
apps/desktop/src/main/services/chat/promptStashService.tsRuntime-backed prompt-stash persistence. Exact prompt text, up to ten image references, their origin sync-site id, provider/model labels, and creation time are stored in the PK-only, CRR-compatible prompt_stashes table; newest-first retention is capped at 20 entries. Text and metadata converge across runtimes. HTTP(S) image references remain portable, while local-image bytes stay on the originating runtime: off-origin readers receive the image count but no absolute paths and cannot consume the stash. Origin-owned image files referenced by live stashes are protected from the normal seven-day temporary-attachment cleanup.
ProviderFailureRecoveryCard.tsxFriendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers Retry turn and Choose model only after the failed turn has released the composer. classifyProviderFailure delegates its rate_limit branch to the shared isUsageLimitChatError, so the card recognises exactly the set of shapes the host arms an auto-resume for. On a usage-limit failure with a known reset time it reports Usage limit reached, the local reset clock (Reset at 3:40 PM (47 min)), status text Continue automatically, and a Don't continue button. Don't continue opts the chat out of Claude SDK autoContinueAtUsageLimit (and cancels any ADE scheduled-work auto-resume row). The pending row arrives through ChatAutoResumeContext so only this card re-renders when the schedule appears or is cancelled. The context is per chat, so a card renders the schedule only when its own providerFailureEventId matches the context's anchorEventId — otherwise every usage-limit failure ever written to this transcript, including ones from days ago, would advertise the one armed row. That id is <envelope timestamp>::<turnId>::<message>: error events carry no id, turnId is optional on this path, and a usage limit produces the same message every time, so the timestamp is what separates two of them.
chatTurnState.tsPure turn-state helpers shared by live and hydration paths. Terminal transcript evidence beats a stale active session summary, and failed-turn retry resolves the associated non-steer user message even when the optimistic row has no provider turn id.
ChatActionsDrawerPanel.tsx, ChatSourcesPanel.tsx, chatSources.tsChat Actions tab shell plus Codex Sources view. The source derivation deduplicates files, web queries/results, MCP apps/tools, and external resource URLs from transcript events; safe web rows open in ADE's browser.
VoiceDictationButton.tsx, microphonePermissionGuidance.ts, apps/desktop/src/renderer/services/globalVoiceRecorder.ts, apps/desktop/src/renderer/components/voice/*, apps/desktop/src/main/services/transcription/microphoneAccess.tsDesktop dictation UI, platform permission boundary, and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via window.ade.transcription, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. Main asks Electron for the platform microphone status; Windows denied/restricted states return explicit Privacy & security > Microphone guidance, while unknown/not-determined states are left to Chromium's origin permission flow. The header indicator and composer pill render the same recording state.
apps/desktop/src/main/services/transcription/*Electron main-process transcription service. Writes captured 16 kHz mono PCM to WAV, runs bundled whisper.cpp base.en, parses the JSON sidecar, and applies deterministic glossary cleanup.
apps/desktop/resources/voice/voice-glossary.json, apps/desktop/resources/whisper/README.mdShared dictation glossary and release notes for materialized whisper resources. The large model and binary are generated by materialize-whisper-resources.mjs and ignored by git.
apps/desktop/src/renderer/components/work/SessionLifecycleChips.tsxAmbient snooze chip for the chat surface header, mounted by WorkSurfaceHeader through its snoozeSessionId prop (AgentChatPane passes the selected session id). Settled state is rendered by ChatLifecycleBanner as a compact pill above the composer. useSessionLifecycleSnapshot(sessionId) reads the row from the local per-project session cache the Work tab already mirrors into the app store, then falls back to the root cross-machine lane snapshot for a foreign chat; a bounded render-only deadline timer repaints an open snoozed row when it expires, with no extra IPC or lifecycle write. The snooze menu calls wakeSessionNow from renderer/components/terminals/sessionLifecycleActions.ts.
ChatSurfaceShell.tsxFloating chat header, body, footer layout. Backdrop-blur glass-morphism styling.
ChatComposerShell.tsxInput container chrome reused by the composer.
ChatAttachmentTray.tsx, ChatAttachmentPreviewModal.tsxInline file/image attachment tray, used both inside the composer and on sent user messages in the transcript. Image attachments render an inline thumbnail and expose a copy-to-clipboard button that ships the image bytes via window.ade.app.writeClipboardImage. Every other attachment renders as a chip: file-type icon from the Files tab's getFileIcon, middle-truncated filename (so the extension survives), human size when the caller knows it, and a remove ×. Chips are focusable — Delete/Backspace removes, Enter/Space opens, arrows move between attachments. Clicking any attachment opens ChatAttachmentPreviewModal, which renders the file with the Files tab's own viewer platform: resolveViewerKind picks the viewer and ViewerHost renders it, so PDFs, CSVs, media, office documents, markdown and code all preview read-only without forking a viewer. The modal is React.lazy-loaded because that platform pulls in Monaco and the document renderers, and the composer is on a hot render path. It locates the attachment with resolveAttachmentWorkspaceTarget (deepest containing Files workspace, Windows-separator aware) using the session's machine pin, so an attachment staged on a paired host is read from that host; an image outside every workspace falls back to the thumbnail bytes the chip already holds. Size is shown only for attachments this composer staged — a chip replayed from transcript history has only a path, and statting each one would be a round trip per chip.
chatAttachmentStaging.tsHow ONE file is staged, given what the destination machine supports. readAttachmentStagingMode(pin) asks the chat's machine once per batch (agentChat.getAttachmentStagingMode) and never throws — a machine that cannot answer gets CONSERVATIVE_ATTACHMENT_STAGING_MODE, the base64 contract every host has supported since attachments existed. planAttachmentStaging then decides per file: the machine-level answer is necessary but not sufficient, because three things force the bytes leg even on a capable host — no source path (a clipboard paste, and every file in the hosted web client, where webUtils does not exist), a renderer-side conversion (HEIC is decoded to JPEG here, so what gets staged is a buffer this process produced), or the host saying base64 outright. Whenever the bytes leg is taken the ceiling drops to LEGACY_MAX_CHAT_ATTACHMENT_BYTES, because that is what chat.saveTempAttachment actually enforces on the other end; returning the larger number would only move the rejection later. stageAttachmentBytesFromFile is that leg (chunked base64 encode, HEIC conversion, saveTempAttachment), and AttachmentConversionError marks the one failure the composer offers no retry for.
apps/desktop/src/shared/chatAttachmentLimits.tsThe three ceilings and the two rejection messages, shared by the renderer, the desktop main process, and the CLI sync host. MAX_CHAT_ATTACHMENT_BYTES (50 MB) governs attachments that move as files — a local disk-to-disk copy or a streamed HTTP upload, where the bytes never sit in a JS string. LEGACY_MAX_CHAT_ATTACHMENT_BYTES (10 MB) governs attachments that move as base64 inside a command payload, which is buffered in memory on both ends and chunked into 720 KiB frames under a 25 MB payload cap over sync. MAX_PROVIDER_INLINE_IMAGE_BYTES (10 MB) is independent of both — see attachmentInlineGuard.ts in the chat README. formatAttachmentSize, legacyAttachmentCapMessage, and attachmentTooLargeMessage render every ceiling from its constant, so raising one cannot leave a stale "10 MB" behind in a message.
apps/desktop/src/shared/chatAttachmentStagingFs.tsNode-only disk rule for writing into <projectRoot>/.ade/attachments — UUID basename, validated extension, containment re-check, stat-before-copy. Shared by desktop main, the ADE action registry, and the CLI sync host's upload route; full contract in the chat README. Cursor local chats may Read those staged files (and Cursor's own ~/.cursor/projects/<slug>/assets copies) through the SDK hook allowlist when worker init supplies the project root; writes stay denied.
attachmentViewerTarget.tsLocates a chat attachment inside a Files workspace so the Files viewers can open it. Attachments live at <projectRoot>/.ade/attachments/<uuid><ext>, already inside the primary workspace, so this is a containment question rather than a new capability — and resolving it on the client keeps the whole lookup pin-aware, so an attachment staged on a paired host is read from that host instead of silently matching a same-named path here. Splits on both separators (a Windows attachment path resolves too), compares segment-for-segment through the shared normalizePathForComparison so /a/ADE-backup cannot match /a/ADE, and the longest matching root wins — an attachment inside a lane worktree resolves to the lane's workspace, not the project containing it.
ChatCommandMenu.tsxPopover for slash commands and the mixed @ menu: files, chats, lanes, and terminals ranked together by match quality (not grouped or biased by kind), each row showing a kind icon. Consumes a ComposerTrigger from shared/composerTriggers.ts (so the menu opens for a mid-draft trigger, not just a leading one). Files and mentions are two independently debounced (40 ms) useDebouncedSuggestions sources sharing one hook; rankComposerAtMenuItems then scores file paths (basename as subtitle) against entity titles with the same exact/prefix/substring/subsequence tiers. Each keeps a per-menu-session query cache (QUERY_CACHE_MAX = 40) so cached queries render same-frame while a background revalidation still runs, and both caches clear when the menu closes or the provider identity changes. A bare @ is a browse of recency-ranked entities (file search returns nothing until there is a query). Multi-word @ queries stay active through spaces and use the same cached/debounced search path. Flat keyboard-nav indices are precomputed in the sections memo (no render-time counters); all three row types share the MenuRow chrome. Selecting a mention inserts an opaque @chat:<id> / @lane:<id> / @term:<id> pointer while the composer displays a compact title chip with a kind icon (see shared/chatMentions.ts). An onNoMatches?(trigger) callback fires once when a non-empty @ query settles with zero rows, so the owner can close the menu instead of leaving it parked over the draft while the user types the rest of a sentence; an empty query is a browse, not a search — it can legitimately show nothing now and match once the user types — so it never reports. useDebouncedSuggestions carries the query its results belong to alongside the provider identity, and results from a previous provider or a previous query are discarded and count as still-loading: state updates from this render's effects are not visible to consumers until the next render, so loading alone would read "settled" for one frame after every keystroke and fire a false no-match.
apps/desktop/src/shared/composerTriggers.tsCursor-relative typed-trigger detection shared by the desktop chat composer (rich + textarea), the WorkViewArea continue composer, and the ade-code TUI (iOS mirrors the same regexes in Swift). detectComposerTrigger(text, cursorPos) finds an in-progress /command / @ query ending at the cursor at any position; @ queries may contain spaces for multi-word entity names but stop at a newline or another @; selecting a matching suggestion narrows the replacement span to its label so trailing prose is preserved; replaceComposerTriggerSpan splices exactly that span; findConfirmedComposerTokens locates confirmed chip tokens for overlay/prompt styling (ComposerTokenKind is `"file"
apps/desktop/src/shared/smartLinks.tsCross-client URL catalog and deterministic fallback labels. Recognizes GitHub PR/issue/repo/commit/action-run links, Linear issues, ade:// deeplinks, and generic HTTP(S) pages; trims sentence punctuation, caps each draft at 12 matches, and keeps the canonical URL separate from optional title/favicon metadata. Desktop, hosted web, and ADE Code import this contract; iOS mirrors it in WorkSmartLinkDetector.
apps/desktop/src/main/services/chat/smartLinkPreviewService.tsRuntime-owned best-effort metadata resolver. GitHub and Linear titles use configured provider services; generic pages use bounded public-network HTML/favicon reads with DNS pinning and SSRF checks. Generic previews cache at most 256 public entries for 30 minutes (five minutes for metadata misses); credential-backed provider results are never stored in that process-global cache. Any error returns the deterministic local preview rather than blocking composition.
ChatTasksPanel.tsxTodo list rendered from todo_update events.
apps/desktop/src/shared/chatScheduledWork.tsPure scheduled-work derivation. Folds scheduled_work_update envelopes into Chat Info schedule rows for Claude wakeups, cron tasks, /loop, remote triggers, and background work; defines the shared Background/Schedule Earlier predicates (including fired one-shot wakeups); and formats next-fire labels. A parent turn's terminal event does not stop a background row, and background snapshots whose sourceTaskId belongs to a real subagent are omitted so native Agents do not appear twice. Shared by desktop, ADE Code, and mirrored by iOS.
ChatFileChangesPanel.tsxTurn-level file change summary for checkpoint-backed turn_diff_summary events, with lazy diff expansion. File rows render the lane-relative path (dimmed directory + filename) rather than a bare basename, so several index.ts in one turn stay distinguishable; the directory truncates first and the full path stays in the row tooltip. Diff reads come from useChatRuntimeScope(), so a foreign chat's file changes are fetched from the machine that has them.
RewindFilesConfirmDialog.tsx, rewindFilesPreview.tsUndo confirmation for provider-backed file rewind. Builds a message-scoped file list from provider dry-run output plus turn diff summaries, then renders per-file expandable diffs before applying rewindFiles. Claude uses SDK file checkpoints; Codex forks the thread before the selected turn (thread/fork + beforeTurnId) on app-server >= 0.145.0, or falls back to thread/rollback (latest user message only) on older servers, and restores files through ADE's git plan.
ChatSubagentsPanel.tsxChat Info panel. It renders the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters. Every subagent row shows a sentence-case model chip (subagentModelAttribution): a reported envelope model is ground truth, and a missing model falls back to the parent session label marked inherited. Running subagent and background rows derive elapsed time from the wall clock and tick once per second; terminal rows keep their final compact duration. Large sections cap active rows and add Show all; terminal rows move into one Completed fold; Clear/Restore is a visual per-session filter. Failed and pinned rows remain active, survivors keep source order, and the pane variant owns a single scroller with sticky section headers. Spawned-chat rows are identified by childSessionId, show the live child title supplied by AgentChatPane / WorkViewArea, keep the runtime as a small kind chip, and navigate to the child rather than opening the provider-subagent drawer. Running native subagent rows (and background rows that carry a sourceTaskId) expose a square stop whose aria-label is Stop ${agentType}; spawned ADE chats (chat: task ids) are not stoppable this way. The Schedule header keeps the per-chat pause/play action. For Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header.
ChatComputerUsePanel.tsxComplete chat proof drawer with image lightbox, inline video, availability/error states, and irreversible artifact deletion. Preview reads stay runtime-routed through useChatRuntimeScope() — the chat's machine, not the tab's — and the surface has no review or Finder/reveal controls. Inline transcript proof is owned by AgentChatMessageList + ChatProofFilmstrip: a collapsed count on the producing turn expands in chronology instead of pinning the newest items to the thread tail.
ChatAppControlPanel.tsxApp Control panel for Electron apps. Two mount points: under the chat composer (chat-scoped, sessionId set) and inside the Work right-edge sidebar (lane-scoped, sessionId={null}). Two modes: Control (live screencast frames + launch/connect form + click/type input + quick terminal write / terminal signal actions) and Inspect (hit-test crosshair on the screenshot; commits selections as AppControlContextItems with screenshot, DOM packet, and source-file candidates). Persists panel state under sessionStorage["ade.chat.appControlPanel.<key>"], where the key is chat:<sessionId> for the chat mount and lane:<laneId>:<projectRoot> for the sidebar mount. Connect/launch calls forward laneId so the resulting AppControlSession records its launching lane. It takes a runtimePin and every status/target/input call routes through it, so the panel drives the machine the chat runs on rather than the one the tab is bound to; the pin also participates in the panel's sessionStorage key so two machines' panels cannot share state. See App Control.
ChatIosSimulatorPanel.tsxmacOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, interact vs inspect mode, hit-test overlay, and selection emission as IosElementContextItem. Accepts an optional laneId prop, forwarded into iosSimulator.launch so the resulting IosSimulatorSession records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. It also takes a runtimePin and derives its scope with useChatRuntimeScopeForPin, so status, device listing, launch, and interaction all reach the chat's own machine — and its project root comes from that machine's lane worktree rather than the tab's project root. See iOS Simulator feature.
ChatBuiltInBrowserPanel.tsxIn-app browser panel mounted under the Work right-edge sidebar's browser tab. Renders the address bar, navigation/tab strip, inspect toolbar, screenshot capture, and an empty/error state derived from BuiltInBrowserStatus; the actual page content is painted by a main-process WebContentsView whose bounds the panel reports back to the broker via ade.builtInBrowser.setBounds. Inspect-mode hit-tests emit BuiltInBrowserContextItem payloads through onAddContext; the sidebar then dispatches ade:agent-chat:add-builtin-browser-context to the active chat. The panel does not run inside AgentChatPane directly — instead, anywhere in the renderer that wants to open a URL calls openUrlInAdeBrowser() (in apps/desktop/src/renderer/lib/openExternal.ts), which fires ADE_OPEN_BUILT_IN_BROWSER_EVENT and asks the broker to open a new tab. It accepts a runtimePin (and derives its scope with useChatRuntimeScopeForPin) so status reads, navigation, and capture reach the machine the chat runs on.
ChatTerminalDrawer.tsxCollapsible terminal drawer at the bottom of the chat. Takes a runtimePin and forwards it to the terminal surface it hosts, so a chat on another machine lists and attaches to that machine's PTYs.
ChatGitToolbar.tsxGit status and quick-action toolbar above the composer. The changed-file count is the number of distinct paths across the staged and unstaged lists, not their lengths summed: git status reports a file with both a staged and an unstaged edit as MM, and the parser puts that one file in both lists. The PR action opens or toggles a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. Opening the chat PR pane or compact PR menu performs a targeted, cooldown-bound refresh for that single linked PR. The toolbar is a status strip only — the manual PR-sync (↻) button moved into the PR pane's title bar, so surfaces that render this toolbar without a PR pane have no manual sync affordance and heal through reconcile-on-focus plus prs-updated instead. It takes a runtimePin prop and derives its scope with useChatRuntimeScopeForPin(runtimePin, laneId) rather than from context, because the CLI session header renders it with its own pin and no ChatRuntimeScopeProvider above it; status and PR reads then follow the chat's machine.
ChatPrPane.tsxLeft floating PR pane for Work chat. Owns a title bar (Pull request + ↻ refresh + ✕ close): ↻ calls prs.syncLanePr and then re-reads the pane's PR, and spins for either a manual sync or a backend reconcile-on-focus (pr-reconcile, debounced 300 ms on the hide so a fast reconcile does not flicker). ✕ is wired to the parent's onClose (the header PR pill still toggles it). Shows cached lane PR details immediately, then refreshes the linked PR row with the same targeted refresh path so pane toggles surface current merged/closed/check state without a broad PR sync. An unmapped lane PR (projection-derived, pr.unmapped) skips the refresh and checks/reviews enrichment — there is no DB row behind its synthetic gh: id. With no PR it embeds ChatPrInlineCreator and forwards the chat's sessionTitle; under a runtimePin it points at the owning machine instead, since creation is not pinned. Reads, the ↻ sync, and the event subscription all take the pin so a chat on another machine sees its lane's real PR.
ChatPrInlineCreator.tsxInline create-PR form inside the PR pane. Laid out as a flow with no uppercase section captions: a flat, boxless source row (lane name + branch + lock glyph, immutable), a connector carrying N ahead · N behind · clean/dirty from lane.status (muted comparing… when the lane has no status yet), then the canonical LaneCombobox target dropdown (no free text), title, description, and Create. The title defaults to the chat session title whenever it is a real title (the placeholder New chat never wins), otherwise to the <lane> -> <target> derivation. Linear magic words and the deeplink footer are added server-side by prService. On success it hands the created PrSummary up through onCreated so the pane swaps to details without waiting for prs-updated.
ChatUserMinimap.tsx, chatUserMinimap.logic.tsTick rail down the transcript's left gutter, one clean tick per user message with no guide hairline, gated on the chatUserMinimapEnabled appearance setting and mouse pointers only ([@media(pointer:fine)]). Ticks are positioned by percentage of the full message-list height, so they compress instead of overflowing and there is no marker cap or subsampling — the entry index stays 1:1 with the tick index, which is what pointer→index mapping depends on. Every tick uses the same normal message geometry; turn outcomes and queued Codex follow-ups retain their colors without changing the tick's width, height, or shape, and context-compaction events are omitted. The whole rail is a single <button> (one tab stop for the timeline) that derives the hovered tick from pointer Y (resolveMinimapIndexFromPointer). The hit strip is width-clamped to the measured side gutter (resolveMinimapHitStripWidth, capped at 40 px) so it can never overlay the centered content column or swallow message-text selection, and it goes pointer-events-none at zero gutter. Hovering opens a preview card with the user message plus a 3-line clamp of that turn's final assistant reply, anchored by a 3-way translateY (resolveMinimapPreviewTranslateY) so the first and last ticks cannot render it off-screen. The rail stays fixed when the floating PR pane opens; the PR card uses a higher stacking level and overlays it when their regions intersect.
chatPromptHistory.tsShared prompt-history entry/key shape used by AgentChatPane, AgentChatComposer, and AgentChatMessageList. The pane derives chronological, visible user prompts from the selected transcript only; ArrowUp starts at the newest prompt, rapid same-direction arrow navigation stays in history for 3 seconds, interruptions cancel that sequence, multiline caret motion resumes after a pause until a line boundary, and ArrowDown past the newest entry restores the selected chat's unsent draft. Each selection emits a transcript jump so the minimap's active tick and brief preview follow.
chatSessionRetention.tsKeeps a chat's agentChat.onEvent subscription alive for CHAT_SESSION_RETENTION_TTL_MS (5 min) after its pane hides or unmounts, so returning to a tab shows a current transcript instead of one that is stale by a whole tab visit. It is a renderer module on purpose: preload's remote event pump only polls while hasRemoteRuntimeEventSubscribers() is true, and that predicate counts renderer-held onEvent callbacks — a retained renderer subscription keeps the pump alive for free, so moving this into preload would idle the pump for exactly the case it exists to fix. Ownership is a handoff, not a shared subscription: a visible pane owns the only subscription; on hide it calls retainChatSession(sessionId) and this module opens its own minimal handler; on return adoptRetainedSession(sessionId) flushes synchronously and tears the module handler down. The handler captures no pane state, setters, or refs — it talks only to the injected ChatSessionRetentionHost (subscribe + batched appendEvents), wired once from AgentChatPane module scope. Envelopes are buffered per session and appended in one batch on a 16 ms timer (CHAT_SESSION_RETENTION_FLUSH_MS, the same cadence as the visible pane's flush) because a per-event append re-walks the whole transcript to dedupe/trim/derive, which would make a hidden chat cost more per event than a visible one. MAX_RETAINED_CHAT_SESSIONS = 2 caps fan-out so a grid of tiles hiding at once cannot open one subscription per session. On TTL expiry the subscription drops but the cached view is kept (warm, just no longer live). The module never computes turnActive policy — adoption still funnels through chatTurnState.resolveTurnActive at the pane's applyCachedSessionView, so terminal transcript evidence still outranks a stale cached turnActive: true. The same rule binds pending input: appendRetainedChatSessionEvents runs from a context with no session summary in hand, so it caches the raw derivePendingInputRequests output and leaves resolvePendingInputs to the pane's read-time memo. Folding the summary in here would cache a summary-tainted derivation and read it back stale.
chatCompanionUiState.tsPer-chat companion UI state — which side panes/drawers a chat had open (chatActionsOpen, chatActionsTab, iosSimulatorOpen, appControlOpen, terminalDrawerOpen, prPaneOpen). Persisted in localStorage under the ade.chat.companionUiState.<key> family (not sessionStorage): reopening the app to the chat you left should look like the chat you left. Keys are scoped by companion key and two surfaces write disjoint key spaces — the ADE chat pane keys by chat session id (plus reserved draft keys), the CLI session pane keys by terminal session id — so nothing may assume one surface's keys are the only live ones. patchChatCompanionUiState(key, patch) does the read-merge-write because the record has two independent owners (the chat shell's drawer state and useChatPrPaneOpen's prPaneOpen), and a whole-record write from either clobbers the other. Every read is defensive and degrades to DEFAULT_CHAT_COMPANION_UI_STATE; the legacy proofDrawerOpen field still maps forward to chatActionsOpen + the proof tab. Each write stamps savedAtMs and runs pruneChatCompanionUiState (cap 200 entries, oldest-first, records with no savedAtMs sort oldest) — self-pruning from inside the module, because the earlier caller-supplied knownKeys prune treated the second writer's live keys as garbage.
useChatPrPaneOpen.tsOwns the floating PR pane's open/closed state, shared by the ADE chat surface (AgentChatPane) and the CLI session surface (WorkViewArea). The pane never auto-opens — only an explicit toggle moves it, and once opened it stays open until the user closes it. Takes the surface's companion-state persistKey, which makes that open/closed state per chat and durable across restarts via chatCompanionUiState; without it the pane is bare component state and every chat switch or relaunch reopens from "closed". Hydration and persistence are two effects in declaration order: on the commit where persistKey changes, the persist effect still closes over the outgoing chat's prPaneOpen, so a pendingHydrationKeyRef marker makes it skip exactly that one stale flush instead of writing chat A's value into chat B's record. Every transition persists — the toolbar toggle and the pane ✕ both move the same state.
ChatProposedPlanCard.tsxComposer-level plan approval card shown while input is locked. Renders the plan description or question text as rich markdown (ChatMarkdown) inside a scrollable container (capped at min(34vh, 360px)). Transcript plan events render through AgentChatMessageList / CodexPlanCard.
apps/ios/ADE/Views/Work/WorkPlanComposerViews.swiftiOS composer-level plan approval strip. The live plan_approval gate renders as a compact full-width strip above the prompt box, opens a large markdown sheet for review, and sends Approve/Reject decisions through chat.approve with optional rejection feedback as responseText. It is one body of the consolidated pending-input strip (see Cross-surface parity) — the strip in WorkChatSessionView+Timeline.swift renders the current request (plan / approval / permission / question / model-selection), a "Request 1 of N" header, and an "Accept all" sweep when more than one gate is queued.
apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swiftiOS prompt box, icon-only staged-steer strip, and WorkStructuredQuestionCard — the mobile question card. The card pins only a provider row (plus the question tab strip when paged) above its internal scroll region and the freeform field plus Send/Decline footer below it; the question text, request body, meta rows, and option list all scroll. WorkPendingInputHeightBoundedCard in the same file is the generic wrapper that caps the non-question gates. Both budget against maxCardHeight (see Cross-surface parity) and enable .scrollDismissesKeyboard(.interactively) so a long typed answer can never trap the user away from the footer.
apps/ios/ADE/Views/Work/WorkDraftPersistence.swiftiOS draft persistence. WorkComposerDraftStore keeps unsent composer text per chat (chat:<sessionId>) plus fixed keys for the Hub and New Chat composers; WorkQuestionDraftStore keeps in-progress question selections/freeform per request id. Both are versioned JSON dictionaries in App Group UserDefaults via WorkDefaultsJSONMap, LRU-evicted by updatedAt (60 composer entries, 30 question entries), with a 400 ms workDraftAutosaveDebounce. The workPersistedDraft(_:key:) view modifier packages the three legs a plain String binding needs: restore-if-empty on appear, debounced autosave, flush on disappear.
ChatModelSelectionPendingCard.tsxFull agent-briefing model picker for orchestration pending inputs. Shows description, touched files, run-after dependencies, provider/model controls, and submitting/cancel states without a recommended default model.
codex/CodexPlanCard.tsxCodex plan card rendered inline in the transcript for plan events. Shows plan state (Planning / Plan ready), step progress with status glyphs, and streaming plan text as rich markdown via ChatMarkdown. Completed plans with no discrete steps render the full markdown body inline; plans with steps offer a toggle to expand the raw markdown details (labelled "details" when complete, "live" while streaming). Handles missing steps arrays gracefully.
codex/CodexGoalCard.tsx, codex/CodexGoalBanner.tsxCodex goal surfaces. The card is the active desktop surface and routes edits, status changes, and clears through typed ADE APIs (ade.agentChat.codex.*) rather than prompt text. It shows objective, status, token count, and elapsed time, while hiding provider budgets because ADE keeps goals unlimited. The banner remains available for compact surfaces that need a horizontal goal strip.
ChatWorkLogBlock.tsxWork-log presentation. The transcript no longer renders work_log_group rows, so the exports that matter there are ChatToolActivityDetails (the expandable tool-call list on the working indicator and the done divider) and ChatTurnFilesChangedSummary — one collapsed N files changed +A −D row at the turn's done divider, aggregated from the whole turn's file_change/write-tool entries and deduped by path. Expanding shows lane-relative paths (absolute path in the tooltip) that open in the Files tab, per-file diff expansion drawn from the entry payload, and a Review in Files action that opens the Files tab for the lane — it is not a revert; revert stays on the checkpoint-backed turn_diff_summary panel, which also suppresses this fallback when the turn moved HEAD. Because it reads only work-log entries it works for every runtime and for turns with no git checkpoint. The whole ChatWorkLogBlock component survives for the Settings chat-appearance preview: it accepts animate so completed groups render a static glyph while in-flight ones pulse, and prefers waiting over working when any entry is interrupted. Web-search work-log rows render provider action details (query / queries, title, url, snippet) as compact result chips; URL chips route through openUrlInAdeBrowser(). Also renders a LocalhostServersStrip above the panels when any work-log entry produced a localhost/127.0.0.1/0.0.0.0/[::1] URL: a sky-toned chip per detected URL routes through openUrlInAdeBrowser() (so the click opens the Work sidebar Browser tab in a new tab), and a sibling Logs button either reveals the chat's currently active terminal (via onRevealChatTerminal) or — when no terminal exists — drafts a "please move this server into the ADE chat terminal" prompt for the agent through onInsertDraft.
AskQuestionComposer.tsxThe ask-question surface, anchored in the composer: while a question blocks, it replaces the textarea inside the same prompt-box frame (provider mark + verb header, ledger option rows, capped previews, note row, keyboard-first answering, A/B compare, minimize). See Pending input card.
QuestionReceipts.tsxThe transcript record for a question: a one-line expandable receipt on the chatCardPrimitives / AdeCard convention once resolved (AnsweredQuestionReceipt), and an "awaiting you" row while the gate is open (OpenQuestionReceipt).
apps/desktop/src/shared/pendingInputAnswers.tsThe shared answer contract — answerState, sendLabel, buildAnswers, notePlaceholder, foldedSummary, plus sanitizeAnswersForTranscript and flattenAnswerForSingleStringProvider. Imported directly by the desktop renderer, the web client (same component), and the TUI; iOS mirrors it in Swift. It also owns isQuestionKind(kind) — "is the agent asking you something, or asking you to allow something" — which isAskQuestionRequest now delegates to, and which the push publisher imports so the split is decided once. Anything unrecognised, including a kind from a newer runtime and the absent kind of an older event, is an approval: the safer of the two words to be wrong with.
chatMarkdown.tsxThe shared agent-markdown renderer (ChatMarkdown, buildChatMarkdownComponents, SAFE_PREVIEW_SCHEMA) used by plan cards, question-option previews, and other non-transcript surfaces. Links route through ChatMarkdownAnchor: a resolvable workspace path becomes a button that opens through the chat workspace-path context (the Work tools-pane Files panel when the file is in this chat's own lane on this machine, the Files tab otherwise), a real URL opens in the in-app browser, and anything that is neither (including a bare file: href) renders as inert text. A file path must never reach the browser opener — normalizeBrowserUrlInput turns laneService.ts into https://laneService.ts and navigates the built-in browser to a garbage host. SAFE_PREVIEW_SCHEMA allows the file: protocol and single-letter drive "schemes" (both cases) on href because rehypeSanitize runs before urlTransform and would otherwise strip a Windows C:\repo\x.ts before it could be linkified; javascript: / data: / vbscript: stay blocked. chatMarkdownUrlTransform decodes the percent-encoded link destination before the drive check, since the markdown pipeline delivers C:%5Crepo%5Cx.ts.
chatMarkdownBlock.tsxMarkdownBlock — the transcript's own markdown renderer (extracted from AgentChatMessageList.tsx, which now imports it) plus the MosaicRenderContext type. Owns the prose wrapper classes, the react-markdown component overrides (headings, lists, tables, blockquotes, workspace-path links, HighlightedCode fences, the Claude-gated ```mosaic fence → MosaicCard) and an inner memoized MarkdownBody. It accepts an optional tailMarkdown: when the paced reveal is running, the settled prefix and the growing tail are rendered as two bodies inside one prose flow, so the settled half is byte-identical between frames and the memo bails out while only the short tail re-parses. Rows that are not being paced pass no tail and render exactly one body, identical to the pre-pacing output.
AssistantTextBody.tsxThe assistant text row's markdown leaf, and the only component that re-renders at 60 Hz while a turn streams. Holds the reveal state (useRevealedLength / useSplitRevealed) so a frame costs one small subtree render instead of touching the transcript derivations, the row list, or sibling rows. paced is true for at most one row at a time. Writes data-stream-text-len (the revealed length, i.e. what the commit actually paints) only while a perf run is active — that is what renderer/perf/streamSmoothness.ts samples.
textReveal.tsPure paced-reveal state machine — no rAF, no DOM, no React. Per commit, step = min(backlog, max(1, ceil(backlog * elapsedMs / horizonMs))), i.e. exponential decay toward zero backlog with a one-character floor, so a burst drains within roughly horizonMs and a trickle still advances. Cuts are grapheme-safe (clampToGraphemeBoundary); without Intl.Segmenter pacing disables itself entirely. Also owns the horizon config (DEFAULT_TEXT_REVEAL_HORIZON_MS = 150, localStorage key ade.textRevealHorizonMs, <= 0 restores paint-on-arrival), the 250 ms elapsed clamp that stops a backgrounded tab from dumping its backlog in one frame, the 60 Hz commit floor, and the incremental block scanner (advanceSplitScan / splitRevealed) that places the settled/tail cut only on a blank line at fence depth zero.
useRevealedText.tsThe rAF binding for textReveal.ts. Runs a frame loop only while the row is paced, pacing is enabled, the document is visible, and the row intersects the viewport; any of those dropping reveals everything immediately and stops the loop, so background grid tiles, hidden windows, and scrolled-away rows keep paint-on-arrival. Visibility lives in refs (two independent axes, deliberately not folded into one boolean) so a tab switch or a scroll stops the loop without re-rendering. Retargeting happens during render, and a row whose first sight is already complete (history, virtualization remount, backfill) paints in full — only growth observed after mount is paced.
chatWorkspacePaths.tsxWorkspace-path parsing/resolution plus the React context that carries the opener. See the chat README source map row for the full contract.
CodeHighlighter.tsx, chatStatusVisuals.tsx, chatSurfaceTheme.ts, chatToolAppearance.tsxSupporting visuals. chatStatusVisuals.ChatStatusGlyph takes an animate prop so non-active rows skip the ping/spin animation; AgentChatMessageList.ActivityIndicator mirrors this and switches to a dimmed static tone plus a non-looping thinking lottie once the turn ends.
pendingInput.ts, chatExecutionSummary.ts, chatNavigation.ts, chatTranscriptRows.tsPure state derivations consumed by the UI. pendingInput.ts is the renderer's only pending-input derivation — a second, drifted copy once lived under chat/hooks/ and was deleted; do not reintroduce one. It owns both halves of the contract: derivePendingInputRequests (transcript in, raw cards out) and resolvePendingInputs (raw cards plus session summary in, live cards out). chatTranscriptRows.ts also owns two message-list helpers: shouldCollapseUserMessageText (a user message over 600 characters or 8 lines renders collapsed) and countRowsAppendedSince (the N new count on the jump-to-latest pill).
apps/desktop/src/renderer/lib/visualContextFormatting.tsPrompt formatting for visual/tool context from attachments, iOS Simulator, App Control, and built-in browser selections.
apps/desktop/src/shared/types/chat.tsShared composer/session DTOs, including PARALLEL_CHAT_MAX_ATTACHMENTS, parallel launch state types, the AgentChatModelCatalog* set, AgentChatModelCatalogRefreshProvider (opencode / cursor / droid / lmstudio / ollama), and AgentChatModelCatalogArgs (mode, refreshProvider). It also single-sources two pieces of user copy so emitter and renderer cannot drift: spawnCompletedNoticeMessage(childTitle)Chat "<title>" finished its turn, and waitingOnYouDescription(count?)Waiting on your answer. / Waiting on your answers. — the fallback line when a chat is blocked on the user and there is no question text to show. That second one is not local to the chat pane: it becomes the ADE Notch card's subtitle, the phone's push body, and the lock-screen preview, so the old per-provider "<Provider> needs input before it can continue." variants (a sentence about the agent where the user wanted a sentence about them) are gone from every runtime path. The approval_request event additionally carries an optional requestKind: PendingInputKind — see Approval vs question.
apps/desktop/src/renderer/components/shared/ModelPicker/Modular ModelPicker (see ModelPicker structure): ModelPicker.tsx, ModelPickerContent.tsx, ModelPickerRail.tsx, ModelListRow.tsx, ReasoningEffortPicker.tsx (draggable/snapping gradient slider that stays open on selection), modelCatalog.ts, modelOrdering.ts, modelPickerSearch.ts, providerEmptyState.tsx, runtimeCatalogCache.ts, plus the useProviderAuthStatus / useAuthOnlyFilter / useModelFavorites / useModelRecents / usePerSurfaceModelDefaults / useReasoningByFamily hooks.
apps/desktop/src/renderer/components/shared/PermissionModePicker.tsxThe permission-mode pill itself, shared by every surface that lets a user choose how a chat starts: the composer's per-provider controls, SessionLaunchModelControls, and the cross-machine handoff modal. Exports the generic PermissionModePicker, PermissionModeGlyph, the tone/icon enums the provider option tables map into, and PERMISSION_TRIGGER_CLASS — the one definition of the trigger chrome, previously hand-copied per surface. That class scales with calc(var(--chat-font-size,14px)*9/14); the fallback is load-bearing, because --chat-font-size only exists on a chat appearance root and a bare token would leave the launch and handoff pills inheriting the ambient size. Anything offering permission modes renders this, not a lookalike. Tone colour is deliberately asymmetric between the collapsed trigger and the open popover: only the red tone (bypassed permissions — the one mode here that can do damage) keeps colour on the resting trigger, and as a border/text tint rather than a filled pill. Every safe tone renders neutral trigger chrome and lets its tone read from the glyph alone. A toolbar where each control is a saturated pill has no way left to say "this one is different"; the full palette still applies to the popover rows, where there is room.
apps/desktop/src/renderer/components/shared/BlockedAction.tsxThe blocked-action primitive: BlockedActionReason (id, title, detail, and the optional fix that clears it), BlockedReasons to render them inline, describeBlockedReasons for tooltip/a11y text, and BlockedActionButton, which takes the reasons themselves rather than a disabled boolean so a caller cannot disable a control without handing over the explanation. Exists because ADE keeps regrowing the same bug — a surface computes blockers, disables the primary button, and renders none of them.

Cross-machine Work drafts do not rebind the project tab. AgentChatPane freezes the selected OpenProjectBinding and uses it for model/auth discovery, slash commands, file search, attachments, parallel launch state, lane/chat creation, rollback, and recovery. A selected binding that is unresolved or disconnected falls back to the bound/This computer machine after the catalog resolves, so a disconnected Studio cannot block local sends or attachments. While the catalog is loading, the persisted selection is preserved; when it is unavailable after resolution, the picker remains visible even with one machine and offers an unavailable row plus Use this computer. Changing the selected machine within the same draft copies local image attachments to the new runtime and keeps portable image URLs. Non-image files and machine-owned tool context are removed because their paths cannot cross runtimes safely. Sending waits for the image copy to finish; a failed copy leaves the source images in place and requires the user to switch back or remove them. Restoring a draft after a project-tab switch first reconciles the tab's selected machine, so hydration does not remove or transfer attachments as though the user changed machines. Browser, App Control, and iOS panels also fail closed when their local-only service cannot safely operate on the chat's owning runtime.

Pane layout

AgentChatPane is the mount point. It:

  1. Subscribes to ade.agentChat.event for the current session and accumulates envelopes into local state.
  2. Derives:
    • Message rows via chatTranscriptRows.ts.
    • Active/idle turn state via chatTurnState.ts; terminal transcript evidence takes precedence over an eventually consistent active summary.
    • Pending inputs via pendingInput.ts — a raw derivePendingInputRequests pass, then a read-time resolvePendingInputs join against the session summary in the resolvedPendingInputsBySession memo. Every reader goes through the memo; nothing reads the raw derivation.
    • Todo items via deriveTodoItems() in chatExecutionSummary.ts.
    • Scheduled/background work via deriveScheduledWorkSnapshots().
    • Subagent snapshots via deriveChatSubagentSnapshots().
    • Turn diff summaries via deriveTurnDiffSummaries().
  3. Resolves a ChatSurfacePresentation (standard, resolver, worker thread, activity feed) to drive header title, accent color, chips.
  4. Mounts the header, message list, composer, and the appropriate side panels based on the session's executionMode and capabilities.

The ChatSurfaceShell wraps everything with a floating header (backdrop blur + subtle glass-morphism), a body region using the theme --color-bg, and a footer that contains the composer.

Floating PR and Chat Actions panes may reserve horizontal gutter when they would otherwise cover the centered chat column. That reservation belongs only to the selected-session branch that mounts those panes. The empty/draft branch uses a zero reserve even when the lane's persisted PR-pane preference is open, so a new-chat composer is centered rather than shifted for invisible chrome.

Empty draft surface

The draft (new-chat) branch is three elements at its core, not seven: the wordmark, the composer, and a launch shelf tucked under the composer, with the activity module below. There is no standing "Start a new conversation" caption — the wordmark already identifies the app, so the line was a band of vertical space spent restating what the user could see. Only a non-default mode still writes a line there (isOrchestratorDraft renders "Orchestrate a swarm of agents"), because that names something the surface does not otherwise show.

The column's row order is wordmark, the optional orchestrator heading, composer, launch shelf, activity module, then the ImportFloatingBadge import hint as the last row. The hint used to sit between the composer and the shelf, where it wedged itself into the shelf's row and overlapped it in a narrow window; at the bottom it reads as a hint below everything it can act on rather than a band cutting the stack in two. Every row below the wordmark is shrink-0 — the wordmark is the only row meant to absorb overflow in this height-capped flex column, and it does so by filling its aspect-ratio box and letterboxing inside it (h-full w-full object-contain). Sizing it h-auto max-h-full instead left the image at its natural height once the box shrank, so a short window cropped the wordmark against the enclosing overflow-hidden wrapper rather than scaling it down proportionally. The badge renders zero nodes when it is dismissed or has no project, so a retired hint spends none of the column's gap-3; both are pinned by tests.

The shelf holds everything that answers where this runs, as two adjacent dropdowns plus two labelled actions: DraftMachinePicker, then a LaneCombobox (mounted compact, so its 28px trigger matches the composer pills above it), then Shell and Import. The composer sits above it at z-10.

Machine and lane are separate controls on purpose. Folding them into one list made that list carry two orthogonal choices: every lane row had to name its machine, and the list grew by machine count rather than staying the length of one machine's lanes. Choosing the machine first keeps the lane list flat, short, and scoped — DraftMachinePicker renders nothing below two machines, and AgentChatPane passes draftShelfLanes (bare lane ids for the selected machine) rather than the machine-qualified option ids the combined selector needed. A draft therefore has no machine label in its toolbar: the shelf is the live machine control. Once a chat exists, the toolbar instead shows its owning machine as a read-only execution label; moving it remains Chat actions → Handoff → Continue on another machine.

handleMachineChange in useDraftMachineRouting.ts re-points the lane to the target machine's primary without touching draftLaunchTargetId, so a draft sitting on "Auto-create lane" keeps that target and only its underlying machine moves — auto-create and primary are the two targets ADE guarantees on every machine running it, so neither needs the user to re-choose. A machine with no lanes at all falls back to AUTO_CREATE_LANE_OPTION_ID rather than erroring or leaving the picker blank.

Shell and Import keep text labels. As icon-only buttons they were unreadable — that was a symptom of solving the wrong problem (compressing controls to fit a shelf that was simply too tall).

LaneCombobox itself was reworked for this: a single-line trigger (lane dot, name, branch, caret) instead of a 40px two-line block, lane colour reduced to the dot so the control sits in the same neutral ghost family as its neighbours, and a search field. Branch text truncates before the lane name via flex shrink factors — the branch is context, the name is the label. Two invariants are pinned by tests: fullWidth emits no max-w-* and nothing inside establishes a min-content floor (the measured narrow-pane overflow this component regressed before), and computeLanePopoverPlacement clamps on both axes including the case where neither side fits. The popover has no exit animation on purpose — a body-portal node that outlives open by a frame leaks into whatever renders next, which is how its search field started colliding with unrelated getByRole("textbox") queries in the chat suites. Its machine-grouping support still exists for other callers; the shelf simply never triggers it.

Its chrome is the .ade-chat-launch-shelf class in renderer/index.css. The shelf cancels both the parent stack's 12px gap and the composer's 12px wrapper margin, overlaps the painted composer by one pixel, and omits its top border. The composer's bottom edge is therefore the shared edge while the shelf supplies the continuing sides and lower corners; no background-colored seam sits between the two surfaces.

Keep the margin and padding in that CSS rule, not on the element. They lived briefly as Tailwind arbitrary values carrying CSS variables (-mt-[var(--chat-radius-shell)], pt-[calc(var(--chat-radius-shell)+4px)]) — exactly the kind of class that can fail to compile with no error. When the negative margin silently vanishes, the shelf detaches from the composer. Nothing in the type system or test suite catches that geometry.

The nesting is also load-bearing: Shell and Import both act on whichever lane is selected in this shelf, so presenting them inside it encodes a dependency the earlier stacked layout inverted — lane selection used to sit below two buttons that could not work without it.

  • Session title from chatSessionTitle(); falls back to "New chat".
  • When the session is attached to a lane, a lane navigation button renders the lane's label with a branch icon. Clicking navigates to the lane in the Lanes tab via the app store.
  • CTO and resolver surfaces override the title and chips through ChatSurfacePresentation (assistantLabel, accentColor, chips).
  • Ambient lifecycle affordances. AgentChatPane passes the selected session id to WorkSurfaceHeader as snoozeSessionId, which mounts the optional SessionSnoozeChip from renderer/components/work/SessionLifecycleChips.tsx. Settled state is rendered once as the compact ChatLifecycleBanner pill floating above the composer; it is not duplicated in the header. The snooze menu calls wakeSessionNow, and the banner's Un-settle action uses the shared lifecycle action path. Both read the same local per-project session cache, with a root cross-machine snapshot fallback for a foreign chat, and the same canonical helpers as the Work sidebar, so the visible state stays consistent. The slot remains above the composer by design: AgentChatPane also renders <LaneBranchDriftStrip laneId={laneId} /> there and arms it (armLaneBranchDriftWarning) on submit so a turn about to run against a worktree whose HEAD wandered off the lane's branch warns first. See Terminals and sessions and Lanes › Branch drift.

Composer

AgentChatComposer supports:

  • Post-failure recovery. A failed/interrupted turn releases the input and send controls. Capacity and usage-limit cards can resend the original user prompt (with its attachments, context items, and metadata) in the same durable provider thread, or explicitly open the model picker before the user starts the next turn. Recovery never starts automatically and is disabled while another turn is active.

  • Deferred model handoffs. Choosing a different model in an existing chat updates the composer locally but leaves the committed session and provider runtime unchanged until the next message is sent. Permission, reasoning, and Fast Mode controls for that pending model stay local too — writing them immediately would apply the new provider's fields to the still-bound previous provider and snap the picker back to a default. The pending pick is scoped to that chat: switching to another locked Work chat hydrates the incoming session instead of carrying the pick across. Send applies the existing handoff together with those pending native controls. A model_handoff divider is recorded only when the top-level provider group actually changes — claudecodex is a handoff; Claude Opus → Claude Fable is not, and neither is swapping the vendor model fronted by an aggregator provider (OpenCode, Cursor, and Droid each collapse to a single group). A same-provider switch is still a full model change: it tears down and rebinds the provider runtime and re-adopts the title, it just emits no handoff, because a divider with the same logo on both sides says nothing. When one is emitted, desktop draws the divider with the previous and current provider marks (20px marks, one baseline) and keeps the current provider mark stacked above prior handoff marks on the Work session card. The TUI prints [model] Codex → Claude. iOS draws its own logo → HANDOFF → logo divider. All three renderers additionally drop a same-provider pair at render time, so an older transcript that already carries Claude → Claude stops showing it.

  • Text input with auto-grow up to composerMaxHeightPx. Grid tiles pass a fixed 144 px ceiling (computed statically from layoutVariant) rather than the old ResizeObserver-based 28 %-of-height formula; that eliminated the observer churn without changing the visible ceiling for normal tile sizes.

  • Prompt stashes. Cmd/Ctrl+S stores the current prompt text in the project runtime and clears the composer only after the runtime confirms the write. The bookmark control immediately left of the context-usage meter performs the same action; invoking either path with an empty composer opens the stash list instead. Restore immediately puts the saved text back into the composer, then consumes the shared row; the runtime acknowledgement never overwrites edits made while a connected desktop is responding. If that delete cannot be confirmed, ADE favors a harmless duplicate stash over losing draft text. ArrowUp from a new draft preserves it through the selected chat's existing stash flow before loading the newest sent prompt; ArrowDown past that history restores the draft. Up to 20 entries are retained, newest first. The CRR-backed prompt_stashes table makes the list available to other synced runtimes, while renderer calls always route through the currently bound local or remote project runtime. File attachments and visual context are deliberately not stashed because their paths can be machine-specific. Appearance > Prompt stash button can hide the bookmark; the keyboard shortcut remains active. iOS exposes the same per-project stash through the composer overflow menu (WorkPromptStash.swift) over chat.listPromptStashes / chat.createPromptStash / chat.deletePromptStash; those actions are optional in mobile compatibility so an older host omits stash instead of going limited. Personal chats hide stash. The TUI does not expose this feature.

  • Smart links. Once an HTTP(S) or ade:// URL is completed by paste, whitespace, or paragraph insertion, the rich editor replaces its visible run with an atomic violet chip. Each chip shows the provider's real brand mark — the GitHub octocat, the Linear mark (shared LINEAR_LOGO_PATH), and an ADE monogram — rendered as an inline currentColor SVG by smartLinkChipMark.ts; generic pages show a globe until they asynchronously adopt a bounded page title and favicon from chat.resolveSmartLinkPreview (a resolved favicon replaces the globe). The provider catalog in smartLinks.ts still supplies the compact text label beside the mark. The literal URL remains draft and is what the agent receives. Hover/title reveals the full URL; click, Enter, or Space opens Copy link / Remove link; Backspace/Delete removes an adjacent or focused chip in one operation. The rich contenteditable is explicitly left-aligned so a pasted link (which swaps the textarea for the rich editor) cannot inherit a centered ancestor's text-align.

  • Focus-on-active. The composer receives focus whenever the enclosing AgentChatPane reports isTileActive: true (for packed grid tiles) or any equivalent active state — typing in the grid immediately targets the focused tile's composer.

  • Attachments via drag-drop in the composer or anywhere in the selected chat surface, paste, and an inline picker. Pasted and dropped image files show a pending thumbnail while ADE writes the temp attachment. Electron clipboard images use ade.app.saveClipboardImageAttachment when available so the main process can save the PNG and return a small preview without sending the full base64 payload through the renderer; the legacy ade.agentChat.saveTempAttachment path remains as the fallback. On macOS, .heic and .heif file drops are converted to JPEG through the main-process image bridge before the temp attachment is saved, so the preview and provider payload use the converted bytes. Windows and Linux do not bundle a HEIF codec; those drops stay out of the attachment list and show a clear instruction to convert the photo to JPEG first. Before reading anything, the composer asks the machine that owns this chat how it stages attachments — once per batch, because the answer is a property of the machine and re-asking mid-batch would only widen the window in which half the files were staged under a different contract. The answer is a ChatAttachmentStagingMode, a mode plus the ceiling that goes with it, so the renderer never hardcodes a cap the chosen transport then rejects halfway. There are three: copy (the chat's machine is this one, so only the path moves), upload (a paired host advertising the streamed HTTP route), and base64 (everything else). copy is decided in preload rather than by the main process, because it is a property of the caller, not of the target. A drag-dropped or picked file carries a real disk path (window.ade.project.getDroppedPath, backed by Electron webUtils), so under copy or upload it is staged by path — the bytes never enter the renderer — and any file type up to 50 MB is accepted. Clipboard pastes, HEIC files the renderer converts first, and every file in the hosted web client have no path, so they take the base64 leg with its 10 MB ceiling whatever the machine said. The image MIME sniff on that leg runs on the sync host only (remote peers); the local IPC/action handlers check size and extension shape. When staging fails for a reason a retry could fix, the composer keeps the file handles and offers Try again beside the error; a size rejection does not, because retrying it cannot succeed, and neither does a HEIC the renderer could not convert, which would fail the same way every time.

  • Issue context. The composer attach menu (~180px, opaque, Linear and GitHub rows, no subtitle) opens LinearIssueSelectModal or GitHubIssueSelectModal. GitHub is offered only when detectRepo() returns this project's repository; personal chats hide GitHub and still allow Linear. Each attachment is a linear_issue or github_issue context item built by makeLinearIssueContextAttachment / makeGitHubIssueContextAttachment from chatContextAttachments.ts. Sending the turn persists the issue on the chat session (attachLinearIssueToSession / attachGitHubIssueToSession); mixed Linear + GitHub attachments are allowed. GitHub PRs are not attachable. Clicking a chip reopens the same pane in details mode (Open + Remove). On send, the composer chip moves onto that user message. TUI /issue attach ADE-123|owner/repo#42|#42 (slash-only on Windows) attaches without a picker. iOS Attach issue in the chat overflow menu reuses the Linear pane in attach mode when lane.attachLinearIssueToSession is advertised. PR bodies get Linear Refs ADE-123 (closeOnMerge: false) and GitHub Closes owner/repo#42 (closeOnMerge: true) — see features/linear-integration/README.md.

  • Typed triggers anywhere. detectComposerTrigger(text, cursorPos) (shared/composerTriggers.ts) finds an in-progress /command or @file or multi-word @chat token that ends at the cursor — at any position in the draft, not just position 0 (fix @src/foo.ts then run /test). Both the rich contenteditable and the plain textarea consume it (as do the WorkViewArea continue-composer and the ade-code TUI, which import the same module). A matching multi-word suggestion remains available while following prose is typed, and selecting it replaces only the @ plus the selected label (composerTriggerForSelection), preserving that prose. Selecting a suggestion otherwise replaces exactly the trigger span (replaceComposerTriggerSpan); a lone leading command keeps the legacy fill-the-draft path so the local /clear intercept and argument-hint scaffold still work. Confirmed tokens render as chips: the rich editor inserts non-editable chip nodes (data-composer-chip-text, serialized back to their literal text), and the textarea renders a backdrop overlay that styles confirmed @path//command tokens while the textarea text goes transparent (overlay only mounts when at least one confirmed token exists). IME composition freezes trigger re-evaluation until compositionend.

  • Dismissed triggers stay dismissed. A suggestion search only narrows as its query grows — once nothing matched @cursor, @cursor agent cannot match either — so isComposerTriggerDismissed(trigger, dismissal) (shared/composerTriggers.ts, over a ComposerTriggerDismissal = Pick<ComposerTrigger, "type" | "start" | "query">) treats any extension of a dismissed query as still dismissed. Backspacing out of it, editing it into a different query, or typing a new @ elsewhere is a genuinely new search and reopens the menu. AgentChatComposer keeps the last dismissal in a ref — it gates the next open, never a render — and closes the menu three different ways: closeCommandMenu() forgets the dismissal because the trigger is resolved (a selection was made, the trigger is gone, the composer locked or reset); dismissCommandMenu(trigger) records one, for Escape (an explicit dismissal: typing the rest of the token must not bring the menu back), for Enter/Tab with no matching row, and for the menu's own dead-query report; and closeCommandMenuKeepingDismissal() leaves the record alone because this trigger cannot open a menu right now (it is already a confirmed token, or still covered by an earlier dismissal) but nothing is resolved and nothing new was dismissed — clearing the dismissal there would reopen the menu the user just escaped, and recording one would suppress the menu for a trigger the user never dismissed. The dead-query report itself is ChatCommandMenu's onNoMatches?(trigger), fired once when a non-empty @ query settles with zero rows; an empty query is a browse, not a search, so it never reports. useDebouncedSuggestions carries the query its results belong to and treats results from a previous provider or a previous query as discarded and still loading, because state written by this render's effects is invisible to consumers until the next render — loading alone would read "settled" for one frame after every keystroke and fire a false no-match.

  • Chip selection highlight. Rich-composer chips are contentEditable="false", so the browser skips them when it paints the native selection and dragging across one renders as two disconnected highlight runs. Every chip therefore carries a data-composer-chip attribute naming its kind (ios-context, app-control-context, built-in-browser-context, …), and a selectionchange-driven effect marks the chips the current range intersects with data-composer-chip-selected. index.css paints that marker as a translucent ::after overlay in the platform selection color, so the highlight reads as one continuous run. range.getRangeAt(0) is already start-before-end, so a backwards drag needs no special casing, and a range outside the editor simply drops the marks. The performance contract is load-bearing — see Chip selection marking must stay cheap.

  • File attach picker opened with the @ key. Runs ade.agentChat.fileSearch with a 40 ms debounce, a per-menu-session query cache (cache hits render same-frame and revalidate silently), and a sequence guard that discards stale results. The spinner only shows when there is nothing cached to display. The composer fires an empty-query fileSearch when it binds to a session, which the action bridge treats as a warm ping (fileService.warmQuickOpenIndex) so the lane's name index is built before the first real query.

  • Slash commands. Local commands (/clear, /login) are available where the current provider owns them and are resolved renderer-side. SDK commands, Codex prompt files (.codex/prompts/**/*.md), Claude command files (.claude/commands/**/*.md), and Agent Skill entries from .claude/skills, .agents/skills, .ade/skills, .codex/skills, inherited ADE_AGENT_SKILLS_DIRS, and bundled ADE skill roots merge in through ade.agentChat.slashCommands. Claude sessions use Claude SDK/runtime commands plus Claude-compatible command/skill files; Codex, Droid, Cursor, and OpenCode also expose the filesystem-backed prompt/skill list when their native runtimes do not auto-list it. Only /clear with source: "local" is intercepted client-side — every other command is sent to the agent verbatim so provider-native commands still flow. The composer also decides whether a leading-slash draft is a command or just a sentence via isProviderSlashCommandInput (heuristics in shared/chatSlashCommands.ts): "/rebase the lane?" is treated as chat text, "/plan" is treated as a command.

  • Model selection. ProviderModelSelector is embedded and filters the registry via filterChatModelIdsForSession. Switching within the allowed family is a normal update; crossing families triggers a handoff. Backed by the modular ModelPicker under renderer/components/shared/ModelPicker/ (see ModelPicker structure). Dynamic-runtime inventories (Cursor / Droid / OpenCode / Ollama / LM Studio) are no longer fetched on chat boot — the picker calls window.ade.agentChat.modelCatalog({ mode: "cached" | "refresh-stale" | "force", refreshProvider? }) and only triggers a runtime probe when the user actually opens the corresponding rail and the per-provider freshness TTL has lapsed (runtimeCatalogCache.ts: 30 min for Cursor / Droid / OpenCode, 30 s for lmstudio / ollama). Cursor runtime rows carry cursorAvailability, so chat surfaces hide CLI-only models while Work CLI setup includes them and hides SDK-only/chat-only rows.

    Everything a prompt box offers is scoped to the machine set in that prompt box. A Work tab unions chats from every machine on the account, so the machine a chat runs on is frequently not the one the project tab is bound to — and a runtime catalog is a machine fact (local ollama / LM Studio endpoints, the installed cursor-agent, the opencode inventory). AgentChatPane always passes activeComposerRuntimeBinding (the session's chatRuntimePin or the draft shelf's machine, never collapsed to null) to AgentChatComposer as modelRuntimePin, even when that machine is also the project tab. The picker routes agentChat.modelCatalog(args, pin) to that machine and caches the result under that binding key. Collapsing same-as-tab composers into the shared "" bucket let a global-tab switch poison the prompt box with Electron's static OpenCode inventory (or the previous tab's leftover). During a project-tab switch, modelCatalog and getAvailableModels stay on the runtime instead of falling through to that in-process registry. Tools / git / iOS / App Control still collapse to null when they match the tab so they keep the bound IPC path. On ADE Web a foreign pin is rejected by assertWebRuntimePinRoutable (single-machine adapter) and the picker falls back to the pin-scoped model list. When a caller passes availableModelIdsOverride, AgentChatPane constrains selection to exactly those ids: filterChatModelIdsForSession({ includeActiveSessionModel: false }) skips the usual "preserve the active model even if it's not in the list" rule, the runtime-catalog merge is bypassed, and AgentChatComposer is rendered with constrainModelSelection={true} so the ModelPicker opens with constrainToAvailableModelIds. The picker drops registry expansion (no "Show all models" suggestion) and the picker rail and the composer both refuse to commit a value outside the allowlist. A matching constrainedModelSelectionError blocks submit, draft auto-create, and parallel launches if the current model or any parallel-slot model fell off the allowlist — main and slot setters also no-op on out-of-list ids instead of silently bouncing. Handoffs create a root-store HandoffLaunchJob before the IPC call starts, label it by mode (preparing-summary for a brief, forking-history for a fork) while the old surface closes, and remove it once the new chat is created or the handoff fails — or hide it earlier as soon as a matching real session row appears in the sidebar (handoffJobLikelyMaterialized), so an in-flight handoff never reads as two sessions with one vanishing (ADE-122). When the source provider is fork-capable (providerSupportsHandoffFork: Claude, Codex, OpenCode, Droid, or Cursor) the local handoff surface exposes both Brief and Fork modes, defaulting to fork. Fork keeps the new chat in the source lane and on the same provider — the fork model picker is constrained to that provider's models — while a brief can retarget any active lane in the project (via targetLaneId) or a freshly created lane. Claude forks the SDK session pointer, Codex the app-server thread (thread/fork), OpenCode session.fork, and Droid forkSession(). Cursor's fork is an ADE-side transcript replay rather than a provider fork, so providerForkReplaysTranscript swaps the panel's three copy slots (subtitle, body, footnote) for wording that says the whole conversation is replayed into a new Cursor agent instead of promising a copied provider thread — the claim has to match what actually happens, because a Cursor thread cannot be resumed twice. The surface also includes an optional handoff note textarea; blank notes are ignored, brief handoffs append non-empty notes to the hidden handoff prompt, and forks send the note as the first user turn. Codex handoff targets do not inherit ADE session goals or seed app-server goals; forked Codex threads are cleared through the goal RPC before user input is sent. The Work sidebar renders the job as a non-selectable placeholder in the same lane/status/time groups as real sessions. Orchestration model-selection requests use ChatModelSelectionPendingCard instead of the inline selector: the card is an agent briefing first (role/tag, description, files, dependencies) and a model choice second. It intentionally starts with no recommended model so the user makes the routing decision explicitly.

  • Reasoning effort. A standalone ReasoningEffortPicker (extracted from the model row) is rendered next to the model trigger when the active descriptor exposes reasoningTiers. The picker remembers the last-used effort per model family via the useReasoningByFamily hook. The control is a real pointer slider: click or drag previews the position and release snaps to the nearest model-supported tick; arrows, Home, and End provide the same keyboard path. The filled portion uses a progressive low-to-high gradient and the active tier keeps the existing pulse/colour treatment. A tier choice does not close the popover — the user can compare levels until clicking outside or pressing Escape. The collapsed trigger uses the full tier label on normal-width composers (for example, Medium rather than MED) and removes the nested label outline because the trigger already supplies the interactive boundary. Narrow/mobile layouts retain the abbreviated label to preserve space. GPT-5.6 displays Light / Medium / High / Extra High / Ultra; ordinary Max is hidden for that family, and Ultra explains that it can delegate to multiple agents and use limits faster.

  • Voice dictation. When voice input is enabled and the bundled model is installed, a mic button appears beside Send. Capture is owned by the app-global globalVoiceRecorder, so recording survives composer unmounts and tab/pane navigation. The recorder down-samples mic input to 16 kHz mono PCM, calls the main-process transcription service, runs deterministic glossary cleanup, inserts the cleaned transcript at the registered composer's cursor, and copies the same text to the clipboard as a recovery path. The composer pill and top bar pill both observe the root-store dictation slice, so their timer and waveform stay in sync. macOS permission requests use systemPreferences.askForMediaAccess; Windows reads Electron's media access status and blocks only explicit denied / restricted results. The error card directs Windows users to Settings > Privacy & security > Microphone and names both device access and app access instead of showing macOS System Settings copy.

  • Fast mode. Toggles the legacy-named codexFastMode bit for the selected session. Fast mode is a property of a model, so the toggle lives on the model row inside the shared ModelPicker rather than as a separate composer chip — every surface that mounts the picker gets it, and the composer toolbar keeps one control where it used to spend two. The row chip renders whenever that descriptor advertises serviceTiers: ["fast"] (dynamic Cursor SDK/CLI rows, GPT-5.6, and older fast-capable Codex entries) and the caller supplied onFastModeChange. Fast is one bit per surface but it belongs to the model it was enabled for, so a chip reads as on only on the selected row (fastModeOn={fastMode && isActive}) — never on every fast-capable row at once. Clicking the selected row's chip is a plain on/off toggle that neither closes the picker nor re-fires selection; clicking a non-selected row's chip means "use this model, fast" — one press commits the model selection and turns fast on. A plain row click onto a different model clears the previous model's fast bit rather than inheriting it. The chip's states are rest (muted outline, outline lightning) → hover (darker fill, still off) → press (active:scale, suppressed under prefers-reduced-motion) → on (violet fill, filled lightning). The collapsed trigger names the state rather than showing a separate indicator — composeModelPickerTriggerLabel() in ModelPicker.tsx renders "GPT-5.6 Terra Fast", with a filled lightning glyph rendered before the model name (aria-hidden, so the accessible name stays text-only). Codex state flows into the next thread/start / turn/start as serviceTier: "fast"; Cursor SDK state flows through the discovered model-parameter selection, and Work CLI launches resolve fast Cursor rows to the matching *-fast alias. Parallel mode passes the per-slot setter through the slot's own picker (onParallelSlotCodexFastModeChange).

    Surfaces not yet migrated (ModelSelector, ReviewLaunchModelControls, CtoSettingsPanel, ChatModelSelectionPendingCard, ProjectlessComposer) still pass the deprecated fastModeActive / onFastModeToggle pair, which keeps rendering the old sibling chip. Migrating them is a prop rename with nothing else to unwind.

  • Overflow control. Issue context, orchestrator mode, parallel models, and the iOS Simulator / App Control drawer toggles are folded behind one trigger (ComposerOverflowMenu). Each entry is gated by exactly the condition that used to gate its standalone button, so a control that would not have rendered does not become a row, and with no entries left the trigger disappears entirely.

    How many entries survive is contextual, not fixed — a Work CLI draft hides the lane tool drawers (hideLaneToolDrawers) and has no orchestrator, so it can be left with one. A that opens onto a single row is a menu pretending to be a button, so at items.length === 1 the control renders that entry directly as an icon button instead. Callers must therefore not assume either form; tests reach it through a helper that accepts both.

    Because folding hides active state, the collapsed trigger carries an accent dot whenever any entry is on, rows report aria-checked, and an entry may carry a badge count (issue context uses it for attached issues) which surfaces on the inline button too. Rows that open their own portal — issue context — position against the triggerRef rather than against the row, because the row unmounts with the menu while the trigger stays mounted.

    The menu itself portals to document.body via composerSplitMenuPosition, like every other composer popover. The composer shell clips its overflow, so an inline-absolute menu is cut off at the prompt-box edge and simply cannot be read.

  • Send options. Background launch is the second row on Send's caret, and Send is a split control: one rounded-full body, the arrow on the left, a hairline divider, a caret sharing the same fill. This is deliberately the same shape as ActiveTurnSendButton, so the composer uses one send idiom whether or not a turn is running. The earlier arrangement — a second filled circle beside Send, also carrying an arrow — read as one control accidentally duplicated and overflowed the composer's padding, clipping against its rounded edge. The split renders only when onSubmitInBackground is supplied and the surface is neither parallel nor Cursor-Cloud mode; otherwise Send stays a plain circle.

  • Attachments. Allows the user to attach files and artifacts to the next turn.

  • Permission controls. Inline with the composer:

    • Interaction mode selector (default / plan).
    • Claude permission mode — a trigger button that opens a popover picker with four tone-coded options: Ask permissions (default, green), Accept edits (blue), Plan mode (purple, read-only turns), Bypass permissions (red). Tone styles live in CLAUDE_MODE_TONE_STYLES.
    • Codex preset modes (Plan / Guarded Edit / Full Auto) — trigger button + popover list. Custom and config-toml configurations appear as a non-selectable "Custom" row with the active summary tooltip, so the trigger can always show the effective preset.
    • OpenCode permission mode selector.
    • Cursor mode snapshot + config options when on Cursor.

    All shared permission dropdowns render only each mode's title in the row. The longer explanation remains available as the row tooltip/title instead of forcing the popover to become a wall of text.

    Both the Claude and Codex popovers render via createPortal into document.body and are positioned with getBoundingClientRect + window.innerHeight. That keeps them visible when the composer is inside an overflow-hidden container (grid tiles, shells). Clicking outside or pressing Escape closes them; the outside-click handler checks both the anchor ref and a data-*-picker-dropdown attribute on the portalized list so clicks inside the popover don't self-close.

  • Parallel launch controls. When the Work pane mounts an empty, embedded draft composer with no locked or initial session, the composer can switch into parallel mode. Parallel mode shows a slot list instead of the single-session model selector; each slot captures the model, reasoning effort, execution mode, and provider-specific permission controls that will be copied into that child session. The first two slots are cloned from the current composer defaults. Users can add/remove slots and open one slot at a time for detailed controls. Send is enabled only when the draft is non-empty and at least two model slots are configured.

    Attachments in parallel mode are capped by PARALLEL_CHAT_MAX_ATTACHMENTS = 12. The same attachment list is sent to every child lane, so the cap is enforced both when toggling parallel mode and when adding files.

  • Work auto-create launch behavior. Auto-created lanes are named deterministically from the prompt (createDeterministicAutoLaneName) and created immediately — naming never sits on the critical path, so there is no 10 s suggest race anymore. When AI titles are enabled, startBackgroundLaneNaming asks the main process for a structured lane title + branch identity in the background. The deterministic fallback stays persisted for failure safety but is masked in lane-label positions by an animated Naming lane… state. The renderer retries the background naming pass once (750 ms apart), refreshes the completed identity before unmasking it, and reveals the fallback only when naming fails or produces no change. Branch uniqueness is resolved by the lane service. Each launch creates a DraftLaunchJob that tracks progress through creating-lane / starting-session / sending-prompt / ready / failed states (auto-create no longer has a distinct naming-lane phase — it goes straight to creating-lane). While the background pass runs, affected lanes are flagged in laneNamingStore so singleton cards, hover details, and grouped lane headers all show Naming lane…. The composer is cleared optimistically when the job starts so the user can begin composing the next prompt immediately; the DraftLaunchSnapshot freezes the model, reasoning effort, execution mode, and native controls at capture time so the async create/send flow uses the settings the user had when they pressed Send. Jobs are stored in appStore.draftLaunchJobsByScope, scoped by project, lane, surface profile, and Work draft kind, so loading/error strips survive a new chat pane or remount without leaking into another lane pane. Foreground launches auto-open the result only if the job is still the latest foreground job (tracked by latestForegroundDraftLaunchJobIdRef); background launches keep the current Work focus and render a job strip with an Open action once ready. Failed jobs offer a Restore button that merges the snapshot back into the composer, and the top error banner mirrors Restore when its message matches that failed job. Active jobs remain visible, and stale active jobs can be hidden if the backing async never settles; terminal rows are pruned per scope.

  • Border beam. On standard (non-grid-tile) layout the composer shell is wrapped in BorderBeam (colorVariant="colorful" at rest, "ocean" with a slower duration while a turn is active). active toggles off for quiet, mid-conversation states.

  • Pending steers. When steers are queued during an active turn, the composer renders a pending-steers section above the input area with per-message controls. Each PendingSteerItem displays a "Sends after turn" badge plus the message text. The action strip (data-testid="pending-steer-actions") hides itself behind hover only inside @media (hover: hover); a touch pointer never hovers, so on mobile and ADE Web the controls stay visible. Both the hidden state and the group-hover reveal sit inside that one media query, and group-hover wins on specificity, so the reveal never depends on stylesheet order. Actions per entry: edit (guard-cancel the queued entry with requireQueued: true, then merge its text, file attachments, and structured context attachments into the main composer so the user can revise it and choose a delivery mode again), cancel (ade.agentChat.cancelSteer), and the immediate-dispatch actions the session's provider accepts per ACTIVE_TURN_DISPATCH_MODES: send during turn (ArrowBendDownRight) and interrupt (Lightning). Send during turn dispatches the queued message into the active turn via ade.agentChat.dispatchSteer({ mode: "inline" }); the user message then appears in-transcript with deliveryState: "inline"; the service pushes an SDK message with priority: "next" and shouldQuery: true. Claude's Interrupt & send calls dispatchSteer({ mode: "interrupt" }), which uses SDK priority now to redirect the current model step without tearing down the Claude query. Cursor sessions get the interrupt action only, labelled Interrupt & continuedispatchSteer({ mode: "interrupt" }) there promotes the staged row to the cancel-and-resend redirect, and "inline" is rejected. The tooltips and the hint above the staged list follow the same table and name the real provider (stagedSteerHint), so a Cursor session reads "Interrupt with this message, edit or remove." rather than promising an inline send. Both buttons are hidden for the remaining providers (Codex, OpenCode, Droid, Pi), which only support post-turn delivery — and for those the hint says so outright ("Codex cannot take a message mid-turn, so this one waits for the turn to end."). That sentence keys off capability.modes, not the wired handlers, so a Claude chat whose dispatch handler is merely unwired never claims Claude is queue-only.

  • A cancel that fails is reported. onCancelSteer catches the rejection and raises "Couldn't remove the queued message: …" in the pane error banner. A swallowed rejection read as a cancellation that never happened while the agent still sent the message.

  • Mid-turn split Send button. While a Claude or Cursor turn is active, the composer's primary send control is a split button (ActiveTurnSendButton, Claude Code parity). The caret selects a delivery mode without sending; the primary click and Enter execute the selected mode, and the icon, tooltip, and accessible label follow it. Which modes appear is the canonical per-provider table ACTIVE_TURN_DISPATCH_MODES in apps/desktop/src/shared/types/chat.ts (read through activeTurnDispatchModes / defaultActiveTurnDispatchMode; the composer's activeTurnSendModesForProvider only layers the copy on top, and the chat pane, the main service's steer/dispatch guards, the ade code TUI and the iOS WorkActiveSendCapability mirror all read the same table): Claude offers Send during turn / Send after turn / Interrupt & send and defaults to Send during turn; Cursor offers Interrupt & continue / Send after turn and defaults to Interrupt & continue. Cursor has no Send during turn because its SDK exposes no mid-run message API — the redirect cancels the run and resends on the same agent thread, so the label says "continue" (that per-provider fact is activeTurnInterruptContinues, beside the table, which the composer, the TUI and the iOS mirror all read). Mode descriptions name the actual provider ("Stop and redirect Cursor now."). The selection is held for the session and re-normalized when the provider changes, so a mode the new provider cannot honor can never stay selected. A mode this pane has no wired handler for — reachable while a model for another provider is picked mid-turn, since the menu follows the picked provider and the handlers follow the live session — downgrades to queueing rather than dead-ending, so Enter and the primary button always deliver the draft somewhere. Immediate modes are a single atomic steer({ dispatchMode }) call rather than queue-then-dispatch. The primary action disables on an empty draft, while the caret remains available so the user can inspect or change the delivery mode. Providers with no atomic active-turn dispatch (Codex, OpenCode, Droid, Pi) keep the single queue-on-send affordance, and a queued Cursor message still gets the plain "Message queued — will be sent when the current turn completes." notice.

  • Queue-aware Stop button. In an active Claude chat, Stop becomes a compact split control whose menu is the four-mode matrix in apps/desktop/src/shared/chatStopModes.ts: Turn only (stop_only), Turn + queue (stop_and_clear, the backward-compatible default, trash icon), Turn + background (N jobs) (stop_and_background), and Turn + queue + background (N jobs) (stop_and_clear_and_background). The live job count is the same activeBackgroundTaskCount the session summary already exposes. Default Stop does not tear down the Claude query or its background jobs once perTaskStopAffordance is declared; square stops on Chat Info / spawn cards call ade.agentChat.stopTask for one taskId. The selection is stored per chat, drives the primary button and Cmd+., and the custom portal menu dismisses as soon as an option is selected. A successful clear that cancelled ADE-attributed messages produces one transcript Undo card for eight seconds; Undo calls restoreCancelledQueue and restores the original queued payloads. With no queue, or on providers without this contract, Stop remains the single-action interrupt button. The TUI /stop command accepts the same four modes (hyphen aliases keep-queue, clear-queue, background, clear-and-background); it has no per-task stopTask control — use ade chat stop-task <session> <taskId>.

  • Context meter lifecycle. ContextUsageDial shows a percentage only for state: "measured". During compaction it shows an ellipsis and explains that the last exact reading is hidden; after a boundary without an exact post count it stays recalculating; a failed authoritative read shows ? / unknown. Streamed usage can move the dial during a turn, but the control-channel snapshot after settle/compaction is the authoritative value. On Claude, Codex, and Pi, an idle measured dial is a compact control: click sends /compact without replacing the unsent draft. Cursor, Droid, and OpenCode stay read-only. Compacting while a turn or pending input is live is disabled; ADE already auto-compacts Claude at 97% at a turn boundary.

  • Question answering. When a question-type pending input is active, the question card replaces the composer textarea (AskQuestionComposer) and the model / permission / effort footer is hidden until it resolves. Selecting an option marks it and never submits; Next / Enter advances. A selection and a typed note both travel, selection first — see Answer semantics. Multi-select questions render a toggle ledger plus a fixed-height preview pane (sanitised via ReactMarkdown + rehype-raw + rehype-sanitize + remark-gfm), disclosed by an explicit click rather than hover or keyboard focus.

  • Composer lock while pending input is unresolved. When pendingInput.blocking is set, the composer hard-locks: the textarea / rich editor are disabled, attachment, slash-command, and edit affordances are gated, the placeholder switches to a "resolve the pending request above" hint, and Enter is a no-op (Escape cancels the request). The same gate runs server-side: agentChatService refuses sendMessage, queued steers, and dispatchSteer while a live pending input exists, throwing "Answer or decline the pending request before sending another message.". AgentChatPane.submit mirrors the message into the composer's error banner so a fast double-Enter doesn't silently drop the second send.

Layout variants

AgentChatComposer accepts a layoutVariant prop:

  • "standard" -- full-width composer (default).
  • "grid-tile" -- constrained for packed grid tiles; composerMaxHeightPx limits auto-grow.

ModelPicker structure

The desktop ModelPicker under apps/desktop/src/renderer/components/shared/ModelPicker/ is split into focused modules. Each piece is independently testable; the same modules power the TUI picker (apps/ade-cli/src/tuiClient/components/ModelPicker/).

ModuleRole
ModelPicker.tsxTrigger + popover entry point. Owns runtime-catalog loading via runtimeCatalogCache, fast mode, and the favorites/recents fan-out. Pass fastMode + onFastModeChange and the picker owns the affordance: a per-row Fast chip inside the popover plus a <Model name> Fast trigger suffix composed by the pure composeModelPickerTriggerLabel helper. Surfaces that pass neither render no fast affordance at all; the deprecated fastModeActive / onFastModeToggle / fastModeSupported props still render the old sibling chip for call sites that have not migrated.
ModelPickerContent.tsxThe popover body: search bar, rail, virtualized list (@tanstack/react-virtual), empty state. Props include hidePermissionRail (forward-compat hook for orchestrated surfaces that suppress permission-related affordances), allowCliOnlyModels (switch Cursor filtering from SDK chat models to CLI launch models), allowRegistryExpansion (when false, skip merging MODEL_REGISTRY entries into the runtime catalog), registryFilter (restrict registry expansion by descriptor, used by fork handoffs to keep the provider fixed without freezing the picker to a stale concrete-id list), and runtimePin (the prompt-box / chat machine, forwarded into auth and OpenCode-installed probes). When the authenticated-only filter is active, authenticated CLI-backed providers (Claude, Codex, Droid, Qwen, Kimi, Grok, Copilot) may expand from the static registry even if the last discovered model-id list is incomplete. The left rail always includes those ACP families (plus Cursor / OpenCode / local runtimes) so they stay reachable before catalog refresh; Favorites only lists starred models. Estimated row height MODEL_ROW_ESTIMATED_HEIGHT = 44.
ModelPickerRail.tsxLeft-rail tabs (Favorites, Recents, Anthropic, OpenAI, Cursor, OpenCode, Pi, GitHub Copilot, Grok, Droid, Kimi, Qwen, Ollama, LM Studio). The desktop, hosted renderer, TUI, and iOS catalog keep this provider order (Cursor is omitted on unsupported Windows ARM); reads AuthStatus per family to render auth gates and the OpenCode "Install OpenCode" CTA from providerEmptyState.
ModelListRow.tsxA single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). Also renders the muted Fast chip when the surface supplied onFastModeChange and modelSupportsFastMode() holds for that row's descriptor; toggling it changes neither the selection nor the popover's open state.
ReasoningEffortPicker.tsxStandalone reasoning-effort dropdown, mounted next to the model trigger and inside per-slot parallel-launch controls.
modelCatalog.tsdescriptorsFromAgentChatModelCatalog, mergeSelectorModels, resolveModelDescriptorWithRuntimeCatalog, createUnknownModelPlaceholder — pure helpers that flatten the IPC catalog into a ModelDescriptor[] and reconcile it with the static registry while preserving runtime metadata such as serviceTiers and Cursor cursorAvailability. All four take the same optional catalog scope key as runtimeCatalogCache.ts: descriptors are remembered per machine because a catalog's reasoningEfforts (the thinking-level ladder) and context window are machine-reported. There is deliberately no fallback to another machine's bucket — answering a miss from the bound machine is the same cross-machine leak the bucketing exists to prevent. A miss falls through to the static registry and then to createUnknownModelPlaceholder: correct-but-generic beats confident-and-wrong.
modelOrdering.tssortModelItems — provider/group ordering and intra-group ranking (favorites first, then recents, then default registry order).
modelPickerSearch.tsscoreModelPickerSearch — fuzzy search across display name, family, provider, and ids; ranks favorites/recents above strict matches.
providerEmptyState.tsxPer-provider empty/auth/install CTA copy. Surfaces "Install OpenCode" when the binary is missing, "Sign in to Cursor" when auth is missing, etc.
runtimeCatalogCache.tsRenderer-side catalog cache, bucketed per machine by binding key. DEFAULT_RUNTIME_CATALOG_SCOPE ("") is only for surfaces with no composer machine (Settings, an unpinned picker). Work composers always key by the prompt-box / chat machine's binding key, including when that machine is also the project tab, so a tab switch cannot overwrite the prompt-box catalog. Capped at 8 scopes. Each bucket tracks its own per-provider freshness (30 min for opencode/cursor/droid after a live probe, 30 s for lmstudio/ollama); a cached catalog that only has ADE's static OpenCode rows does not start that TTL. Each bucket also owns the descriptors parsed from its catalog, so one cap and one eviction govern both and a dropped scope cannot leave descriptors behind; the unpinned "" bucket is never evicted. Concurrent modelCatalog requests dedupe by `${scopeKey}
useProviderAuthStatus.tsResolves AuthStatus (ok / limited / unauthed / unknown) per ProviderFamily from the runtime-binding-scoped aiDiscoveryCache. Pass runtimePin so Work composers probe a foreign prompt-box machine (ai.getStatus / ai.isOpenCodeInstalled(pin)); identity is the pin's key, so a reallocated pin object does not re-probe. When that machine is the project tab, auth shares Settings' unpinned projectRoot cache instead of isolating into root::pin:<key>, and a tab switch to another runtime with the same root force-refetches that unpinned bucket (IPC stays unpinned; pin-scoped caches are left alone). A picker with no explicit providerAuthStatus seeds from the cached value, joins the shared single-flight refresh, and reacts to cache update/invalidation events — matching projectRoot with renderer arePathsEqual so Windows drive-letter case and separators still apply. Callers that already supply status opt out of the full fetch. The separate cheap OpenCode-binary probe is deduplicated by runtime/project scope.
useAuthOnlyFilter.tsHides models whose provider is not authenticated, with a toggle for the catalog browse mode.
useModelFavorites.ts / useModelRecents.tsCross-surface favorites and recents persisted to the per-project ade.db tables model_picker_favorites and model_picker_recents via the modelPicker.* JSON-RPC methods on adeRpcServer. Desktop, TUI, and iOS share the CRR-backed store; the legacy ~/.ade/modelPicker.json file is only a one-time migration source.
usePerSurfaceModelDefaults.tsPer-surface default-model resolver (Settings, parallel slots, CTO, etc.) — keyed by surface so each call site can have its own remembered default.
useReasoningByFamily.tsLast-used reasoning effort per model family.

Renderer state and the TUI share descriptors and ordering: the TUI ModelPicker/modelPickerLayout.ts imports modelPickerSearch/modelOrdering from the desktop package directly, so behaviour stays in lockstep. The TUI layout also preserves serviceTiers and Cursor cursorAvailability from the same catalog so Fast Mode and chat-vs-CLI model availability do not drift between desktop and ade code. Its provider rail stays stable across auth and runtime-loading states, always shows the full provider catalog with unavailable rows dimmed, and uses separate rail/list focus so arrow-key navigation matches the rendered two-column picker.

All desktop call sites should use this shared auth path rather than derive provider availability from only availableModelIds. The ids are a discovered inventory and can lag authentication; for CLI-backed providers, a positive provider-auth status is enough to expose registry models. The full status read starts only while picker content is mounted, uses the shared project cache, and does not poll. When a caller does pass providerAuthStatus (Work chat's AgentChatPane), it opts the picker out of the live auth hook — so that caller must itself listen for ade:ai-status-cache-updated / invalidated, apply peekAiStatusCached on update, and at most settle an orphan invalidate with one coalesced non-force getAiStatusCached for the active tile. Otherwise Settings auth looks Connected while the Work picker stays Off until remount. Local and cross-machine fork handoffs additionally apply a same-provider descriptor filter, so they can show newly registered models from that provider without allowing a cross-provider fork.

Attachment handling

  • Pasted and dropped images are written to a temp location. The selected chat surface (header, transcript, and composer) accepts the same file and image-URL drops as the composer and routes them through one attachment pipeline, while composer-owned drops are handled only once. File-backed renderer payloads use ade.agentChat.saveTempAttachment; native clipboard images prefer ade.app.saveClipboardImageAttachment, which reads the Electron clipboard, writes the PNG beside other chat attachments, and returns a downsized preview data URL. While either save is in flight, the composer disables send and shows a cancellable pending thumbnail in ChatAttachmentTray.
  • iOS Simulator selections add IosElementContextItem chips to the composer instead of plain attachments. Each chip is a data-ios-context node in a contenteditable rich-input variant; submission serialises the chips back into the prompt via formatIosElementContextForPrompt so the model sees a structured tag with componentId, source file/line, and any metadata. When the same selection produced a paired screenshot within 10 s, the chip carries an attachmentPath so the chip and image stay linked. See the iOS Simulator feature for the upstream flow.
  • inferAttachmentType and mergeAttachments in shared/types/chat.ts dedupe attachments by path (last-write wins).
  • MIME-type validation happens per provider. Claude enforces image/jpeg | image/png | image/gif | image/webp; macOS HEIC/HEIF uploads are normalized to JPEG before that boundary, while Windows/Linux report the missing codec instead of relabeling HEIC bytes. Codex uses local path references; OpenCode uses runtime content blocks.
  • The attachment cap and the provider inline cap are different numbers. shared/chatAttachmentLimits.ts single-sources both: MAX_CHAT_ATTACHMENT_BYTES (50 MB) is what may be staged on disk, and MAX_PROVIDER_INLINE_IMAGE_BYTES (10 MB) is what an adapter base64s into a model request. Most providers receive a path and do not care about size. The three that inline — Claude images, the AI-SDK streaming content used by OpenCode, and the Droid/Pi prompt blocks — check the inline limit and substitute a text hint naming the path when a file is over it, so an oversized attachment degrades the message instead of failing the turn. WORKER_MAX_IMAGE_FILE_BYTES (Cursor/Pi/Droid worker IPC) is the same inline limit and deliberately did not move with the attachment cap.
  • Remote hosts. A chat pinned to a paired machine asks that machine how it stages attachments before reading anything (agentChat.getAttachmentStagingMode). A host advertising features.attachmentUploadV1 in hello_ok accepts a streamed HTTP upload: the desktop mints a single-use ticket over the already authenticated sync socket (chat.createAttachmentUpload), then POSTs the file body to /ade-attachments/upload on the host's sync port with that ticket as a bearer. The ticket is consumed before the first body byte, so a retry needs a fresh one. Hosts predating the feature, relay-routed sockets (the relay brokers WebSocket frames, not HTTP), and SSH targets all fall back to chat.saveTempAttachment with the legacy 10 MB image-only contract. The capability is purely additive; iOS stays on the legacy path and is not offered the upload route.
  • Parallel launches reuse this same attachment path after the renderer validates the 12-file cap. Every child session receives identical attachment refs; provider-specific handling still happens inside agentChatService.sendMessage.

Message list

AgentChatMessageList windows its rendering with a custom virtualizer (no @tanstack/react-virtual): a measuredHeights row-key → height Map supplies estimates, a top and a bottom spacer div hold the scroll height around the rendered window, and MeasuredEventRow's ResizeObserver feeds real heights back through handleMeasurereconcileMeasuredScrollTop. Rows below the virtualization threshold render unwindowed. Key rules:

  • Assistant message cards constrain to max-w-[78ch] for readability (recent bump from 72ch to 78ch on large screens).
  • User messages animate in with a motion/react spring transition.
  • A user message over 600 characters or 8 lines renders collapsed (CollapsibleUserMessageBody) behind a CSS gradient mask with a Show full message / Show less toggle. The mask is used instead of line-clamp on purpose: line-clamp needs a single inline formatting context and mangles markdown, chips, and code. Row keys are unchanged by expanding, so the normal measure → reconcile chain absorbs the height change.
  • Code blocks render through HighlightedCode.
  • Tables get rounded borders, separated spacing, and a subtle inset shadow.
  • System notices render compact inline (no pill badges).
  • Turn dividers (ChatTurnDivider) separate turns.
  • Plan approval cards display the plan body as rich markdown inside a scrollable container (capped at 360px). When a plan-approval event carries non-empty body text, it is rendered as a MarkdownBlock (chatMarkdownBlock.tsx) beneath the header.
  • The jump-to-latest pill (shown while scrolled away from the bottom of a live session) reads N new · Jump To Latest when rows arrived after the reader detached, and plain Jump To Latest otherwise. The count comes from countRowsAppendedSince in chatTranscriptRows.ts.
  • The transcript head pages older history silently: an IntersectionObserver on a fixed-height sentinel — plus an underfill effect for tails too short to scroll — backfills without asking the reader to do anything, so the healthy path renders an empty slot. The fetch starts roughly two viewport-heights out (resolveOlderHistoryPrefetchTriggerPx, falling back to the 300 px near-top threshold before the pane has been measured), so normal reading arrives after the content rather than before it; the observer's rootMargin uses the same runway and re-arms on container resize. At most one page is ever in flight, because the load callback is a no-op while a request is outstanding. Each triggered batch is turn-anchored in readOlderHistoryBatch — it keeps paging until the loaded span contains a user message — so a byte-sized page made entirely of superseded streaming deltas can no longer look like "loaded nothing". The couldn't load earlier messages · retry control appears only after a genuine failure that survived the two backoff retries (OLDER_HISTORY_RETRY_DELAYS_MS = [800, 2400] in AgentChatPane). The slot height is constant either way, so toggling it never shifts the transcript. Clicking retry performs one immediate request, keeps the failure visible with a loading state, and never repeats the automatic backoff ladder. A late result from a previous chat, runtime binding, or cursor is discarded.
  • The timeline keeps programmatic scrolling and the left tick rail, but hides the native browser scrollbar. The minimap remains the transcript-position affordance without adding a second bright rail at the window edge.
  • The left tick rail (ChatUserMinimap) mounts as a direct child of the list root, because its left-0 gutter maths assume the offset parent is the element measured as listWidthPx. When older transcript pages exist before the resident tail, the rail keeps a top continuation marker even if the loaded window contains fewer than two user turns; paging fills in real ticks progressively instead of making the rail disappear at the cutoff.
  • The floating PR pane is an overlay, not a vertical rail reserve: opening it leaves the minimap anchored to the list root, and the PR card's higher stack level keeps it above the ticks if the two regions overlap.
  • iOS uses the same healthy-path contract: a fixed-height head sentinel automatically reveals the next local window and requests a 256 KiB host page near the top. It renders words only while loading or after a retryable failure, preserves the cursor on failure, and uses LazyVStack so older history does not make every transcript row resident. The subagent roster is owned exclusively by Chat Info; only lifecycle cards remain inline at their transcript positions.
  • Per-chat and nested-transcript scroll memory. The owning pane still remounts the list when the chat session changes (key={renderedSessionId ?? "chat-draft"}), but native transcript drill-in keeps that list mounted. AgentChatPane passes scrollMemoryKey for the parent session and for each subagent:<session>:<taskId> child view, so a parent position cannot be overwritten by a nested transcript position. The list keeps a bounded per-key snapshot alongside the module-scope LRU Map (chatScrollMemoryBySession, capped at 32 entries) — not the store, since this is throwaway view state, not user data. Each entry records wasPinnedToBottom, the anchorRowKey at the viewport top, its anchorOffsetPx, and the lastSeenRowKey that seeds the N new counter on return. The active key is captured from layout effects as rows, measurements, or pin state change and finalized on cleanup, so following the scroll costs no renders while the chat is open. Restore is hybrid: a reader who left pinned to the tail comes back pinned, otherwise a layout effect re-anchors — waiting for a non-zero container height, because the container measures 0 on the first frame and writing scrollTop against that clamps to 0 and reads as "it forgot where I was" — then applies exactly one correction on the next frame once real measured heights replace ESTIMATED_ROW_HEIGHT. Any real scroll between the two passes means the reader took over and the correction is abandoned. The anchor uses measuredRowStartOffsets, the same shared height model as the prepend anchor and the minimap, so the three cannot disagree about where a row starts.

Row derivation uses chatTranscriptRows.ts (see transcript-and-turns).

Row identity stabilization

events gets a fresh array on every streaming delta, so every useMemo keyed on it produced a new object each token flush even when nothing it described had changed. useStableIdentity(value, isSame) returns the previous reference whenever a cheap content comparison says the two are equivalent — sameMapContents, sameSetContents, sameKeyList — and is applied to resolvedInputStates, resolvedInputAnswers, staleInterruptReceipts, settledQueueRecoveryIds, and groupedRowKeys. groupedRowKeys is the load-bearing one: its identity feeds rowHeight, which feeds handleMeasure, which feeds every rendered row's ResizeObserver. Without the latch, a pure content delta recreated all of them on each flush. When the keys genuinely change (rows added, removed, regrouped) the fresh array is returned and every downstream memo and effect recomputes exactly as before.

Paced text reveal

Streamed assistant text arrives in lumps — the runtime coalesces deltas and flushes 5–7 times a second, and in subagent-heavy turns the gaps stretch past half a second — so unpaced prose lands as visible chunks rather than as a stream. The store is never delayed (copy, export, and every derivation read the full text the instant it arrives); only the painted slice is.

AgentChatMessageList picks exactly one row to pace: the trailing text row of a streaming turn, found by scanning back at most PACED_TEXT_ROW_SCAN_DEPTH (8) rows so the search stays O(1) per delta. It passes pacedTextReveal down to that row, and AssistantTextBody owns the reveal from there. Everything else — ended sessions, idle turns, history, rows that mount already complete — keeps the pre-pacing paint-on-arrival path. AgentChatPane passes textPacingEnabled={false} for subagent transcript views: they are a secondary surface, so only the user's own prose pays for the reveal.

Mechanics and guardrails:

  • The pacing algorithm, the grapheme-safe cutting, and the settled/tail block scanner live in textReveal.ts; the frame loop lives in useRevealedText.ts. See their source-map rows above.
  • Reveal is a leaf concern. The re-rendering component is AssistantTextBody, not the row, the list, or any derivation above it.
  • The revealed prefix is split at the last blank line at fence depth zero. The settled half is byte-identical between frames so MarkdownBlock's memoized body bails out; only the growing tail re-parses. The cut is never placed inside a fenced code block, which would leave the settled half with an unterminated fence and swallow the rest of the message.
  • Kill switch: set ade.textRevealHorizonMs in renderer localStorage and reload. 0 (or any non-positive value) restores paint-on-arrival exactly; the default is 150. The value is read once per session and memoized — it is on the per-frame path. A missing Intl.Segmenter disables pacing the same way, because grapheme-safe cuts cannot be guaranteed without it.
  • Elapsed time fed into a commit is clamped to 250 ms, so a backgrounded tab or a stalled runtime cannot make the first frame after the stall dump the entire backlog — the exact lump the reveal exists to remove.
  • Commits are capped at 60 Hz. On a 240 Hz display one character per frame is invisible and costs four times the React work for the same perceived speed, so sub-interval frames accumulate their elapsed time instead.

Mosaic cards

A Claude-family agent can emit a fenced ```mosaic code block whose body is strict versioned JSON ({"v":1,...}) describing a small form — text / select / multiselect / number-or-slider / input / approve-deny / key-value table elements. MarkdownBlock's code-fence handler (chatMarkdownBlock.tsx) renders it as an interactive MosaicCard (MosaicCard.tsx) when the pane passes a Claude-gated mosaic context prop (AgentChatPane); non-Claude sessions, and any card that fails the strict parse in chatMosaic.ts (parseMosaicCard: unknown version/element type, duplicate id, or malformed JSON → null), fall back to rendering the plain code fence unchanged. The artifact is data only — no expressions, no eval, no host actions.

Submitting serializes the selections through serializeMosaicSubmission (readable - Field: value lines the user bubble shows, plus a machine json fence keyed by element id) and sends them through the normal agentChat.send path with a displayText, so the answer reads as an ordinary user turn. Answered state is latched for the session in a cardKey-keyed map (<sessionId>:<row-scope>:<djb2(source)>) so a card that unmounts while scrolled out of the virtualized list restores its "Answered" state on remount; a send failure rolls the latch back so the user can retry. The TUI collapses the fence to a one-line summary (summarizeMosaicCard) and iOS shows the raw fence. The schema is documented for agents in the ade-mosaic Agent Skill (apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md).

Tasks panel

ChatTasksPanel renders todos from deriveTodoItems(). Items carry status (pending | in_progress | completed). The panel:

  • Groups with in-progress first, then pending, then completed.
  • Renders status glyphs (filled check, spinning arc, empty circle).
  • Supports collapse/expand with a count badge in the header.

Wrapped in BottomDrawerSection for consistent collapse semantics with other bottom drawer panels.

File changes panel

A turn reports its file changes exactly once, at its done divider. Which of the two summaries renders depends on whether the turn moved HEAD.

Checkpoint-backed. ChatTurnFileChangesPanel renders turn_diff_summary events inline at the bottom of the turn that produced them. The collapsed row shows the turn's file count and aggregate insert/delete totals. Expanding it shows two nested diff scopes:

  • This turn — fetches the selected turn diff via ade.agentChat.getTurnFileDiff.
  • Full thread — aggregates all available turn summaries for the session, advancing afterSha and stats as later turns amend the same file, then fetches the combined diff through the same API.

Both scopes render the shared AdeDiffViewer; the former bottom-of-chat aggregate bar is not mounted on ADE chat surfaces.

Entry-derived fallback. When a turn produced no checkpoint — no lane, a runtime with no git integration, edits that never moved HEAD — ChatTurnFilesChangedSummary (in ChatWorkLogBlock.tsx) renders instead. It aggregates that turn's work-log file_change and write-tool entries, deduped by path, into one collapsed N files changed +A −D row at the same divider. Expanded rows show the lane-relative path (full path in the tooltip), open in the Files tab, and expand the per-file diff carried on the entry. The row's Review in Files action just opens the Files tab for the lane — reverting is checkpoint-scoped and stays on the turn_diff_summary panel, which owns the before/after SHAs.

AgentChatMessageList picks between them per row: a done row whose turn id appears in the session's turn_diff_summary set suppresses the fallback, so a turn never shows two files-changed summaries. The timeline drops work_log_group rows outright, so neither summary competes with per-burst file panels.

Rewind files confirmation

Claude and Codex user messages expose an undo affordance when the provider can prove a rewind target. The pane first calls ade.agentChat.rewindFiles({ dryRun: true }); if the checkpoint or rollback plan can be restored, rewindFilesPreview.ts filters turn diff summaries after the selected user message and pairs each reported file with the earliest beforeSha and latest afterSha it can prove. The confirmation dialog then shows:

  • The user message being rewound to and its sent time.
  • Aggregate insertion/deletion counts from the SDK dry run.
  • One expandable row per file, including status and per-file stats when a turn diff summary is available.
  • Lazy AdeDiffViewer previews via ade.agentChat.getTurnFileDiff.

Confirming calls rewindFiles without dryRun. Claude leaves conversation history untouched and only restores files. Codex moves the upstream thread back and restores the matching files: on app-server

= 0.145.0 it forks the thread before the selected turn (thread/fork with beforeTurnId); on older servers, or when the turn id cannot be resolved, it falls back to the deprecated thread/rollback and is limited to the latest user message (see agent-routing.md).

Chat Info and subagents panel

When the Claude Agent SDK spawns background subagents, the service emits subagent_started, subagent_progress, and subagent_result events. ChatSubagentsPanel renders running/completed/failed/stopped subagents with usage metrics. Each roster row shows a sentence-case model chip from subagentModelAttribution: a reported envelope model is ground truth, and a missing model falls back to the parent session label marked inherited (display-only — the parent model is never written onto the envelope). Inspecting a subagent switches the Chat Info / takeover header to that child's attribution and locks the composer model picker without changing the parent session's modelId. Running subagent and background-command durations tick from wall-clock time once per second instead of freezing on the last SDK usage snapshot; terminal rows retain their final duration. The same panel also renders the current Codex goal, plan, todo_update task list, and the Schedule section derived from scheduled_work_update events. The Work tab actions badge shows the running subagent count and also counts scheduled work when no subagents are present. Full native Claude subagent-text forwarding is disabled. Any tagged child assistant/tool frames the parent SDK query still emits carry parent_tool_use_id, and ADE filters those frames out of the main timeline and resolves the backing Claude session pointer when opening the dedicated child transcript. A user's mid-turn steer always carries parent_tool_use_id: null, so it remains addressed to the parent agent. The same lifecycle events also render inline in the transcript as identity-anchored spawn/result cards, so the panel is a live roster and the transcript keeps a durable per-agent boundary. The desktop transcript derives those rows in chatTranscriptRows.ts (a backgrounded shell command gets a single background_job_line there instead of cards); deriveSubagentTimelineRows() in shared/chatSubagents.ts is the portable spawn / result / background_chip shape iOS mirrors in buildWorkSubagentTimelineRows, and it still models a background shell as one settle-position chip rather than a live line.

Claude wakeups, cron tasks, /loop, remote triggers, and background work are folded by deriveScheduledWorkSnapshots() into rows with kind, status, cron/prompt/reason details, source ids, fire timestamps, and late state. A parent turn ending does not coerce a running background row to stopped; only an explicit terminal work update or genuine runtime teardown does. The SDK's background_tasks_changed level set also keeps the owning query alive past the normal idle cleanup window and is authoritative for whether local_bash is actually backgrounded; ordinary foreground Bash uses the same task kind and never enters Background. Stop-hook snapshots keep real session-wide child shell/Monitor rows, but native Agents/workflows remain only in Subagents so one task cannot appear in both sections. The desktop Schedule header calls the typed agentChat.setScheduledWorkPaused API for the owning chat. While paused, active schedule rows remain visible but dim and show paused; on resume, overdue work fires once. Cron rows show last ran <time> · next <relative>. Large Chat Info rosters use the shared stable partition in shared/chatSubagents.ts: Subagents cap at 12 active rows, Background at 8, Schedule at 10, Progress at 14, and Tasks at 12. Terminal rows move into a single Completed (N) disclosure without sorting either group; failed and pinned rows remain active and cap-exempt. Clear (shown beside the toggle only while the fold is expanded) hides clearable terminal ids, Restore brings them back, collapse/Completed/clear state is scoped to the chat session, and Show all resets when the surface remounts. Small sections retain the original static header without disclosure chrome. Fired one-shot wakeups keep their fired time and optional late marker in the dim Completed row. ADE Code mirrors the grouped row model in-memory, while iOS mirrors the same predicates, caps, persistence semantics, and fired/late decoding.

Desktop renders those rows in the Chat Info drawer, ADE Code renders them in the Chat Info right pane, and iOS shows the roster only in its Chat Info popup/sheet for active scheduled items above the composer. The renderer does not own the timers: all controls mutate the project runtime's durable scheduler.

Interrupt transitions all running subagents to stopped by emitting a subagent_result with status: "stopped" for each, matching the Claude Code CLI behavior. Each such subagent_result is only emitted when its subagent_started was, and in the transcript a run of two or more stopped cards folds into one SubagentStoppedGroupCard instead of a wall of identical stopped rows.

Claude Workflow runs (the SDK's multi-agent orchestration tool) render in the same panel with zero new chrome: claudeWorkflowProgress.ts normalizes the undocumented workflow_progress snapshot and fans each workflow agent out as its own subagent row (phase in the summary line, tokens/duration from the snapshot, workflowName chip), while the parent workflow task row falls back to a phase/count rollup summary. Child chat sessions spawned with a parent lineage (ade chat create from a tracked agent shell, --parent) also list here via synthetic subagent_* events keyed chat:<childSessionId>. deriveChatSubagentSnapshots preserves that prefixed task id and the underscore event's spawnKind when the canonical dot-form subagent.started twin merges into the same snapshot, then derives an explicit childSessionId. The panel uses that field for routing, labels the row with the live child-session title when available, and shows the runtime as the small kind chip. The parent transcript additionally shows a quiet "Subagent spawned" chip (a status:"subagent_spawned" system notice) that deep-links to the child chat.

Codex parallel-agent lifecycle comes from both legacy collabAgentToolCall items and newer app-server subAgentActivity items. The service registers each child thread for transcript backfill, carries label, model, and reasoningEffort when the app-server provides them, and emits the same subagent_started / subagent_progress / subagent_result rows the panel already understands. Codex parallel agent failures emit a system-notice plus failed / stopped subagent_result rows. The agentChatService maps failed | errored | rejected | refused | denied Codex status values to failed and stopped | interrupted | shutdown | notfound | cancelled | canceled to stopped; readCodexCollabFailureSummary pulls the human-readable rejection out of item.error / item.result / item.contentItems[*].text so the chat surface shows a useful reason instead of a bare "Agent spawn failed".

deriveChatSubagentSnapshots (in chatExecutionSummary.ts) keeps sibling Codex subagents distinct when they share a parentToolUseId: it pre-scans every envelope to count the resolved subagent ids per parent, only adopts the placeholder parent row when exactly one sibling resolves under that parent, and otherwise creates separate snapshots keyed by agentId ?? taskId. The TUI now imports the same pure helpers from apps/desktop/src/shared/chatSubagents.ts (buildSubagentPaneRows, selectedSubagentSnapshot, subagentIndexForPaneLine, subagentPaneSelectableLineOffsets, buildSubagentTranscriptEvents, isLifecycleEventForSnapshot, latestPlan) — apps/ade-cli/src/tuiClient/subagentPane.ts and chatInfo.ts re-export them so desktop and TUI never drift.

Subagent envelopes carry both an agentId (raw runtime id) and an agentType label that's used as the row title when present. The label sources differ per runtime:

  • Claude / ade-code. agentType comes from the Task tool's input.subagent_type (e.g. code-reviewer, Explore). The service stashes that input at the assistant tool_use boundary (runtime.taskToolInputByToolUseId, keyed by tool_use_id) and joins it onto the later system:task_started / task_progress / task_completed envelopes via parentToolUseId. Stale entries are cleared at turn boundaries and on subagent completion.
  • Codex parallel agents. The Codex wire format has no human-friendly name for collab agents, so the service assigns Agent #N labels via assignCodexAgentLabel: 1-based, per-turn, remembered in runtime.codexAgentIndexByTurn (a Map<turnId, Map<threadId, index>>), and cleared when the turn ends. The raw threadId is kept on the snapshot as agentId.
  • OpenCode subagents. OpenCode encodes the agent's identity in session.title, which already flows through as description; the service intentionally does NOT set agentType so the renderer falls back to that description for the row label.

Chat Actions and Sources

ChatActionsDrawerPanel assembles the contextual tabs for an active chat. The Handoff tab is a two-view surface that resets to its landing menu each time it opens. The menu presents two cards: Continue on another machine (cross-machine) and Hand off locally (same machine, a new chat forked or briefed from this one). Choosing local opens the local handoff surface with the fork/brief mode toggle, lane targeting, and optional note described above. Choosing Continue on another machine opens the Send to machine staged modal that checks source publication, lets the user choose an eligible connected runtime, brief or fork mode, and an optional continuation note, explains clone/storage/model/route failures, and shows exactly what the capsule includes and excludes before final confirmation.

Eligibility is a fact about the chat's machine, not the active tab. AgentChatPane decides it from chatEffectiveBinding (isRemoteChat), so a local chat viewed from a remote-bound tab can still hand off, and a chat pinned to a remote machine cannot — regardless of which project the tab has open. When it is blocked the card names the machine (This chat runs on <machine>. Open that machine's project to start a cross-machine handoff.) rather than talking about the tab. CrossMachineHandoffModal receives that same chatRuntimePin as its runtimePin and pins every source-side call — lane list, sync status, origin remote, push, pull, prepare, validate, and the source marker — to it, freezing the pin once per operation so one handoff cannot straddle two runtimes. The complete Git, capsule, fork-transport, idempotency, and security rules live in Cross-machine session handoff.

When the selected provider is Codex, Sources is the first tab (ahead of Missions/Agents/Proof/Handoff/Run) and receives the current display event set. deriveChatSources() builds four compact sections:

  • Files — user attachments and image URLs.
  • Apps & tools — MCP servers/plugins/connectors, grouped by app identity with the distinct actions summarized once.
  • Web — search queries and result URLs/titles/snippets.
  • External resources — HTTP(S) links and resource URIs found in MCP metadata/results, including Linear issue context.

The derivation is bounded and recursive only for JSON-shaped tool results, deduplicates by canonical path/URL/source id, and excludes the internal node_repl execution host. A row is clickable only for an allowed HTTP(S) URL and opens through openUrlInAdeBrowser; local paths remain informational. The main transcript separately keeps web, image, MCP/connector, and subagent lifecycle compact across Codex, Claude, Cursor, Droid, and OpenCode adapters.

Terminal drawer

ChatTerminalDrawer is a collapsible drawer at the bottom of the chat surface. Each drawer tab creates an untracked shell PTY in the current lane, reusing the shared TerminalView component (with global terminal preferences) rather than managing raw xterm instances directly. Tabs track PTY exit state and auto-close the drawer when the last tab is removed. When a new chat-owned terminal is created from a non-drawer source (e.g. an in-chat agent calling ade --socket app-control launch, the localhost-strip "Logs" button, or another chat surface) the pane subscribes to window.ade.sessions.onChanged and dedupes the new terminal into the drawer instead of opening a duplicate tab — ChatTerminalDrawer.openTab checks the existing tab list by sessionId / ptyId before pushing a new entry, and the AgentChatPane revealCreatedTerminal effect calls the same drawer with the recovered { terminalId, ptyId, label }.

The drawer is mounted only when lane tool drawers are visible on the chat surface. Work-grid tiles pass hideLaneToolDrawers because the Work sidebar owns lane-scoped tools there; chat headers no longer expose a separate Terminal shortcut. Other chat-owned terminal creation paths still reveal the matching drawer tab through the shared revealCreatedTerminal flow.

Pending input card

The ask-question surface is anchored in the composer, not in the transcript. AskQuestionComposer replaces the composer textarea inside the same prompt-box frame — same border, radius, and width — so nothing shifts when the question resolves back into a textarea. (There is no longer a separate AgentQuestionModal, no InlineQuestionRequestCard, and no question-kind pendingBanner: that banner sat on a composer composerInputLocked had already hard-locked, so the composer was dead and wearing a sign saying so. plan_approval, model_selection, approval, and permissions keep their banners — they render real controls.)

The transcript keeps only the record: OpenQuestionReceipt while the gate is open (which is also where a queued second question waits until it becomes the composer's primary gate), and AnsweredQuestionReceipt once it resolves.

Which card the composer draws is not the transcript's decision alone. The pane renders resolvedPendingInputsBySession[sessionId][0], and that list is the transcript derivation reconciled against the session summary's pendingInputItemId — because a card's asker can outlive the turn it was tagged with (a backgrounded ade actions run, an ask_user whose caller stopped waiting), and only main knows whether it is still blocked. The renderer deciding alone is what produced the invisible wedge: no card on screen, send refused with "Answer or decline the pending request before sending another message", fork refused. The full rule is the "Pending input derivation" entry in Fragile and tricky wiring.

Answer semantics

apps/desktop/src/shared/pendingInputAnswers.ts is the contract. Four states per question — EMPTY, PICK, PICK_NOTE, NOTE — and five rules:

  1. Both travel. A note never replaces a selection; a selection never clears a note.
  2. Typing never deselects. Selecting never clears the note.
  3. Selection values come first, note last, so a model reads the choice before the qualification.
  4. The Send label is the payload receipt. sendLabel is derived from the state and nothing else (Send 1, Send 3 picks, Send 1 + note, Send note, Send N answers). If the label and the payload can disagree, the implementation is wrong.
  5. Multi-question: Send is enabled only when every question is answered.

The note row's placeholder says which of its two jobs is live: Your answer when the question has no options, Or send your own response instead when nothing is picked, Add a note (sent with your pick) once something is. No disabled state, no mode switch.

This module previously did not exist and the four surfaces disagreed: desktop sent both as an array, the TUI let typed text replace the selection, and iOS silently dropped the note once more than one question was in play. No provider forces any of it — Claude's question.reply takes answers: string[][], ADE's own askUser tool returns free-form JSON, and Droid takes a single string ADE joins itself (now via flattenAnswerForSingleStringProvider, which labels the note rather than comma-joining it into the picks).

The three defects this shape fixes

  • Hidden mode switch. handleOption's submitSingle branch submitted immediately on click for a single single-select question — unless the freeform field held text, in which case the same click only selected. One gesture, two outcomes, no signal. Select marks now; Next / Enter advances.
  • Hover-reflow jitter. Options set onMouseEnter → setFocusedOption, which swapped the preview, which changed the card's height, which made the virtualizer re-measure and reconcileMeasuredScrollTop, so the row walked out from under the cursor and the click landed on its neighbour. Hover mutates no state. Previews open on their own disclosure control, which neither selects the option nor advances the question. The option region is natural-height and capped: explicit disclosure may grow it once, while hover and focus cannot trigger any reflow.
  • Unbounded height. Only the option list scrolls; header, note row, and footer are pinned outside it, so Decline / Next / Send are reachable at any list length. Rows that fall fully below the fold get a ⌄ N more options row on its own line — floating it over the list would let it cover a row the user meant to click, and a shadow alone is deniable (a list that cuts cleanly after option 4 reads as "there are four options").

Height budget (desktop). useOptionsMaxHeight measures [data-chat-appearance-root] — the flex column holding the transcript and the composer — and gives the option list clamp(260, height * 0.55, 520). The 260px floor keeps the normal three-option case, including one disclosed preview, out of a cramped inner scroller; long lists still scroll with the footer pinned. Budgeting from that column rather than the transcript viewport is load-bearing for the same reason it is on iOS (workPendingInputMaxHeight): the column's height does not change when the card grows, the viewport's does, and feeding the viewport back in is a runaway loop that eats the screen.

Minimize. A beside the × folds the card to one line inside the prompt box (provider mark · {header} — {question} · N/M or ANSWER · ) so the transcript can be scrolled freely. It does not dismiss and does not unblock — the gate stays open, and picks survive the fold. × remains the decline; the two affordances must not be merged. iOS already ships this as pendingInputCollapsed; both share the summary string (foldedSummary).

Anatomy:

Anatomy:

  • Header — the provider mark (ProviderLogo(source)) plus a kind-derived verb from pendingInputHeaderLabel(source, kind): {Provider} asks for questions, {Provider} · Plan ready for plan approvals. No clock icon and no generic "Question from {provider}" title. For paged sets a dot rail sits on the right (filled = current, green = answered) and jumps between questions; N / M lives in the footer. Then the minimize and the decline ×.
  • Body — the question's short header renders as a kicker, then the question text exactly once. A request-level description only renders when it differs from the question text, so it never duplicates the question.
  • Options — a ledger. One column always, never a 2-col grid (three options in two columns leaves a ragged orphan). A hairline between rows and nothing else: no per-option border, fill, radius, or radio glyph. Selection is a flush-right; the leading number stays constant. Rows carry role="radio"/"checkbox" in a radiogroup/group container, support multi-select, and show a quiet Recommended label. Nothing is preselected.
  • PreviewsQuestionOptionPreview (in questionOptionPreview.tsx) is format-aware: wireframe/ASCII content (detected by looksLikeWireframe, or previewFormat: "html") renders in a column-preserving monospace <pre> (white-space: pre, horizontal scroll), and prose markdown routes through the shared code-fence-aware ChatMarkdown. This replaced a bare ReactMarkdown that collapsed ASCII alignment. Previews live inside the capped option region. The closed state stays natural-height with no blank preview reserve; an explicit disclosure may grow the composer up to the cap, after which only the option region scrolls. When ≥2 options carry previews, a ⇄ Compare toggle shows two side by side.
  • Keyboard-first1-9 pick, next/send, ←→ page between questions, esc declines. Digits typed into the note field are never hijacked.
  • Accent — chrome uses var(--chat-accent), which the chat surface sets per provider, so the card is amber for Claude, warm-white for Codex, violet for Cursor/Droid, blue for OpenCode (and honours the neutral-chrome preference). The tint count is deliberately two — the header mark and the selected — plus one structural use: a hairline top border on the composer meaning "you are in answer mode". Not a glow, not a fill. The same accent treatment is applied to ChatProposedPlanCard.

Responses are sent back via ade.agentChat.respondToInput (accepts AgentChatRespondToInputArgs with structured answers; values may be string or string[] for multi-select, plus an optional decision). Legacy ade.agentChat.approve is still supported. Plan approval cards receive the plan text from the ExitPlanMode tool input so the UI shows meaningful content rather than a generic label.

Codex app-server mcpServer/elicitation/request uses the same contract. Form-mode JSON Schema properties become paged questions (enum/oneOf, boolean, freeform primitive, and multiselect array); answers are coerced back to the schema before ADE replies. Approval/URL mode shows Allow once and Deny, an Open authorization action only for safe web URLs, and Always allow only when _meta.persist includes always. Provider full-auto mode does not auto-answer these app/connector consent requests. serverRequest/resolved emits the usual resolution event so a request completed outside the card cannot leave the composer locked.

Approval vs question

approval_request.kind describes the shape of the thing being confirmed (tool_call, permissions, plan_approval, …) and has no word for "the agent asked you a question". Claude's AskUserQuestion therefore rode the event as a tool_call approval, and every surface downstream of the transcript — push, the ADE Notch, the lock screen — offered Approve / Deny for something that wants prose. The answer branches in those surfaces were unreachable code.

So the event carries an additional optional requestKind: PendingInputKind — what is actually being asked, alongside the shape of it. It is additive: an event without it is an approval, which is exactly what every build before this one meant by sending one. Readers classify through isQuestionKind(requestKind) from shared/pendingInputAnswers.ts rather than re-deriving the split:

  • AgentChatMessageList uses it directly instead of wrapping the kind in isAskQuestionRequest({ kind }).
  • The brain's push publisher (pushPublisherService.ts) sets the run phase to waiting_for_input for a question and waiting_for_approval otherwise, and ships the question without the Approve/Deny notification category — a question has nothing for those inline buttons to do. Both flavours share the alert:<sessionId>:approval dedupe key, since either way it is one prompt per session, and sharing it keeps a question from re-alerting over an approval it replaced. See push notifications.

Cross-surface parity

The card's data contract (PendingInputRequest / PendingInputQuestion / PendingInputOption in shared/types/chat.ts) is the single source of truth: the TUI (apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx) and iOS (WorkStructuredQuestionCard / WorkPlanComposerStrip) render the same header verb, dedup, monospace preview, and per-provider accent.

On iOS the pending inputs collapse into a single consolidated strip pinned above the composer (consolidatedPendingStripSection in WorkChatSessionView+Timeline.swift), replacing the previous split of plan/approval composer strips plus inline question/permission/model-selection transcript cards. It renders the current (primary) request, shows a "Request 1 of N" header once more than one gate is queued, and advances to the next request as each is answered. Answers use an optimistic-removal path (dispatchPendingInputAnswer / optimisticallyAnsweredInputIds): the answered item is hidden the instant its decision is dispatched so the strip advances without waiting on the host round-trip, then reconciled out of the set once it leaves the host-derived queue (canonicalPendingInputSignature change) or rolled back if the command errored. An "Accept all" affordance appears when the current gate is an approval/permission kind (never question, plan-approval, or model-selection): it flips acceptForSession on the current gate, then accepts each remaining sweepable gate sequentially (stale itemIds no-op on the host, so re-sends after auto-resolution are safe). The verb/name helpers live in shared/pendingInputLabels.ts so desktop and TUI share them; iOS mirrors them in Swift. A blocking pending input also surfaces an "Awaiting you" badge on the Lanes row and the Work grid tile (derived from exact pending-input counts, not idle CLI attention heuristics), and iOS fires a light haptic when a new blocking gate arrives.

Height budget (iOS). The strip is capped so a gate can never claim the whole page. workPendingInputMaxHeight(chatSurfaceHeight:) (in WorkChatSessionView.swift) returns max(160, min(available * 0.82, chatSurfaceHeight * 0.62)) where available subtracts a fixed 110pt composer reserve. The input is chatSurfaceHeight = max(240, scrollViewportHeight + composerLayoutHeight), not the transcript viewport: the transcript and the composer inset split the same surface, so their sum is invariant to how the two divide it, while the viewport alone shrinks as the card grows — feeding that back in was a runaway loop where the card ate the screen. The composer reserve is a constant for the same reason; composerLayoutHeight already includes the strip being sized, so measuring it would be circular. Inline-in-transcript question cards use the separate workInlinePendingInputMaxHeight(transcriptViewportHeight:) rule (max(240, viewport * 0.62)) because the composer sits below that viewport either way and there is nothing to reserve for.

The card's own arithmetic subtracts its measured top/bottom chrome plus named cardPadding / cardStackSpacing constants from that budget and floors the scroll region at 64pt — roughly one option row. On a small phone with the keyboard up the irreducible chrome can still exceed the budget; the overflow is absorbed by the transcript, not the composer, because the composer inset is fixedSize(vertical:) and the transcript scroll view is the flexible sibling. That view ordering is the actual guarantee that Send/Decline stay on screen; the number only keeps the common case from getting there.

Minimize (iOS). The strip header carries a chevron that collapses the card to a one-line pill showing the provider mark, a content-derived summary (workPendingInputCollapsedSummary — the question header, plan title, or Permission: <tool>, never a generic "1 request"), the queued count, and an expand chevron. The gate stays open and the composer stays locked; only the card is swapped out, so the user can scroll the conversation for the context the question needs. State is a collapsedPendingInputId, with the boolean derived from it — a minimize applies to the gate the user chose to defer, so it must expire the moment a different gate becomes primary, and deriving makes that impossible to get wrong. A keyboard Done toolbar item (gated on the freeform field actually holding focus, because a toolbar declared unconditionally would surface over the main composer's keyboard and silently do nothing), a footer dismiss button, and interactive scroll-to-dismiss are the three ways back out of the keyboard.

Draft persistence (iOS). Mobile keeps unsent text the way desktop does. WorkComposerDraftStore persists each chat's composer draft under chat:<sessionId>, plus fixed keys for the Hub inline composer and the Work New Chat composer; WorkQuestionDraftStore persists a still-open question's selections, per-question freeform, shared freeform, and page index under the request id, so backing out of the chat to check the transcript — the exact reason a user minimizes the card — no longer discards what they picked. Both autosave on a 400 ms debounce and flush on disappear, because a cancelled .task throws out of its sleep before the write and a navigation pop is precisely the case the debounce misses. Send clears the stored draft synchronously rather than waiting out the debounce: a jetsam inside that window would otherwise restore an already-sent message into the composer, where it reads as unsent and invites sending it twice. Two deliberate exclusions: answers to isSecret questions are never written (the backing store is App Group UserDefaults, shared with the widget extension, so that would put a credential on disk in plaintext), and unpair does not clear the stores (its only production trigger fires automatically on an attributed auth failure, and the stores are keyed by session, not by host, so clearing would destroy unsent text for every other paired machine).

Per-runtime question richness (ceilings)

Each runtime populates as much of the schema as its SDK exposes; the card renders whatever is present:

  • Claude — full: header, options description/preview/ previewFormat/recommended, multiSelect (from the AskUserQuestion tool).
  • Codex — full via the app-server item/tool/requestUserInput payload (header, multiSelect, isSecret, per-option description/preview).
  • Cursor — full via normalizeCursorControlQuestions (incl. defaultAssumption, impact, isSecret).
  • OpenCodeheader, multiSelect (from multiple), allowsFreeform (from custom), per-option description. Ceiling: no recommended/preview/isSecret.
  • Droidtopicheader and bare-string options only. Ceiling: the @factory/droid-sdk ask-user schema (AskUserQuestionSchema) exposes no per-option description, no multiSelect, and no preview, so those fields stay empty for Droid.

Presentation profiles

ChatSurfacePresentation (in shared/types/chat.ts) drives the surface's visual treatment:

FieldEffect
mode`standard
profile`standard
modelSwitchPolicyOverrides the default switch policy for the session.
title, subtitle, assistantLabel, messagePlaceholderText overrides.
accentColorAccent color used in header, chips, and active-turn indicators.
chipsList of { label, tone } chips shown in the header.
showMcpStatusWhether to render the ADE CLI status indicator.

CTO and resolver surfaces set profile: "persistent_identity" and override the chips.

Resolving the accent for the chat on screen

chatAccentForRenderedChat() in chatSurfaceTheme.ts is the single derivation, and it is synchronous — the accent must be right on the frame a chat switch renders, not one IPC round trip later. Best evidence first:

  1. sessionProvider — the rendered chat's own provider.
  2. lockSessionProvider — the provider an embedding surface already knows for the locked chat.
  3. modelFamily — the composer model's provider family.
  4. modelColor — the per-model registry color.
  5. NEUTRAL_CHAT_ACCENT (#A1A1AA).

The caller owns staleness. Pass null for anything you cannot vouch for this frame, so the answer falls through to a source you can — or to gray — rather than landing on a stale color. Neutral gray is the only honest answer when nothing trustworthy identifies the chat: a held-over color reads as the previous chat's provider.

AgentChatPane applies that rule twice:

  • Its sessionProvider comes from renderedSession, which is resolved from renderedSessionId (with the host's initialSessionSummary as the switch-frame fallback), so it already describes the chat on screen and needs no freshness test.
  • The model-derived inputs do need one, because the pane is not remounted across a chat switch and the composer's model trails a prop-driven change. They are withheld unless composerModelDescribesRenderedChat (selectedSession?.sessionId ?? null === renderedSessionId) holds. That is deliberately not !chatSelectionTransitioning, which compares the composer id to the rendered id and already agrees in the frame where the incoming id is known but its row has not been listed yet — the frame where selectedSession is still null and the stale model args must stay withheld. A draft pane has no session at all, so null === null holds and it keeps its composer model color.

lockSessionProvider is the never-borrow guard for embedded panes: a host that has the Work row in hand supplies the provider directly. WorkViewArea's SessionSurface passes providerFromChatToolType(session.toolType) — the inverse of chatToolTypeForProvider, both now living in renderer/lib/sessions.ts as two hand-written literal tables (a Record<KnownChatProvider, …> forward map keyed on a closed six-member union so adding a seventh provider is a type error, plus a Record<string, …> reverse map whose agreement is pinned by a round-trip test). Both directions look up through Object.hasOwn, so a provider or tool type literally named constructor or toString falls back instead of resolving to an Object.prototype member.

State derivation helpers

These modules are pure and unit-testable:

  • chatTranscriptRows.ts -- event-to-row pipeline (hidden/visible, work-log grouping, tool-use summary absorption).
  • pendingInput.ts -- event-to-pending-input derivation plus the read-time reconciliation that decides which derived cards are live. derivePendingInputRequests hard-deletes only on an explicit pending_input_resolved receipt; a done status or a tool_result / command / file_change that moots a card only marks it sweptWithoutReceipt. resolvePendingInputs(derived, summary) then keeps a swept entry the summary still names in pendingInputItemId and drops the rest. Renderers call the second function, never read the flag themselves, and never fold the summary back into the derivation — see the "Pending input derivation" entry in Fragile and tricky wiring.
  • chatExecutionSummary.ts -- todos, subagent snapshots, turn diff summaries, and re-exports the shared scheduled-work derivation.
  • apps/desktop/src/shared/chatScheduledWork.ts -- scheduled-work snapshots from scheduled_work_update envelopes.
  • chatNavigation.ts -- keyboard navigation between transcript rows.
  • chatToolAppearance.tsx -- tool-specific visuals (icons, tone, label formatting).
  • pendingInput.ts exports getPendingInputQuestionCount() and hasPendingInputOptions() for introspection inside the composer, and resolvePendingInputs() for the summary join described above.

Cursor Cloud fleet view

The chat composer also exposes a Cursor Cloud agents side panel from its overflow menu whenever the lane has a connected Cursor account. The panel lists active and recent agents, opens an existing agent as an ADE chat, and keeps archive, cancel, delete, and refresh actions on the same Cursor-owned agent record. Cursor supplies the agent name: ADE mirrors that name and does not offer an ADE rename action. New cloud launches pass the selected model's reasoning and service-tier parameters explicitly, so Cursor cannot silently replace the chosen variant with its default. A control the selected model does not define at all (Composer models carry no reasoning parameter, for example) is simply not sent; the launch fails closed only when the model has the control and cannot represent the chosen value.

Cloud readiness is honest about the lane's git remote. The remote is read through useLaneGitRemote with a loading, ready, and error state, retried on its own a few times and again whenever the machine picker opens; "no GitHub remote" is only shown after a finished read that returned none. The auto-create lane row reads the primary lane's remote, because the auto-created branch is cut from the same repo and the placeholder id is not a lane the brain knows. Before the agent starts, the launcher asks git where the lane's branch stands: a branch that is only behind origin is not pushed (the cloud clones origin, which is newer), a diverged branch blocks the send with one plain sentence, and only local commits are pushed. A failed push always aborts the launch, even when origin already lists the branch — listing it is not proof it has these commits. A rejected push is rewritten into a sentence instead of git's stderr. ADE rename is blocked on every surface that can write a chat title: desktop menus, the command palette, ADE Code (/rename, hotkey r), iOS, sessions.updateMeta, session.updateMeta, and work.updateSessionMeta. The iOS chat settings sheet shows Cursor's name as read-only. Pin and other non-title patches still go through.

A new cloud launch returns its ADE session as soon as it is persisted, and the first hydrate runs in the chat view; only reopening an existing empty cloud chat waits for hydrate. Cursor names the agent shortly after the first run produces output, so while the ADE title is still a default the mirror re-reads the name on the tick that yields the first visible turn or a terminal run, capped at three extra reads, with no polling of its own.

The top bar carries a Cursor quick-view button (CursorCloudQuickViewButton, mounted by TopBar beside the Linear quick-view) that opens CursorCloudFleetModal — a project-scoped account surface listing every Cursor Cloud agent that belongs to the open project. An agent qualifies when an ADE chat session links to it or when its repos include the project's origin; shared cursorCloudRepoMatch.ts normalizes SSH, HTTPS, and .git-suffixed remotes so those spellings of one repository compare equal. Each entry records why it matched (matchedBy: session / repo / both), so agents launched on cursor.com outside ADE still appear — unlinked — instead of being invisible.

Grouping is state-first: Active runs first, then finished/error rows grouped under their owning ADE lane (the lane header carries its Linear identifier when present), then unlinked rows clustered by repo · branch. Status, lane, and archived filters apply across all groups. What counts as active is derived once in shared/cursorCloudFleetStatus.ts, so the modal, the row component, and the main-process service cannot disagree about section placement or Stop-button visibility.

Row actions: Open mirrors the cloud agent into an ADE chat in its lane (resolving the lane first when unlinked), Stop cancels the latest run even for agents launched elsewhere, Pull into lane… appears once a run finishes, and the ⋯ menu offers Archive/Unarchive, Open PR, and Delete with an explicit click-again confirmation. Expanding a row lazily fetches usage; failures render inline on the offending row rather than disabling it.

Pull-into-lane never guesses at a target. Resolution order is the linked session's lane → any local lane already on the pushed branch → a fresh lane imported from the remote branch. It refuses dirty worktrees (uncommitted changes must be committed or stashed first), fetches + merges FETCH_HEAD, and on merge conflict aborts the merge and reports exactly that instead of half-landing. Branch names coming back from Cursor are guarded against git option injection, and a multi-repo agent pulls only a branch pushed to this project's repo — branches attributed to other repositories refuse with an explanation.

Freshness has no timer. The Cursor Cloud ingress relay's terminal deliveries are re-broadcast as the per-project ade.ai.cursorCloud.fleetEvent push, which soft-refreshes the open modal and lights the button's unread-finishes badge while it is closed; everything else waits for the manual refresh control. When the relay is unconfigured or erroring, a banner says so ("Live updates not configured yet — this list updates on refresh and when agents finish") rather than letting a stale list look current. A missing Cursor key renders a connect prompt linking Settings → AI connections instead of an empty list.

Fragile and tricky wiring

  • Draft launch job lifecycle. DraftLaunchJob tracks multi-step async launches and is stored in the root store's draftLaunchJobsByScope (read/written via useRootAppStore / rootAppStoreApi.getState().setDraftLaunchJobs) rather than the per-project store or local pane state. This is load-bearing: a launch routinely outlives the pane that started it, and switching to another remote project tears down the originating project's scoped store entirely — keeping the job in the root store is what lets it re-surface (and ready jobs auto-open / failures show Restore) when the user returns. The composer is cleared immediately when the job starts, not when it finishes. Auto-created lanes start at creating-lane (named deterministically up front), then move through session start and prompt send; any AI rename happens in the background after the lane exists and is surfaced via the "Auto-naming…" card status, not a launch-job phase. If the launch fails, the Restore action merges the snapshot back via restoreDraftLaunchSnapshot, which appends rather than replaces existing draft text and merges context items by id. isDraftLaunchJobStale makes an active row hideable after the stale threshold so a hung IPC call cannot leave a permanent status strip. latestForegroundDraftLaunchJobIdRef prevents stale foreground jobs from auto-opening when a newer foreground launch superseded them. The DraftLaunchSnapshot captures the full composer control state (model, reasoning, execution mode, native controls) so createSessionForLane receives a launchState that overrides the live composer state during the async gap.
  • Draft launch project-switch safety. Because the launch chain is detached from the pane lifecycle, it must never act on the wrong project. It captures the originating project's OpenProjectBinding (launchBinding) at the start and passes it as the optional pin arg to project config reads, branch discovery, lane create/rename, background lane-name suggestions, session create/send/delete, orchestration bundle allocation, and CLI PTY create/dispose. The preload routes a pin through callPinnedRuntimeAction — see Remote runtime internal architecture — so a mid-launch project switch keeps the detached work targeting the project that started it instead of the now-active project. Rollback of a partially-created launch is pinned to the same binding: window.ade.lanes.delete(..., launchBinding) and window.ade.agentChat.delete(..., pin) delete the lane/session they created even after the active project changed. The legacy fallback where no binding is available still aborts on project-root drift with LAUNCH_PROJECT_CHANGED_MESSAGE. Each step is also wrapped in withDraftLaunchTimeout so a runtime call that neither resolves nor rejects (DRAFT_LAUNCH_TIMEOUT_MS = 90 s) fails the job instead of wedging it in a non-terminal state and blocking re-submission.
  • Composer draft persistence. ComposerDraftStorageSnapshot is persisted to localStorage on every draft/model/attachment change and restored on scope switch. composerDraftHydratingRef suppresses the first write-back after hydration so the restore does not immediately re-persist with a new timestamp. Normalization (normalizeStoredComposerDraft) validates every field defensively so corrupt stored data degrades gracefully instead of crashing.
  • Empty-state hero column. The new-chat hero column in AgentChatPane lives in an overflow-hidden wrapper and caps itself with max-h-full, so exactly one row — the ADE logo — may be flexible; every sibling (heading, lane pill, inline composer, extras) must be shrink-0, and the logo keeps a min-h floor so it shrinks instead of the column overflowing and clipping top and bottom in a short window. The optical lift is wrapper padding, not a negative margin on the column: a negative margin escapes the max-h-full cap and reintroduces the clipping.
  • Session creation and first turn race. When a new session is created from the composer, the pane awaits the onSessionCreated callback and the session-list refresh before sending the first agent turn. Skipping this wait renders a blank "new chat" screen because the parent surface has not yet navigated to the chat tab.
  • Model warmup is not on selection. Changing the composer model does not call ade.agentChat.warmupModel. Warmup remains an IPC for callers that explicitly preload a Claude runtime; the Work composer waits until Send to bind the new model, so a selection must not tear down the current provider.
  • Reasoning slider pointer ownership. The track captures a drag only after the small movement threshold, writes preview positions through CSS custom properties, and commits exactly once on pointer-up. Ridge-button clicks take the normal radio path; a drag that starts on a ridge suppresses the trailing synthetic click so it cannot toggle the newly snapped tier back to Auto. Do not close the Radix popover from onChange — outside click and Escape are the intentional close actions.
  • Stale slash commands. SDK-provided slash commands fetch by session while the composer matches the committed model. A pending model handoff fetches by lane and the pending provider (the draft path), so the menu does not keep the previous provider's commands. Missing that refetch surfaces slash commands from the still-bound session.
  • File-search debounce. The @ picker debounces input (40 ms in ChatCommandMenu) and stamps each request with a sequence number to discard stale results; cached queries re-render immediately and revalidate in the background. Stale-result handling is easy to regress when adjusting the debounce.
  • Trigger detection is cursor-relative. Both composer inputs and the TUI share shared/composerTriggers.ts. Slash tokens require a word boundary before / and allow no whitespace or / inside, so paths (/usr/bin), URLs, and fractions never open the menu. Don't reintroduce startsWith("/") gates — that regresses mid-sentence commands. @ queries may contain spaces so multi-word chat names remain searchable; the trigger still stops at a newline or another @. In the rich editor, detection runs on the DOM text run around the caret (getRichTriggerContext), NOT on serialized-draft offsets: serialization collapses whitespace and flattens chips, so serialized indices cannot be mapped back onto DOM positions.
  • Chip selection marking must stay cheap. selectionchange fires on every caret move on the Work tab's hottest input path, so the chip-highlight effect in AgentChatComposer holds five constraints together: the document listener exists only while the editor is focused and actually holds a chip; a collapsed caret costs one boolean (it returns early unless an earlier selection left marks behind); every DOM write is coalesced into a single requestAnimationFrame; all queries are scoped to the editor element, never the document; and because chips are inserted/removed by direct DOM writes rather than React renders, a MutationObserver watches the editor's structure (childList + subtree) and never its character data, and only while the editor is focused. Widening any of these — a permanently attached listener, a document-wide query, or a characterData observer — puts work on every keystroke of every chat.
  • Smart-link presentation is not prompt storage. The chip label, remote title, and favicon are presentation only. Preserve the full URL in data-composer-chip-text and in the controlled draft; reconciliation must never replace sent text with a compact label. Metadata failures are expected and must degrade to the deterministic provider label or complete URL.
  • Chat event identity is never the sequence number alone. eventSequence is a runtime counter, but the transcript it numbers is durable and appended across desktop restarts, so a rehydrated session that restarts at 0 mints sequence numbers the file already contains — one transcript can hold two events numbered 67, hours apart. Any consumer keying identity on sessionId + sequence then mistakes the newer event for a replay of the older one and drops it. Both halves of the fix are load-bearing. Host side, readTranscriptHydrationState (agentChatService.ts) seeds managed.eventSequence from the transcript's max sequence in the same pass that recovers todo items — one pass, because the transcript is not cached — so sequences stay strictly increasing for the life of the file. Client side, iOS's AgentChatEventEnvelope.id includes the timestamp (sessionId:timestamp:sequence), so a genuine redelivery (same timestamp and sequence) still collapses while cross-epoch collisions do not. On a real 425-event transcript the old key destroyed 103 events, including two approval_request envelopes carrying whole AskUserQuestion cards and 31 short text chunks (short text has no content dedupe key of its own — that requires >= 24 characters — so it fell through to the sequence-derived id). Blocking gates additionally get itemId-based content dedupe keys in SyncService.chatEventContentDedupeKey (approval_request, structured_question, pending_input_resolved): a dropped gate is a question the user never sees and can never answer, so it must not depend on sequence uniqueness at all.
  • isAskUserToolName deliberately does not match AskUserQuestion. For Claude's own ask-user tool the host emits both a tool_call (keyed by the SDK tool-use id) and a separate approval_request (keyed by a fresh randomUUID). iOS's derivePendingWorkInputs dedupes by item id, so adding askuserquestion to that name list produces two cards for one question — and the tool_call-derived one is unanswerable, because the host has no approval registered under that id and discards the response silently. The tool_call branch exists only as a fallback for hosts that emit a bare ask-user call with no wrapping approval.
  • Question drafts persistence. Question answer state (selected options + notes) is local to AskQuestionComposer on desktop. If the user navigates away and back, drafts reset. Minimizing the card does not reset them — folding to read the transcript is a normal step in answering, so picks survive it. This is intentional to avoid stale answers leaking across sessions. iOS makes the opposite call for the same surface — see Cross-surface parity — because minimizing the card to read the transcript is a normal step in answering it there, not a session change. The card's one-time focus and entrance animation are guarded by module-level sets (focusedQuestionCardKeys / enteredQuestionCardKeys) so the virtualized list re-mounting the row mid-scroll doesn't re-steal focus or replay the fade.
  • Terminal drawer tab lifecycle. PTY exit must trigger tab removal, and the last-tab-removed condition must collapse the drawer; the ChatTerminalDrawer state machine is the canonical source.
  • Virtual-scroll offset drift. The hand-rolled virtualizer is sensitive to changing row heights (plan approval cards, work-log expansion, expanding a collapsed user message). The spacer heights are computed from the measuredHeights map, so a row that resizes above the viewport desyncs the spacer math from el.scrollTop unless handleMeasurereconcileMeasuredScrollTop compensates. The map is keyed by stable row keys; rolling back to an unstable key causes the list to "jump" on updates. Sticky-bottom recovery intentionally follows for a few animation frames after row measurement changes; removing that follow-up can make active streams appear to stop short of the newest output.
  • Native permission picker updates serialize before submit. AgentChatPane tracks the in-flight native-control update through pendingNativeControlUpdateRef (sessionId + monotonic updateId + promise). Every updateSession dispatched from the permission popovers chains onto the previous promise so the backend always sees the final picker state, and submit() awaits that chain for the active session before dispatching the turn. The handler also optimistically patches the renderer session summary with the fields returned from updateSession (permissionMode, interactionMode, claudePermissionMode, codexApprovalPolicy, codexSandbox, codexConfigSource, opencodePermissionMode, cursorModeId, cursorModeSnapshot) so the chip state reflects the server's normalized values before the list refresh lands.
  • Inbound mode changes re-seed composer state. When another client changes a session's mode, the service emits a session_meta_updated event carrying the mode fields (see transcript-and-turns). AgentChatPane's event handler patches the session summary with any mode fields present and — because the composer seeds its local mode state from the session scope, not from summary content — also applies the authoritative fields directly to composer state (setInteractionMode, setClaudePermissionMode, setCodexApprovalPolicy/setCodexSandbox/ setCodexConfigSource, setOpenCodePermissionMode, setDroidPermissionMode, and the Cursor mode/config setters derived from cursorModeSnapshot) when the event targets the selected session, mirroring the plan-mode transition special-case. A title-only emit changes no mode key and is a no-op for composer state.