mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
|
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 0s
nix-check / build (push) Failing after 1s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
ChatComposer tracks the `@…` tokens this surface authored via the
@-mention popover plugin-pick path. When PluginsSection's chip strip
clears, we wire its `onCleared` and prune *only* those tracked
insertions from the draft so the textarea no longer holds orphaned
styled mentions whose chips just unmounted.
Architecture summary (rounds 1–9 collapsed; round 10 detailed below):
- `Array<{token, start, pluginId, insertionId?}>` tracking with
start offsets reconciled across each keystroke via an LCS+LCP
edit-range diff in
`apps/web/src/utils/pluginInsertionTracking.ts` (round 3-4).
`insertionId` is forwarded by `reconcileInsertions` so the
producer can locate its own entry across reconciles
(round 10).
- All draft mutations route through a single `updateDraft`
chokepoint that runs `reconcileInsertions` outside the
`setDraft` updater so React StrictMode's double-invoke is
harmless (round 4-5).
- Boundaries delegate to the shared
`inlineMentions.isMentionBoundary` /
`inlineMentions.isMentionRightBoundary` helpers so the
tracker can never diverge from the parser (round 5).
- `setActivePlugin` is a chokepoint for every applyById path,
filtering tracked entries to those matching the new active
plugin so a replace-plugin flow can never let stale entries
survive (round 6).
- Picker rollback double-snapshots draft + tracker so apply-
failure restores the tracker but only rewrites the draft
when no user keystrokes arrived during the await
(round 7-8).
- `stripPluginInsertedTokens` collapses whitespace seam-local
so user-authored multi-space spans elsewhere are preserved
(round 8).
- `setActivePlugin` is deferred past `await applyById` on
every path, and `onCleared` filters by
`pluginsSectionRef.current?.getActiveRecord()?.id` so a
pending-window clear scopes to the actually-mounted
plugin's tokens (round 9).
race in the picker rollback:
Round 9 made `onCleared` mutate the tracker and the draft when
it ran during a pending replace, and added the `getActiveRecord`
filter so the strip targets the still-mounted plugin's entries
only. The picker's failure-path rollback, however, still
restored `prevEntries` / `prevActiveId` wholesale — assuming
nothing else had touched the tracker during the await. If the
user clicked the still-mounted original chip's × during the
pending replace AND the deferred `applyById` then resolved
with a 500, the wholesale restore (a) resurrected entries that
`onCleared` had legitimately stripped (now stale offsets) and
(b) left the optimistic `@<target>` orphaned in the draft with
no chip ever having mounted — the original #2881 symptom
recurring inside the failure window.
Fix splits the failure rollback into two paths:
1. **Detect "intervening clear" via `activePluginIdRef.current
=== null && prevActiveId !== null`.** `onCleared` always
nulls the active id as its last action; our deferred
`setActivePlugin` never ran in the failure branch. So the
null-while-prev-not-null state is the smoking gun for an
intervening clear during the await.
2. **On detection, surgically remove only our optimistic
entry and only its `@<target>`.** Locate the entry by
`insertionId` (added to `TrackedInsertion` as an optional
field, forwarded by `reconcileInsertions` so the id
survives offset shifts) — this disambiguates the case
where the user picked the same plugin from the @-popover
more than once during the await window. Splice that entry
out and run `updateDraft((d) => stripPluginInsertedTokens(
d, [ourEntry]))` so the draft loses `@<target>` and any
remaining tracked entries (the in-flight target would have
no others, but a co-pending second pick could) get their
offsets reconciled. `activePluginIdRef` stays at `null` —
`onCleared`'s truth, since no chip is mounted.
The "no intervening clear" branch is the round 7/8 path:
restore `prevEntries`/`prevActiveId` wholesale and rewrite
the draft only if `draftRef.current === postInsertDraft`
(no user keystrokes during the await).
Regression coverage (additions):
- `apps/web/tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx`
— 18 integration specs total (17 prior + 1 new round-10):
* `@-popover pick A → @-popover pick B (apply pending) →
clear A's chip → resolve B with 500 → assert no orphan
@<target>, no orphan @A, no chip mounted, no stale
tracker entries`. Uses a deferred `Promise<Response>` so
the apply stays in flight while the chip-clear is fired,
then resolves with a 500 to drive the failure path. Pre-
fix this would resurrect Airbnb's stale entry AND leave
`@SecondPlugin` orphaned in the draft.
PluginsSection.tsx is unchanged. The host-local tracking +
draft-update chokepoint + parser-aligned boundaries + deferred
active-plugin scoping + transactional applyById + intervening-
clear-aware rollback + filtered `onCleared` keep the cross-
component contract identical to main — only ChatComposer touches
behavior, plus the utils module and two `inlineMentions` exports.
Validation:
- pnpm exec vitest run tests/utils/pluginInsertionTracking.test.ts → 36/36 passed
- pnpm exec vitest run tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx → 18/18 passed
- pnpm exec vitest run -c vitest.config.ts (full apps/web suite, 228 files) → 2202/2202 passed
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
|
||
|---|---|---|
| .. | ||
| home-hero | ||
| Theater | ||
| AgentIcon.test.tsx | ||
| agentModelSelection.test.ts | ||
| AmrGuidance.test.tsx | ||
| AmrLoginPill.test.tsx | ||
| App.connectors.test.tsx | ||
| App.mediaProviders.test.tsx | ||
| App.previewKeepAlive.test.tsx | ||
| assistant-message-tool-status.test.tsx | ||
| assistant-message-unfinished-todos.test.tsx | ||
| AssistantMessage.error-pill.test.tsx | ||
| AssistantMessage.feedback-analytics.test.tsx | ||
| AssistantMessage.linkClick.test.tsx | ||
| AssistantMessage.pluginInstall.test.tsx | ||
| AssistantMessage.test.ts | ||
| AssistantMessage.test.tsx | ||
| auto-open-file.test.ts | ||
| BoardComposerPopover.keyboard-submit.test.tsx | ||
| BoardComposerPopover.pod-chip-hover.test.tsx | ||
| BoardComposerPopover.pod-remove.test.tsx | ||
| chat-feedback.test.tsx | ||
| chat-scroll-preservation.test.tsx | ||
| chat-todo-autoscroll.test.tsx | ||
| ChatComposer.context-pickers.test.tsx | ||
| ChatComposer.import-menu.test.tsx | ||
| ChatComposer.infinite-render.test.tsx | ||
| ChatComposer.plugin-clear-prunes-draft.test.tsx | ||
| ChatComposer.search.test.tsx | ||
| ChatPane.connect-repo.test.tsx | ||
| ChatPane.conversation-title.test.tsx | ||
| ChatPane.streaming.test.tsx | ||
| CommentTargetOverlay.hover-class.test.tsx | ||
| ConnectorsBrowser.test.tsx | ||
| ContinueInCliButton.test.tsx | ||
| conversation-timestamps.test.tsx | ||
| CustomSelect.test.tsx | ||
| design-system-github-evidence.test.ts | ||
| design-system-group-order.test.ts | ||
| design-system-project.test.ts | ||
| DesignFilesPanel.test.tsx | ||
| DesignFilesPanel.view-state-persist.test.tsx | ||
| DesignsTab.empty.test.tsx | ||
| DesignsTab.select-mode.test.tsx | ||
| DesignsTab.test.ts | ||
| DesignSystemFlow.test.tsx | ||
| DesignSystemsSection.test.tsx | ||
| DesignSystemsTab.test.tsx | ||
| EntryShell.onboarding.test.tsx | ||
| EntryView.test.ts | ||
| examples-tab-filter-counts.test.tsx | ||
| examples-tab-preview-dispatch.test.tsx | ||
| examples-tab-retry.test.tsx | ||
| ExamplesTab.test.tsx | ||
| file-viewer-image-export.test.tsx | ||
| file-viewer-markdown-copy.test.tsx | ||
| file-viewer-render-mode.test.ts | ||
| FileOpsSummary.test.tsx | ||
| FileViewer.manual-edit-history.test.tsx | ||
| FileViewer.manual-edit.test.tsx | ||
| FileViewer.test.tsx | ||
| FileWorkspace.design-system.test.tsx | ||
| FileWorkspace.test.tsx | ||
| GenUISurfaceRenderer.diff-review.test.tsx | ||
| GenUISurfaceRenderer.schema-form.test.tsx | ||
| GenUISurfaceRenderer.test.tsx | ||
| GithubStarBadge.test.tsx | ||
| HandoffButton.fallback-reveal.test.tsx | ||
| HandoffButton.test.tsx | ||
| HomeHero.plugin-picker.test.tsx | ||
| HomeHero.rail.test.tsx | ||
| HomeView.context-picker.test.tsx | ||
| HomeView.media-options.test.tsx | ||
| HomeView.plugin-i18n.test.tsx | ||
| HomeView.prefill.test.tsx | ||
| InlineModelSwitcher.test.tsx | ||
| InlinePluginsRail.test.tsx | ||
| ManualEditPanel.test.tsx | ||
| MarketplaceView.test.tsx | ||
| McpClientSection.oauth.test.tsx | ||
| McpJsonHelper.test.tsx | ||
| MemorySection.test.tsx | ||
| MissingBrandFontsBanner.test.tsx | ||
| modelOptions.test.tsx | ||
| NewAutomationModal.context-picker.test.tsx | ||
| NewAutomationModal.project-picker.test.tsx | ||
| NewProjectModal.test.tsx | ||
| NewProjectPanel.media.test.tsx | ||
| NewProjectPanel.test.ts | ||
| NewProjectPanel.test.tsx | ||
| pet-task-center.test.ts | ||
| PetOverlay.test.tsx | ||
| PluginDetailsModal.dispatch.test.tsx | ||
| PluginDetailsModal.layering.test.tsx | ||
| PluginExampleDetail.unavailable-noun.test.tsx | ||
| pluginFolderActions.test.ts | ||
| PluginInputsForm.test.tsx | ||
| plugins-home-facets.test.ts | ||
| plugins-home-html-surface.test.tsx | ||
| plugins-home-media-surface.test.tsx | ||
| plugins-home-preview.test.ts | ||
| plugins-home-section.test.tsx | ||
| plugins-home-visualScore.test.ts | ||
| PluginShareMenu.test.tsx | ||
| PluginsSection.test.tsx | ||
| PluginsView.test.tsx | ||
| preview-modal-error-state.test.tsx | ||
| preview-modal-fullscreen.test.tsx | ||
| preview-modal-image-export.test.tsx | ||
| preview-modal-unavailable-state.test.tsx | ||
| PreviewDrawOverlay.send-disabled.test.tsx | ||
| PreviewDrawOverlay.test.tsx | ||
| PreviewModal.test.tsx | ||
| PrivacyConsentModal.test.tsx | ||
| PrivacySection.test.tsx | ||
| ProjectDesignSystemPicker.test.tsx | ||
| ProjectView.api-empty-response.test.tsx | ||
| ProjectView.deleteConversation.test.tsx | ||
| ProjectView.pendingPrompt.test.tsx | ||
| ProjectView.previewKeepAlive.test.tsx | ||
| ProjectView.projectInstructions.test.tsx | ||
| ProjectView.reattach-restore.test.tsx | ||
| ProjectView.run-cleanup.test.tsx | ||
| ProjectView.run-isolation.test.tsx | ||
| ProjectView.tabs-navigation.test.tsx | ||
| QuestionForm.test.tsx | ||
| QuickSwitcher.test.tsx | ||
| RecentProjectsStrip.test.tsx | ||
| registry.latest-release.test.ts | ||
| RoutinesSection.test.tsx | ||
| SettingsDialog.execution.test.tsx | ||
| SettingsDialog.media.test.tsx | ||
| SettingsDialog.orbit.test.tsx | ||
| SettingsDialog.test.ts | ||
| sketch-colors.test.ts | ||
| sketch-model.test.ts | ||
| SketchEditor.default-color.test.tsx | ||
| SketchEditor.save.test.tsx | ||
| SkillsSection.test.tsx | ||
| TasksView.history.test.tsx | ||
| TasksView.page.test.tsx | ||
| TasksView.routines.test.tsx | ||
| TasksView.templates.test.tsx | ||
| Toast.test.tsx | ||
| TrustBadge.test.tsx | ||
| UpdaterPopup.test.tsx | ||
| use-everywhere-agent-guide.test.ts | ||
| WorkingDirPill.test.tsx | ||
| WorkspaceTabsBar.test.tsx | ||