DocumentationDecision recordsADR-0040

An AI run is visible work, steered from the note

ADR-0036 moved a text result out of a modal and into the note. Three things it left alone turned out to be the rest of the same problem.

accepted

Context

ADR-0036 moved a text result out of a modal and into the note. Three things it left alone turned out to be the rest of the same problem.

The first is reach. The only visible way into AI was a button on the selection toolbar, so the actions that read the whole note — summarise, outline, suggest a title, extract tasks, suggest tags — were reachable only by selecting text those actions then ignore, or by knowing a command-palette entry existed. AI was present exactly when it was least applicable.

The second is the surface that starts a run. Choosing an action opened a modal picker, and picking one opened a second modal that asked the writer to press Run. Both covered the paragraph the action was about, and the second one asked for a confirmation that buys nothing: ADR-0033 already guarantees a completion cannot touch the document, and ADR-0036 already puts the result in front of the writer before it can.

The third is what a run looks like while it happens. useAiRun had two states a surface could see — composing and streaming — and a stream that has opened but produced no token is indistinguishable in both from a stream that was never started. A cold local model can spend twenty seconds there. The note does not change, no text arrives, and the only honest reading available to a writer is that nothing happened. Stopping was worse: cancel asked the provider to stop and waited for an acknowledgement that a failed transport never sends, so the button could be pressed with no visible effect at all.

Decision

AI has a standing entry point. AiLauncher renders into the editor pane whenever the opt-in gate is open, and opens the menu on the caret when nothing is selected. It is part of the AI host, so it is absent exactly when AI is. The selection toolbar's entry stays and is now named rather than drawn as one more unlabelled mark.

The menu is anchored, not layered. AiMenu is a popover over the range it is about, sharing rangeMenuAnchor with the bubble and link menus so the two swap in place. Selection actions stay listed with nothing selected, carrying the reason they cannot run: hiding them would make the menu's contents depend on state the writer cannot see from inside the menu.

An action that asks the writer nothing starts on the click that chose it. Only the three actions with an instruction — change tone, translate, custom — get a second step, and that step is a pane inside the same popover rather than a second surface. The payload preview ADR-0033 requires is a disclosure in that pane; for the actions with no instruction it is the selection itself, already on screen and unobscured.

A run has stages, and they are shown. AiActionRun carries an AiRunStage alongside its phase: sending while the invocation that opens the stream is in flight, waiting once the provider accepted it and before any token, and generating from the first delta. aiRunSteps turns that into three labelled steps the card paints, so a slow run says which half of the round trip it is slow in, and a failure marks the step it failed in rather than reddening the whole card. A run quiet past AI_RUN_STALL_MS says so in words. Elapsed time comes off a clock that only runs while a run does.

Stopping is answered locally. cancel disposes the consumer, asks the seam to stop, and moves the run to cancelled itself. Waiting for a provider to confirm a cancellation is waiting for something that may never come, and there is nothing to be careful about: no completion has touched the document, so the note is unchanged whether the provider heard or not. A straggling delta cannot reopen the run because the consumer is gone before the state changes.

Every outcome is reviewed in the note. ADR-0036 kept the dialog for results with no range to diff — a title, a task list, a set of tags. AiRunCard handles all four outcomes in the same widget: the diff for a replacement, the plan checklist for tasks and tags, the proposed line for a title. Only a replacement strikes its range through, because only a replacement is going to take it.

Consequences

There is one AI surface in the editor instead of three, and one component owning apply-time refusal instead of two copies of it. applyRefusal is still asked in exactly one place per card.

The launcher finds its pane by polling getView() briefly on mount rather than having the editor push the element down as a prop. That is a deliberate trade: one button does not justify widening the editor's interface, and the poll stops at the first hit.

The card is now the only place a non-text result is reviewed, so a result whose range is gone can no longer be read at all — the plugin clears the preview on any docChanged, which was already true for text and is now true for tags. That is the same rule ADR-0036 chose deliberately, applied consistently.

Stopping a run before the seam has answered means the provider may still be generating for a moment after the card says the run is over. Nothing consumes what it produces, and cancel_ai_completion is still sent.