fix(apps): close the three gaps the review flagged as previously missed

**The task-augmented inbound branch bypassed the routing.** A server→client
`elicitation/create` carrying `params.task` answers immediately with a
`CreateTaskResult` and settles the TASK when the user answers, so it cannot
ride `enqueuePendingElicitation` — and it was therefore never offered to an
app, even with a valid `_meta.ui.resourceUri`. It now makes the same attempt,
completing the task with the app's result and falling back to the native queue
otherwise; an abort fails the task rather than reopening it natively. The two
task-settling closures are extracted so both answer routes settle it
identically.

**The web client advertised the capability without a sandbox.**
`useInitialConfig` leaves `sandboxUrl` undefined while `/api/config` is in
flight and when the sandbox controller could not start, but the renderer was
supplied unconditionally — so the client claimed it could host an app in
exactly the window where it could not. The option is now gated on a resolved
sandbox URL: such a connection behaves like the CLI/TUI, native queue and no
claim made.

**Concurrent modals each owned the keyboard.** Every open `Modal.Root`
installed its own focus trap, Escape handler and overlay, so the traps fought
and one Escape could dismiss more than one pending request. Only the topmost
owns them now; the rest stay mounted, keeping their bridges and handshakes,
but inert to the keyboard.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 22:05:15 -04:00
commit d90c51a04c
2 changed files with 206 additions and 32 deletions

View file

@ -193,6 +193,35 @@ describe("AppElicitationHost (#1854)", () => {
expect(onSettle.mock.calls[0][0]).toBe("req-2");
});
it("gives the focus trap, Escape and the overlay to the top modal only", async () => {
// Every entry stays mounted (each app keeps its own bridge and handshake),
// but only one may own the keyboard — otherwise the traps fight and a
// single Escape can dismiss more than one pending request.
const first = createMockBridge({});
const second = createMockBridge({});
const bridges = [first.bridge, second.bridge];
const factory = vi.fn(() => bridges.shift()) as unknown as BridgeFactory;
const user = userEvent.setup();
renderWithMantine(
<AppElicitationHost
entries={[makeEntry("req-1"), makeEntry("req-2")]}
sandboxPath="/sandbox.html"
bridgeFactory={factory}
onSettle={onSettle}
onFail={onFail}
/>,
);
await flushAsync();
expect(screen.getAllByTestId("app-elicitation")).toHaveLength(2);
await user.keyboard("{Escape}");
// Exactly one request is dismissed — the topmost, which is the last one.
expect(onFail).toHaveBeenCalledTimes(1);
expect(onFail.mock.calls[0][0]).toBe("req-2");
expect(onFail.mock.calls[0][1].message).toMatch(/dismissed/);
});
it("falls back when the app does not advertise elicitation", async () => {
const mock = createMockBridge({ elicitation: false });
renderWithMantine(

View file

@ -118,10 +118,13 @@ export function AppElicitationHost({
return (
<>
{entries.map((entry) => (
{entries.map((entry, index) => (
<AppElicitationFrame
key={entry.requestId}
entry={entry}
// Only the last-rendered modal is on top, so only it may own the
// focus trap, the Escape key and the overlay — see the prop's doc.
isTop={index === entries.length - 1}
sandboxPath={sandboxPath}
bridgeFactory={bridgeFactory}
onSettle={onSettle}
@ -134,6 +137,16 @@ export function AppElicitationHost({
interface AppElicitationFrameProps {
entry: AppElicitationEntry;
/**
* Whether this modal is the topmost of the open set.
*
* Concurrent elicitations mean several are open at once, and each would
* otherwise install its own focus trap, Escape handler and overlay — which
* fight over the keyboard and let one Escape dismiss more than one pending
* request. Only the top one owns those; the rest stay mounted (their apps
* keep their bridges and their handshakes) but inert to the keyboard.
*/
isTop: boolean;
sandboxPath: string;
bridgeFactory: BridgeFactory;
onSettle: (requestId: string, result: ElicitResult) => void;
@ -150,6 +163,7 @@ interface AppElicitationFrameProps {
*/
function AppElicitationFrame({
entry,
isTop,
sandboxPath,
bridgeFactory,
onSettle,
@ -221,8 +235,13 @@ function AppElicitationFrame({
);
return (
<ElicitationModal opened onClose={dismiss}>
<ElicitationOverlay />
<ElicitationModal
opened
onClose={dismiss}
trapFocus={isTop}
closeOnEscape={isTop}
>
{isTop && <ElicitationOverlay />}
{/* `aria-label` and the `data-*` attributes go on the CONTENT (the
role="dialog" element), not the Root — the Root is only a portal
wrapper, so a name placed there never reaches the dialog. Named per