Skip to content

Retire wirePluginView() in native input - #9542

Open
malmstein wants to merge 9 commits into
developfrom
feature/david/retire_wire_plugin_view
Open

Retire wirePluginView() in native input#9542
malmstein wants to merge 9 commits into
developfrom
feature/david/retire_wire_plugin_view

Conversation

@malmstein

@malmstein malmstein commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Task/Issue URL: https://app.asana.com/1/137249556945/project/1157893581871903/task/1217549691747336
Tech Design URL (if applicable): https://app.asana.com/1/137249556945/task/1217490861467320
API Proposals URL(s) (if applicable): None

Description

NativeInputModeWidget.wirePluginView() handed dependencies and callbacks to concrete plugin views (AttachmentView, ModelPicker, OptionsView). It was already commented as "will be removed with the new plugin architecture", and the remaining control migrations add nine more plugins on top of it, so it goes first. No duckchat-api change, hence no API proposal.

The apparent options-to-model-picker dependency turned out not to be real: OptionsView.updateCapabilitiesFrom(picker) only asked whether image generation and web search were supported, and those resolve inside ModelPickerViewModel from the app-scoped DuckAiModelManager plus the active chat's model. Nothing view-private was ever read, so no plugin-to-plugin channel was needed.

Commit by commit:

  • EffectiveModelProvider (new, ActivityScope): one home for "which model should the input controls reflect" — the recovery pick while the model-change window is open, else the chat's model, else the global selection. ActivityScope because the unqualified DuckAiChatStore is deliberately bound per activity so it can resolve the browser mode.
  • Options derives its own tool visibility from the provider and the model list, and emits an event when the selected tool stops being supported so the host clears it. Deletes updateCapabilitiesFrom and both widget call sites.
  • The picker resolves through the provider instead of keeping a second copy of the rule. It keeps the local recovery pick, since the menu tick and hasPendingRecoverySelection are per-view concerns.
  • Menu events move onto NativeInputHost (modelMenuShown, modelMenuDismissed, changeModelSubmitted) rather than the widget assigning lambdas to the view. clearSelectedTool becomes a state write, so the widget stops holding OptionsView.
  • wirePluginView deleted. AttachmentView injects its own ViewViewModelFactory, NativeInputStateProvider and FaviconManager like every other plugin view, so bind() is gone; its camera, file-picker, ask-about-page and page-context-removed calls go through the host; isContextual comes from published state. Plugins read host.isEditSurface() at creation, which removes the per-view isEditMode assignments.

Submission still needs the staged attachments, so the widget resolves AttachmentViewModel through its own ViewModelStoreOwner — the same instance the plugin uses — instead of holding the plugin's view. That removed nine view pass-throughs with no new component and no API change; the alternative was a per-tab attachment store, which is bigger and would have needed a proposal.

Left deliberately: the widget still references the model picker for setPickerEnabled and openPicker. Picker-enabled is not derivable from NativeInputState today, because the two hosts mean different things by it (:app binds new-chat-only, the contextual sheet binds input-mode-only to stop model changes mid-chat), and the flag has a second consumer in getSelectedModelId(). Tracked as a follow-up on the Asana task.

Steps to test this PR

Model capabilities and options

  • On a new Duck.ai chat, open the options menu and confirm the tool list matches the selected model's capabilities
  • Pick a model that does not support web search and confirm the web-search option disappears, and any active web-search selection clears without firing a deselect pixel
  • Open an existing chat and confirm the model picker is disabled; start a new chat and confirm it is enabled again
  • Run the FE model-change recovery flow: confirm the chip shows the picked model immediately, and that dismissing the menu without picking ends the change window

Attachments (the regression-prone path)

  • Attach an image from the camera, and from the file picker
  • Attach files past the limit and confirm the limit error and disabled send behave as before
  • Remove an attachment, then submit and confirm the prompt still carries the attachments
  • In the contextual sheet, confirm page context attaches, can be removed, and that "ask about page" still works
  • From the edit-message screen, confirm existing attachments are adopted and the reduced control set still renders

All hosts

  • Repeat a submit with an attachment on the top omnibar, the bottom omnibar and the contextual sheet

UI changes

No UI changes: this is a wiring refactor, every control renders exactly as before.


Note

Medium Risk
Touches attachment submission, FE model-recovery, and options/tool clearing across omnibar, contextual sheet, and edit surfaces; behavior is intended to be unchanged but these paths are regression-prone.

Overview
Removes NativeInputModeWidget.wirePluginView() and the widget’s direct wiring to plugin views. Plugins get isEditMode from NativeInputHost.isEditSurface() at creation; AttachmentView self-injects and drives camera, file picker, page context, and contextual behavior through new host APIs instead of lambdas and bind().

Introduces EffectiveModelProvider (activity-scoped) as the single source for which model drives chip label and tool capabilities: recovery pick during FE model-change, else chat model, else global selection. ModelPickerViewModel and OptionsViewModel consume it; OptionsView no longer calls updateCapabilitiesFrom on the picker and instead observes visibleTools / toolSelectionCleared. Model menu lifecycle and recovery submit move onto NativeInputHost; tool clearing goes through viewModel.setSelectedTool(null).

Submission still reads staged attachments via shared AttachmentViewModel on the widget’s ViewModelStoreOwner, with applyPendingAttachmentState() replaying page context and edit adoptees after attach.

Reviewed by Cursor Bugbot for commit 225495d. Bugbot is set up for automated code reviews on this repo. Configure here.

The model picker resolves which model the input controls should reflect
(recovery pick, else the chat's model, else the global selection), and
the options menu currently gets at that answer by calling into the
picker's view. Extracting the resolution into one app-scoped provider
lets both plugins inject it instead, which is what allows the widget to
stop wiring one plugin view to another.

No behaviour change yet: this only adds the provider and its tests.
OptionsView was told which tools to show by the widget, which read the
answer off the model picker's view: the only real cross-plugin edge in
the native input. Options now resolves it itself from the shared
EffectiveModelProvider plus the model list, and emits an event when the
selected tool stops being supported so the host can clear it.

Deletes updateCapabilitiesFrom, both of the widget's call sites, and the
ModelPicker view members that existed only to answer it. The capability
tests move from ModelPickerViewModelTest to the options and provider
tests; the picker's remaining tests assert effectiveModelId instead.
ModelPickerViewModel had its own copy of the effective-model rule, which
is now the provider's job. It keeps the local recovery pick, because the
menu tick and hasPendingRecoverySelection are per-view concerns, and
reports that pick to the provider so options sees the same model.

The picker's existing tests exercise the provider through it, which is
how the chip-label case caught an earlier version of onRecoveryModelPicked
that made the caller supply a matching chatId. The window is per tab, so
honouring the pick while modelChangeMode is open is enough.
The widget assigned three lambdas to the model picker's view to learn
that its menu opened, closed, or submitted a model change. Those are
plugin-to-host events, so they belong on NativeInputHost: the picker
calls the host it was given at creation, and the widget implements the
same behaviour it had in those lambdas.

Clearing the selected tool becomes a state write instead of a call into
OptionsView, so the widget no longer keeps that view either. What is
left of wirePluginView is the AttachmentView block.
The widget handed dependencies and callbacks to concrete plugin views,
which is what kept every new control tied to it. What is left of that
plumbing moves to where it belongs:

AttachmentView now injects its own ViewModel factory, state provider and
favicon manager like the other plugin views, so bind() is gone. Its
camera, file picker, ask-about-page and page-context-removed calls go
through NativeInputHost, and it reads isContextual from the published
state instead of being told.

Plugins learn they are on the edit surface from host.isEditSurface() at
creation, which removes the per-view isEditMode assignments.

Submission still needs the staged attachments, so the widget resolves
AttachmentViewModel through its own ViewModelStoreOwner (the same
instance the plugin uses) rather than holding the plugin's view. That
leaves the model picker as the widget's only plugin-view reference, kept
for setPickerEnabled and openPicker.
The provider recorded the model picked during a model-change window and
never dropped it. Gating on modelChangeMode only hid it while the window
was closed, so the next window in the same activity reapplied the old
pick, and the picker's menu tick disagreed because its own copy is reset.

The picker already owns that lifecycle, so it now clears the provider
alongside its local copy. Covered at both levels: the provider contract,
and the picker case that regressed (fails without the clear).
Deriving isContextual from the published inputContext was wrong: the
sheet shares its per-tab state slot with the omnibar widget and that slot
can briefly carry BROWSER state, which would have hidden Ask about page
and the attach control on the sheet. The widget already keeps a sticky
flag for exactly this reason, so the plugin asks the host for it.

Also cancel the attachment-state collector on detach. It was launched
without keeping the job, so a re-attach would have added a second
collector writing into the shared attachments container.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 7423218. Configure here.

@malmstein

Copy link
Copy Markdown
Contributor Author

Verification

Unified-input Maestro suite, green on a local API 34 emulator (--include-tags unifiedInputTest, internal release build of 7423218116, 6/6 flows, each across top/bottom/split omnibar):

Flow
duck.ai tool pills select and remove Passed (1m 33s)
duck.ai web search prompt sticky-then-clears Passed (2m 47s)
open duck.ai via icon (AI off) and back to NTP Passed (2m 2s)
ask duck.ai suggestion opens chat Passed (1m 44s)
seeded favorites visible across omnibar × AI combos Passed (52s)
search suggestions visible and dismiss Passed (56s)

The tool-pills flow is the relevant one for this PR: it exercises the path where options now derives tool visibility from EffectiveModelProvider instead of asking the model picker's view.

Attachments verified manually on device (attaching photos, file limits, and related paths). There are no attachment flows in .maestro, so this could only be done by hand, and it is the area two of the three Bugbot findings landed in.

Unit: :duckchat-impl:testDebugUnitTest and :duckchat-impl:spotlessCheck green; :app compiles for both play and internal. The recovery-pick regression test was checked properly, by reverting only the production line and confirming ModelPickerViewModelTest fails without it.

Storing one activity-scoped pick was wrong in both directions, and Bugbot
caught the second half: the picker clears on modelChangeMode going false
from the selected-tab flow, so leaving a tab whose window is still open
wiped its pick, and returning to it showed the wrong model.

The pick now carries the chatId whose window it belongs to. It applies
only while that chat's window is open, and a clear only drops a pick for
the same chat, so one tab's window closing cannot affect another's. The
picker takes the key from published state rather than currentChat, which
can still be resolving when the user picks.
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.

1 participant