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

View file

@ -1436,33 +1436,57 @@ export class InspectorClient extends InspectorClientEventTarget {
initialStatus: "input_required",
statusMessage: "Awaiting user input",
});
// Settling the receiver task, shared by both answer routes below so
// an app-rendered answer completes the task exactly as a native one
// does.
const completeTask = (result: ElicitResult) => {
record.resolvePayload(result);
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: new Date().toISOString(),
};
record.task = updated;
this.upsertReceiverTask(updated);
};
const failTask = (error: Error) => {
record.rejectPayload(error);
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: new Date().toISOString(),
statusMessage: error.message,
};
record.task = updated;
this.upsertReceiverTask(updated);
};
void (async () => {
// A task-augmented request is still an `elicitation/create`, so the
// app-rendering contract applies to it too (#1854). It cannot go
// through `enqueuePendingElicitation` — the response frame has
// already been sent as a `CreateTaskResult` and the answer settles
// the TASK rather than the request — so the same attempt is made
// here, falling back to the native queue exactly as that funnel
// does. An abort (disconnect) fails the task rather than reopening
// it natively.
let appResult: ElicitResult | null;
try {
appResult = await this.tryAppElicitation(request);
} catch (error) {
failTask(
error instanceof Error ? error : new Error(String(error)),
);
return;
}
if (appResult) {
completeTask(appResult);
return;
}
const elicitationRequest = new ElicitationCreateMessage(
request,
(result) => {
record.resolvePayload(result);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: now,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
completeTask,
(id) => this.removePendingElicitation(id),
(error) => {
record.rejectPayload(error);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: now,
statusMessage: error.message,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
failTask,
);
this.addPendingElicitation(elicitationRequest);
})();