Clean up the leftovers from the steering PR (#35 follow-up) #40

Description

@radroid

Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
longer the primary mid-turn path, steering is.

This issue is the cleanup that transition left behind. Nothing is being reverted and no
commits are being dropped.
The outbox stays, the queue UI stays, steering stays. What follows
is residue: defects the new path introduced, and places that still assume the old
queue-first model.

Line numbers are against main after the 2026-08-02 upstream sync.


A. Defects the steering path introduced or left behind

A1. Queued messages carry their enqueue-time createdAt into the transcript.
useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
(the reducer appends in arrival order), so the inversion only appears on reload, reconnect
resync, or reopening the thread
— and then permanently. Reordering the queue makes it
reproducible: rows delivered in the new order, transcript in the old one.
Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

A2. The same stale timestamp disables the anti-double-send gate.
packages/client-runtime/src/state/threadSettled.ts:37 sets
QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
Any message that waited longer than two minutes — the normal case for a queued message — drains
with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
change as A1.

A3. The dispatch breadcrumb fires before every guard.
ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
composer logs composer dispatch: steer for a submit that never happened. That matters more
than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
whether a message steered or queued, so a noisy one is actively misleading during triage.
Fix: move the call below the guards.

A4. The steer allowlist keys off the wrong provider.
ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
fallback resolves to the first enabled provider, while the server routes by the thread's
persisted binding. A thread whose provider instance was disabled or removed can be classified
steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
to prevent. Fix: key on activeThread.session.providerName. This is what makes the
"fail closed" promise in b9d94d96c actually hold.

A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
ignores the return, closing the editor as if it saved.

A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
is gone from the composer, the draft store, and everywhere else.

B. Still-open behaviour worth a decision (no action proposed)

B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
weakening canSettle, which is shared with settle/snooze and mirrored server-side.

B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
requires only phase === "running", and a session stays running while an approval is pending.
The fork now steers into a state it still refuses to drain into.

B3. One mistimed second message poisons the rest of the turn into queue mode.
queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
server ack round trip (~100–500ms). Type again inside that window and everything queues for the
rest of the turn, signalled only by the glyph changing.

B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
turn and then interrupts the turn that just received it. Probably costs a sentence of visible
copy on the running-turn Send button, which today carries only an aria-label.

C. Divergence and docs

C1. Web and mobile now disagree. Mobile still queues everything mid-turn
(apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
submit path into a running turn and is not in it. It should be, along with the upstream guards
it must mirror.

C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
here so the decision is not re-litigated.

Note on seam cost

A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
keep them tight and update the row if the delta moves.

Metadata

Metadata

Assignees

No one assigned

    Labels

    wontfixThis will not be worked on

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       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

      Clean up the leftovers from the steering PR (#35 follow-up) #40

      Description

      @radroid

      Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
      from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
      longer the primary mid-turn path, steering is.

      This issue is the cleanup that transition left behind. Nothing is being reverted and no
      commits are being dropped.
      The outbox stays, the queue UI stays, steering stays. What follows
      is residue: defects the new path introduced, and places that still assume the old
      queue-first model.

      Line numbers are against main after the 2026-08-02 upstream sync.


      A. Defects the steering path introduced or left behind

      A1. Queued messages carry their enqueue-time createdAt into the transcript.
      useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
      snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
      (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
      resync, or reopening the thread
      — and then permanently. Reordering the queue makes it
      reproducible: rows delivered in the new order, transcript in the old one.
      Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

      A2. The same stale timestamp disables the anti-double-send gate.
      packages/client-runtime/src/state/threadSettled.ts:37 sets
      QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
      Any message that waited longer than two minutes — the normal case for a queued message — drains
      with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
      turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
      change as A1.

      A3. The dispatch breadcrumb fires before every guard.
      ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
      threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
      composer logs composer dispatch: steer for a submit that never happened. That matters more
      than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
      whether a message steered or queued, so a noisy one is actively misleading during triage.
      Fix: move the call below the guards.

      A4. The steer allowlist keys off the wrong provider.
      ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
      fallback resolves to the first enabled provider, while the server routes by the thread's
      persisted binding. A thread whose provider instance was disabled or removed can be classified
      steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
      to prevent. Fix: key on activeThread.session.providerName. This is what makes the
      "fail closed" promise in b9d94d96c actually hold.

      A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
      at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
      send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
      ignores the return, closing the editor as if it saved.

      A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
      a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
      is gone from the composer, the draft store, and everywhere else.

      B. Still-open behaviour worth a decision (no action proposed)

      B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
      hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
      narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
      queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
      weakening canSettle, which is shared with settle/snooze and mirrored server-side.

      B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
      requires only phase === "running", and a session stays running while an approval is pending.
      The fork now steers into a state it still refuses to drain into.

      B3. One mistimed second message poisons the rest of the turn into queue mode.
      queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
      server ack round trip (~100–500ms). Type again inside that window and everything queues for the
      rest of the turn, signalled only by the glyph changing.

      B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
      taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
      turn and then interrupts the turn that just received it. Probably costs a sentence of visible
      copy on the running-turn Send button, which today carries only an aria-label.

      C. Divergence and docs

      C1. Web and mobile now disagree. Mobile still queues everything mid-turn
      (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
      outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
      exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
      not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

      C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
      submit path into a running turn and is not in it. It should be, along with the upstream guards
      it must mirror.

      C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
      here so the decision is not re-litigated.

      Note on seam cost

      A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
      row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
      enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
      keep them tight and update the row if the delta moves.

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        wontfixThis will not be worked on

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , '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

          Clean up the leftovers from the steering PR (#35 follow-up) #40

          Description

          @radroid

          Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
          from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
          longer the primary mid-turn path, steering is.

          This issue is the cleanup that transition left behind. Nothing is being reverted and no
          commits are being dropped.
          The outbox stays, the queue UI stays, steering stays. What follows
          is residue: defects the new path introduced, and places that still assume the old
          queue-first model.

          Line numbers are against main after the 2026-08-02 upstream sync.


          A. Defects the steering path introduced or left behind

          A1. Queued messages carry their enqueue-time createdAt into the transcript.
          useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
          snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
          (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
          resync, or reopening the thread
          — and then permanently. Reordering the queue makes it
          reproducible: rows delivered in the new order, transcript in the old one.
          Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

          A2. The same stale timestamp disables the anti-double-send gate.
          packages/client-runtime/src/state/threadSettled.ts:37 sets
          QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
          Any message that waited longer than two minutes — the normal case for a queued message — drains
          with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
          turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
          change as A1.

          A3. The dispatch breadcrumb fires before every guard.
          ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
          threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
          composer logs composer dispatch: steer for a submit that never happened. That matters more
          than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
          whether a message steered or queued, so a noisy one is actively misleading during triage.
          Fix: move the call below the guards.

          A4. The steer allowlist keys off the wrong provider.
          ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
          fallback resolves to the first enabled provider, while the server routes by the thread's
          persisted binding. A thread whose provider instance was disabled or removed can be classified
          steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
          to prevent. Fix: key on activeThread.session.providerName. This is what makes the
          "fail closed" promise in b9d94d96c actually hold.

          A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
          at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
          send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
          ignores the return, closing the editor as if it saved.

          A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
          a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
          is gone from the composer, the draft store, and everywhere else.

          B. Still-open behaviour worth a decision (no action proposed)

          B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
          hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
          narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
          queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
          weakening canSettle, which is shared with settle/snooze and mirrored server-side.

          B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
          requires only phase === "running", and a session stays running while an approval is pending.
          The fork now steers into a state it still refuses to drain into.

          B3. One mistimed second message poisons the rest of the turn into queue mode.
          queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
          server ack round trip (~100–500ms). Type again inside that window and everything queues for the
          rest of the turn, signalled only by the glyph changing.

          B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
          taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
          turn and then interrupts the turn that just received it. Probably costs a sentence of visible
          copy on the running-turn Send button, which today carries only an aria-label.

          C. Divergence and docs

          C1. Web and mobile now disagree. Mobile still queues everything mid-turn
          (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
          outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
          exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
          not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

          C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
          submit path into a running turn and is not in it. It should be, along with the upstream guards
          it must mirror.

          C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
          here so the decision is not re-litigated.

          Note on seam cost

          A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
          row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
          enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
          keep them tight and update the row if the delta moves.

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            wontfixThis will not be worked on

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , '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 > 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

              Clean up the leftovers from the steering PR (#35 follow-up) #40

              Description

              @radroid

              Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
              from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
              longer the primary mid-turn path, steering is.

              This issue is the cleanup that transition left behind. Nothing is being reverted and no
              commits are being dropped.
              The outbox stays, the queue UI stays, steering stays. What follows
              is residue: defects the new path introduced, and places that still assume the old
              queue-first model.

              Line numbers are against main after the 2026-08-02 upstream sync.


              A. Defects the steering path introduced or left behind

              A1. Queued messages carry their enqueue-time createdAt into the transcript.
              useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
              snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
              (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
              resync, or reopening the thread
              — and then permanently. Reordering the queue makes it
              reproducible: rows delivered in the new order, transcript in the old one.
              Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

              A2. The same stale timestamp disables the anti-double-send gate.
              packages/client-runtime/src/state/threadSettled.ts:37 sets
              QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
              Any message that waited longer than two minutes — the normal case for a queued message — drains
              with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
              turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
              change as A1.

              A3. The dispatch breadcrumb fires before every guard.
              ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
              threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
              composer logs composer dispatch: steer for a submit that never happened. That matters more
              than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
              whether a message steered or queued, so a noisy one is actively misleading during triage.
              Fix: move the call below the guards.

              A4. The steer allowlist keys off the wrong provider.
              ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
              fallback resolves to the first enabled provider, while the server routes by the thread's
              persisted binding. A thread whose provider instance was disabled or removed can be classified
              steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
              to prevent. Fix: key on activeThread.session.providerName. This is what makes the
              "fail closed" promise in b9d94d96c actually hold.

              A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
              at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
              send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
              ignores the return, closing the editor as if it saved.

              A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
              a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
              is gone from the composer, the draft store, and everywhere else.

              B. Still-open behaviour worth a decision (no action proposed)

              B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
              hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
              narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
              queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
              weakening canSettle, which is shared with settle/snooze and mirrored server-side.

              B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
              requires only phase === "running", and a session stays running while an approval is pending.
              The fork now steers into a state it still refuses to drain into.

              B3. One mistimed second message poisons the rest of the turn into queue mode.
              queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
              server ack round trip (~100–500ms). Type again inside that window and everything queues for the
              rest of the turn, signalled only by the glyph changing.

              B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
              taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
              turn and then interrupts the turn that just received it. Probably costs a sentence of visible
              copy on the running-turn Send button, which today carries only an aria-label.

              C. Divergence and docs

              C1. Web and mobile now disagree. Mobile still queues everything mid-turn
              (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
              outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
              exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
              not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

              C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
              submit path into a running turn and is not in it. It should be, along with the upstream guards
              it must mirror.

              C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
              here so the decision is not re-litigated.

              Note on seam cost

              A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
              row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
              enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
              keep them tight and update the row if the delta moves.

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                wontfixThis will not be worked on

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , '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

                  Clean up the leftovers from the steering PR (#35 follow-up) #40

                  Description

                  @radroid

                  Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
                  from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
                  longer the primary mid-turn path, steering is.

                  This issue is the cleanup that transition left behind. Nothing is being reverted and no
                  commits are being dropped.
                  The outbox stays, the queue UI stays, steering stays. What follows
                  is residue: defects the new path introduced, and places that still assume the old
                  queue-first model.

                  Line numbers are against main after the 2026-08-02 upstream sync.


                  A. Defects the steering path introduced or left behind

                  A1. Queued messages carry their enqueue-time createdAt into the transcript.
                  useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
                  snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
                  (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
                  resync, or reopening the thread
                  — and then permanently. Reordering the queue makes it
                  reproducible: rows delivered in the new order, transcript in the old one.
                  Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

                  A2. The same stale timestamp disables the anti-double-send gate.
                  packages/client-runtime/src/state/threadSettled.ts:37 sets
                  QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
                  Any message that waited longer than two minutes — the normal case for a queued message — drains
                  with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
                  turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
                  change as A1.

                  A3. The dispatch breadcrumb fires before every guard.
                  ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
                  threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
                  composer logs composer dispatch: steer for a submit that never happened. That matters more
                  than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
                  whether a message steered or queued, so a noisy one is actively misleading during triage.
                  Fix: move the call below the guards.

                  A4. The steer allowlist keys off the wrong provider.
                  ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
                  fallback resolves to the first enabled provider, while the server routes by the thread's
                  persisted binding. A thread whose provider instance was disabled or removed can be classified
                  steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
                  to prevent. Fix: key on activeThread.session.providerName. This is what makes the
                  "fail closed" promise in b9d94d96c actually hold.

                  A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
                  at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
                  send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
                  ignores the return, closing the editor as if it saved.

                  A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
                  a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
                  is gone from the composer, the draft store, and everywhere else.

                  B. Still-open behaviour worth a decision (no action proposed)

                  B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
                  hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
                  narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
                  queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
                  weakening canSettle, which is shared with settle/snooze and mirrored server-side.

                  B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
                  requires only phase === "running", and a session stays running while an approval is pending.
                  The fork now steers into a state it still refuses to drain into.

                  B3. One mistimed second message poisons the rest of the turn into queue mode.
                  queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
                  server ack round trip (~100–500ms). Type again inside that window and everything queues for the
                  rest of the turn, signalled only by the glyph changing.

                  B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
                  taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
                  turn and then interrupts the turn that just received it. Probably costs a sentence of visible
                  copy on the running-turn Send button, which today carries only an aria-label.

                  C. Divergence and docs

                  C1. Web and mobile now disagree. Mobile still queues everything mid-turn
                  (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
                  outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
                  exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
                  not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

                  C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
                  submit path into a running turn and is not in it. It should be, along with the upstream guards
                  it must mirror.

                  C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
                  here so the decision is not re-litigated.

                  Note on seam cost

                  A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
                  row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
                  enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
                  keep them tight and update the row if the delta moves.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    wontfixThis will not be worked on

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , '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

                      Clean up the leftovers from the steering PR (#35 follow-up) #40

                      Description

                      @radroid

                      Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
                      from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
                      longer the primary mid-turn path, steering is.

                      This issue is the cleanup that transition left behind. Nothing is being reverted and no
                      commits are being dropped.
                      The outbox stays, the queue UI stays, steering stays. What follows
                      is residue: defects the new path introduced, and places that still assume the old
                      queue-first model.

                      Line numbers are against main after the 2026-08-02 upstream sync.


                      A. Defects the steering path introduced or left behind

                      A1. Queued messages carry their enqueue-time createdAt into the transcript.
                      useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
                      snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
                      (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
                      resync, or reopening the thread
                      — and then permanently. Reordering the queue makes it
                      reproducible: rows delivered in the new order, transcript in the old one.
                      Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

                      A2. The same stale timestamp disables the anti-double-send gate.
                      packages/client-runtime/src/state/threadSettled.ts:37 sets
                      QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
                      Any message that waited longer than two minutes — the normal case for a queued message — drains
                      with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
                      turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
                      change as A1.

                      A3. The dispatch breadcrumb fires before every guard.
                      ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
                      threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
                      composer logs composer dispatch: steer for a submit that never happened. That matters more
                      than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
                      whether a message steered or queued, so a noisy one is actively misleading during triage.
                      Fix: move the call below the guards.

                      A4. The steer allowlist keys off the wrong provider.
                      ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
                      fallback resolves to the first enabled provider, while the server routes by the thread's
                      persisted binding. A thread whose provider instance was disabled or removed can be classified
                      steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
                      to prevent. Fix: key on activeThread.session.providerName. This is what makes the
                      "fail closed" promise in b9d94d96c actually hold.

                      A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
                      at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
                      send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
                      ignores the return, closing the editor as if it saved.

                      A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
                      a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
                      is gone from the composer, the draft store, and everywhere else.

                      B. Still-open behaviour worth a decision (no action proposed)

                      B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
                      hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
                      narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
                      queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
                      weakening canSettle, which is shared with settle/snooze and mirrored server-side.

                      B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
                      requires only phase === "running", and a session stays running while an approval is pending.
                      The fork now steers into a state it still refuses to drain into.

                      B3. One mistimed second message poisons the rest of the turn into queue mode.
                      queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
                      server ack round trip (~100–500ms). Type again inside that window and everything queues for the
                      rest of the turn, signalled only by the glyph changing.

                      B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
                      taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
                      turn and then interrupts the turn that just received it. Probably costs a sentence of visible
                      copy on the running-turn Send button, which today carries only an aria-label.

                      C. Divergence and docs

                      C1. Web and mobile now disagree. Mobile still queues everything mid-turn
                      (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
                      outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
                      exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
                      not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

                      C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
                      submit path into a running turn and is not in it. It should be, along with the upstream guards
                      it must mirror.

                      C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
                      here so the decision is not re-litigated.

                      Note on seam cost

                      A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
                      row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
                      enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
                      keep them tight and update the row if the delta moves.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        wontfixThis will not be worked on

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , '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

                          Clean up the leftovers from the steering PR (#35 follow-up) #40

                          Description

                          @radroid

                          Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
                          from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
                          longer the primary mid-turn path, steering is.

                          This issue is the cleanup that transition left behind. Nothing is being reverted and no
                          commits are being dropped.
                          The outbox stays, the queue UI stays, steering stays. What follows
                          is residue: defects the new path introduced, and places that still assume the old
                          queue-first model.

                          Line numbers are against main after the 2026-08-02 upstream sync.


                          A. Defects the steering path introduced or left behind

                          A1. Queued messages carry their enqueue-time createdAt into the transcript.
                          useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
                          snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
                          (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
                          resync, or reopening the thread
                          — and then permanently. Reordering the queue makes it
                          reproducible: rows delivered in the new order, transcript in the old one.
                          Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

                          A2. The same stale timestamp disables the anti-double-send gate.
                          packages/client-runtime/src/state/threadSettled.ts:37 sets
                          QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
                          Any message that waited longer than two minutes — the normal case for a queued message — drains
                          with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
                          turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
                          change as A1.

                          A3. The dispatch breadcrumb fires before every guard.
                          ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
                          threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
                          composer logs composer dispatch: steer for a submit that never happened. That matters more
                          than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
                          whether a message steered or queued, so a noisy one is actively misleading during triage.
                          Fix: move the call below the guards.

                          A4. The steer allowlist keys off the wrong provider.
                          ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
                          fallback resolves to the first enabled provider, while the server routes by the thread's
                          persisted binding. A thread whose provider instance was disabled or removed can be classified
                          steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
                          to prevent. Fix: key on activeThread.session.providerName. This is what makes the
                          "fail closed" promise in b9d94d96c actually hold.

                          A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
                          at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
                          send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
                          ignores the return, closing the editor as if it saved.

                          A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
                          a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
                          is gone from the composer, the draft store, and everywhere else.

                          B. Still-open behaviour worth a decision (no action proposed)

                          B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
                          hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
                          narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
                          queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
                          weakening canSettle, which is shared with settle/snooze and mirrored server-side.

                          B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
                          requires only phase === "running", and a session stays running while an approval is pending.
                          The fork now steers into a state it still refuses to drain into.

                          B3. One mistimed second message poisons the rest of the turn into queue mode.
                          queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
                          server ack round trip (~100–500ms). Type again inside that window and everything queues for the
                          rest of the turn, signalled only by the glyph changing.

                          B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
                          taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
                          turn and then interrupts the turn that just received it. Probably costs a sentence of visible
                          copy on the running-turn Send button, which today carries only an aria-label.

                          C. Divergence and docs

                          C1. Web and mobile now disagree. Mobile still queues everything mid-turn
                          (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
                          outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
                          exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
                          not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

                          C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
                          submit path into a running turn and is not in it. It should be, along with the upstream guards
                          it must mirror.

                          C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
                          here so the decision is not re-litigated.

                          Note on seam cost

                          A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
                          row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
                          enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
                          keep them tight and update the row if the delta moves.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            wontfixThis will not be worked on

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , '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

                              Clean up the leftovers from the steering PR (#35 follow-up) #40

                              Description

                              @radroid

                              Follow-up to #35. The steering work (b9d94d96c, 7e7f91ac6) changed what "Queue" means —
                              from "the thread is busy" to "this cannot go out now" — and settled #35 §0: the queue is no
                              longer the primary mid-turn path, steering is.

                              This issue is the cleanup that transition left behind. Nothing is being reverted and no
                              commits are being dropped.
                              The outbox stays, the queue UI stays, steering stays. What follows
                              is residue: defects the new path introduced, and places that still assume the old
                              queue-first model.

                              Line numbers are against main after the 2026-08-02 upstream sync.


                              A. Defects the steering path introduced or left behind

                              A1. Queued messages carry their enqueue-time createdAt into the transcript.
                              useThreadOutboxDrain.ts:185, :201, :224 all ship createdAt: queuedMessage.createdAt, and the
                              snapshot sorts ORDER BY thread_id, created_at ASC, message_id. The live session looks correct
                              (the reducer appends in arrival order), so the inversion only appears on reload, reconnect
                              resync, or reopening the thread
                              — and then permanently. Reordering the queue makes it
                              reproducible: rows delivered in the new order, transcript in the old one.
                              Fix: stamp createdAt at dispatch, which is what upstream's own immediate-send path does.

                              A2. The same stale timestamp disables the anti-double-send gate.
                              packages/client-runtime/src/state/threadSettled.ts:37 sets
                              QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000, and :64 rejects anything outside that window.
                              Any message that waited longer than two minutes — the normal case for a queued message — drains
                              with the guard already expired. Benign on steer-capable drivers; on Codex it can produce two
                              turn/start calls, the second orphaning the first turn's events. Fixed by the same one-line
                              change as A1.

                              A3. The dispatch breadcrumb fires before every guard.
                              ChatView.tsx:4714 calls logComposerDispatch near the top of onSend, above the
                              threadDetailLoading / isSendBusy / hasSendableContent checks. Pressing Enter on an empty
                              composer logs composer dispatch: steer for a submit that never happened. That matters more
                              than it sounds: per outboxDiagnostics.ts this log is the only client-side evidence of
                              whether a message steered or queued, so a noisy one is actively misleading during triage.
                              Fix: move the call below the guards.

                              A4. The steer allowlist keys off the wrong provider.
                              ChatView.tsx:2150 feeds canSteerActiveThread the composer's selectedProvider, whose
                              fallback resolves to the first enabled provider, while the server routes by the thread's
                              persisted binding. A thread whose provider instance was disabled or removed can be classified
                              steer-capable and take the immediate-send path into Codex — the exact case the allowlist exists
                              to prevent. Fix: key on activeThread.session.providerName. This is what makes the
                              "fail closed" promise in b9d94d96c actually hold.

                              A5. An edit saved during dispatch is silently discarded. The editing hold is only consulted
                              at head-selection time, so an edit started after beginDispatchingQueuedMessage proceeds to
                              send; threadOutboxManager.update returns false and ThreadOutboxQueueList.saveEditing
                              ignores the return, closing the editor as if it saved.

                              A6. A refused steer loses the composer text. Not invisible — recoverTurnStartFailure sets
                              a session error, appends provider.turn.start.failed, and unsticks the composer — but the text
                              is gone from the composer, the draft store, and everywhere else.

                              B. Still-open behaviour worth a decision (no action proposed)

                              B1. The queue holds while the agent is blocked on an approval.canSettle refuses while
                              hasPendingApprovals, so a queued message waits while the agent is waiting on you. Steering
                              narrowed this to Codex and non-empty queues, but it is the case most likely to read as "the
                              queue is broken." The honest fix is a separate canDispatchQueuedTurn predicate rather than
                              weakening canSettle, which is shared with settle/snooze and mirrored server-side.

                              B2. Steering into an approval-blocked turn is new and untested.canSteerActiveThread
                              requires only phase === "running", and a session stays running while an approval is pending.
                              The fork now steers into a state it still refuses to drain into.

                              B3. One mistimed second message poisons the rest of the turn into queue mode.
                              queueCount > 0 is evaluated before the steer branch, and isSendBusy stays true across the
                              server ack round trip (~100–500ms). Type again inside that window and everything queues for the
                              rest of the turn, signalled only by the glyph changing.

                              B4. "Type + Enter, then Stop" now loses the message. The muscle memory the previous build
                              taught (Enter queues → Stop interrupts → queue drains into a fresh turn) now enters the live
                              turn and then interrupts the turn that just received it. Probably costs a sentence of visible
                              copy on the running-turn Send button, which today carries only an aria-label.

                              C. Divergence and docs

                              C1. Web and mobile now disagree. Mobile still queues everything mid-turn
                              (apps/mobile/src/features/threads/composerSendLabel.ts) while web steers. Note that the mobile
                              outbox is upstream's, not the fork's — all seven apps/mobile/src/state/thread-outbox*.ts
                              exist in upstream/main and upstream builds its offline pending-task flow on them — so this is
                              not a simple "port the web behaviour" job. Record the divergence or close it deliberately.

                              C2. docs/t3x/SEAMS.md's "Parallel paths" table has one row. The steering path is a second
                              submit path into a running turn and is not in it. It should be, along with the upstream guards
                              it must mirror.

                              C3. `#35 §2 (queued images) and §3 (send-and-interrupt) are closed as won't-do — recorded
                              here so the decision is not re-litigated.

                              Note on seam cost

                              A1/A2 land in fork-owned files (useThreadOutboxDrain.ts, threadSettled.ts is already a fork
                              row). A3/A4 are edits to lines the fork already owns inside ChatView.tsx, so they do not
                              enlarge the seam surface — but they are new commits touching the ledger's highest-risk file, so
                              keep them tight and update the row if the delta moves.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                wontfixThis will not be worked on

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions