fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(web): an edit saved during dispatch no longer disappears (#40 A5) - #82

Merged
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race
Aug 12, 2026
Merged

fix(web): an edit saved during dispatch no longer disappears (#40 A5)#82
radroid merged 1 commit into
mainfrom
t3x/outbox-edit-race

Conversation

@radroid

Copy link
Copy Markdown
Owner

Partial for #40section A item 5. Independent of #81 (verified: the two branches auto-merge, only outboxDiagnostics.ts overlaps and the hunks are ~20 lines apart).

The race

The drain consults the editing hold only when it picks a head — the editingQueuedMessageIds check sits in the selection loop in useThreadOutboxDrain.ts, above beginDispatchingQueuedMessage. A hold taken after that point does not stop the send already under way.

threadOutboxManager.update handles its side correctly: it is a documented no-op for a message that has left the queue and returns false to say so. What was missing is that saveEditing ignored the return and closed the editor as if it had saved.

Net effect: the message went out with its original text, the user's revision was gone from the queue, the editor, and the composer, and nothing anywhere said so.

Closed from both sides

  • beginEditing refuses to open the editor for the row currently being dispatched. Checked the ordering: selection and beginDispatchingQueuedMessage happen synchronously within the same effect, so dispatchingQueuedMessageIdAtom covers the whole async send with no gap on either end.
  • saveEditing honours the boolean. On false it logs through the module's own [thread-outbox] breadcrumb helper and raises a warning toast saying the message already went out with its original text, so the revision can be re-sent. The row unmounts either way — it renders from the queue — so keeping the editor open is not an option; the news has to leave the component.

logOutboxMutationFailure gains "update" beside "reorder". A no-op update is exactly as invisible to the user as a rejected reorder, which is the case that helper already exists for.

Seam cost

None. Both changed files are fork-created (git cat-file -e <merge-base>:apps/web/src/components/chat/ThreadOutboxQueueList.tsx fails; apps/web/src/outbox/ does not exist upstream at all). The upstream-owned file count stays at 37 and no deletion count moves, so SEAMS.md needs no edit.

Verification, and one honest gap

  • vp test run apps/web/src/outbox — 41 passed.

  • vp lint on both files — clean. tsgo --noEmit -p apps/web — 0 errors.

  • Not unit-tested at the component level.apps/web has 34 component tests and every one of them is renderToStaticMarkup — there is no @testing-library/react, no fireEvent, no userEvent anywhere in the package, so there is no way to drive a click on the Edit button without adding an interaction-test dependency. That is a call for you, not something to slip into a cleanup PR. I did not manufacture a pure-helper wrapper around dispatchingId === messageId just to have something to assert on.

    The contract this change leans on is already pinned: threadOutbox.logic.test.ts:442 asserts manager.update(...) resolves false once the message has left the queue ("stale flush"). What was untested was the caller honouring it, and that is what this PR fixes.

Still open on #40

A6 (a refused steer loses the composer text) is deliberately not here. recoverTurnStartFailure lives in apps/server/src/orchestration/Layers/ProviderCommandReactor.ts — an upstream-owned server file — and the server cannot put text back in a composer anyway. A real fix means the client reacting to provider.turn.start.failed by re-populating the composer and draft store, which is a new client behaviour rather than a leftover, and wants its own design. Section B and C1 also remain.

The drain consults the editing hold only when it PICKS a head
(`useThreadOutboxDrain.ts` — the `editingQueuedMessageIds` check sits in the
selection loop, above `beginDispatchingQueuedMessage`). A hold taken after
that point does not stop the send already under way, and
`threadOutboxManager.update` correctly returns `false` for a message that has
left the queue — but `saveEditing` ignored the return and closed the editor as
if it had saved. Net effect: the message went out with its ORIGINAL text, the
user's revision was gone from the queue, the editor, and the composer, and
nothing anywhere said so.
Closed from both sides:
* `beginEditing` refuses to open the editor for the row currently being
dispatched. Selection and `beginDispatchingQueuedMessage` are synchronous
within the same effect, so the atom covers the whole async send with no gap.
* `saveEditing` honours the boolean. On `false` it logs through the module's
own `[thread-outbox]` breadcrumb helper and raises a warning toast saying the
message already went out with its original text. The row unmounts either way
— it renders from the queue — so the news has to leave the component.
`logOutboxMutationFailure` gains `"update"` beside `"reorder"`; a no-op update
is exactly as invisible to the user as a rejected reorder, which is what that
helper already exists for.
Both changed files are fork-owned; the upstream-owned file count and every
deletion count are unchanged.
Verified: `vp test run apps/web/src/outbox` 41 passed; `vp lint` clean;
`tsgo --noEmit -p apps/web` 0 errors. Not unit-tested at the component level —
apps/web has 34 component tests and every one is `renderToStaticMarkup`, so
there is no way to drive a click here without adding an interaction-test
dependency. The contract this leans on (`update` resolving `false` once the
message has left the queue) is already pinned by
threadOutbox.logic.test.ts:442.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c56a85e3-1a1c-41d8-9d17-11156fdf7cc2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@radroid
radroid merged commit 4e1f44d into mainAug 12, 2026
2 checks passed
@radroid
radroid deleted the t3x/outbox-edit-race branch August 12, 2026 02:06
Sign up for freeto 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

@radroid