Skip to content

fix: render extension dialogs inline with keyboard nav, stacking, and expiry (#462) - #676

Closed
mufflegyro wants to merge 1 commit into
agegr:mainfrom
mufflegyro:fix/extension-dialog-ux
Closed

fix: render extension dialogs inline with keyboard nav, stacking, and expiry (#462)#676
mufflegyro wants to merge 1 commit into
agegr:mainfrom
mufflegyro:fix/extension-dialog-ux

Conversation

@mufflegyro

Copy link
Copy Markdown

Closes #462

Summary

ctx.ui.select() / confirm() / input() / editor() dialogs were rendered as a full-window overlay that hid the chat context, ignored the timeout/expiresAt fields in the protocol, offered no keyboard support for select, and used a single state slot that couldn't express composed flows. This PR implements all three proposed steps (P0 + P1 + P2) from the issue.

Changes

P1 — Inline cards in the transcript

  • Dialogs now render as inline cards in the conversation transcript (message stream, plus the new-session state below the composer) instead of a dimming position:absolute; inset:0 overlay. The conversation stays readable while answering; no overlay, no click-outside needed.
  • Dropped aria-modal="true": the card is intentionally non-modal (role="dialog" + aria-label instead).

P0 — Keyboard navigation + Esc

  • select supports Arrow Up/Down (and Left/Right), Home/End, Enter to pick the highlighted option, and Esc to cancel. Hover syncs the active option.
  • The global stop-agent Esc shortcut now yields when focus is inside an extension dialog (hooks/useKeyboardShortcuts.ts), so Esc cancels the dialog instead of aborting the run.

P2 — Dialog stack + timeout/expiresAt

  • Unanswered dialogs queue in a stack instead of clobbering each other, so composed flows (select → input) and pipelined requests from concurrent extensions resolve in order. Entries are deduped by request id because the events stream replays pending requests on reconnect (hooks/useAgentSession.ts).
  • Dialogs carrying timeout/expiresAt show a live countdown ("expires in Ns") and auto-cancel at the deadline, resolving the extension's await with its default value — matching the daemon-side timeout and the TUI's auto-dismiss. Stale dialogs are dropped when switching sessions.

Verification

  • tsc --noEmit, eslint ., and the full test suite (844 tests) pass.
  • End-to-end with a real extension command on the running dev instance (playwright, headless chromium):
    • Keyboard select → input composed flow round-trips the user's actual answers back to the extension;
    • Esc cancels the select (notify received);
    • a 6s timeout shows the countdown, auto-dismisses, and notifies the extension;
    • two queued dialogs show one active card + a "N more pending" indicator and resolve sequentially;
    • a page reload (SSE reconnect) replays only one dialog card (dedupe).

Notes

  • ExtensionCustomPanel (custom UI method) is intentionally left as an overlay — out of scope for this issue.
  • The select options list caps at maxHeight: 260 with internal scroll (replaces the old flex: 1 overlay cap); the card is maxWidth: 560, width: 100%.

… expiry (agegr#462)

Extension dialogs (ctx.ui.select/confirm/input/editor) were rendered as a
full-window overlay that hid the chat context, ignored timeout/expiresAt,
offered no keyboard support for select, and used a single state slot that
could not express composed flows.

- Inline cards: dialogs now render as cards in the conversation transcript
  (message stream and new-session state) instead of a dimming overlay, so the
  conversation stays readable while answering. Drop aria-modal; the card is
  non-modal by design.
- Keyboard navigation: select supports Arrow keys, Home/End, Enter to pick,
  and Esc to cancel (input/editor already handled Esc; the global stop-agent
  Esc shortcut now yields to focused extension dialogs).
- Dialog stack: unanswered requests queue instead of clobbering each other,
  so composed flows (select -> input) and concurrent requests resolve in order
  (deduped by id against SSE replays on reconnect).
- timeout/expiresAt: dialogs that carry a deadline show a countdown and
  auto-cancel (extension await resolves with its default value, matching the
  daemon-side timeout and TUI behavior).
- Drop stale dialogs when switching sessions.

Verified with a playwright E2E against a real extension command: keyboard
select -> input flow round-trips answers, Esc cancels, countdown auto-cancels,
queued dialogs resolve sequentially, and SSE reconnect replays only one card.
@agegr

agegr commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closing this PR in favor of the existing #612 changes and 2356904 on main.

#612 already provides collapsible dialogs that keep the composer and Stop accessible, plus server-driven dialog closure on timeout/cancellation. The new commit adds arrow-key and Home/End navigation with visible native button focus, scrolls the focused option into view, handles Escape inside the dialog without aborting the agent, and displays a localized countdown from the server's expiresAt value. Expiry remains owned by the server through extension_ui_closed.

The inline layout and preemptive dialog stack are not being adopted here. Review reproduced cancellation leaking from an expired request into an unrelated queued request, loss of a queued dialog's draft, and keyboard selection moving offscreen. Concurrent request queuing can be addressed separately; sequential select-to-input flows already work without a stack.

Validation: TypeScript, ESLint, and all 891 tests pass. The browser suite passes at 1280px and 390px, including real extension commands for navigation, Escape without agent abort, draft preservation across collapse/expand, countdown updates, and answering a new request after server-side expiry. These checks are now retained in the E2E suite.

@agegr agegr closed this Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extension UI dialogs: full-window overlay hides chat context; single-dialog limit breaks select→input flows; timeout/expiresAt unimplemented

2 participants