Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

Description

@radroid

Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
design decision that should be settled first, because it changes how much of the rest is worth
building.


0. The decision: does the queue earn its keep?

The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
type, hit send, the current turn is interrupted and your message starts the next one immediately.
That is what most agent UIs do, and it is what people reach for reflexively.

I checked whether upstream already covers this — it does not:

// packages/contracts/src/provider.ts:87
export const ProviderInterruptTurnInput = Schema.Struct({
threadId: ThreadId,
turnId: Schema.optional(TurnId),
});

Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
turn and submits text. The queue is not redundant today.

But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

  • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
  • Disconnected buffering — composing while the backend is unreachable
  • Survives reload — localStorage-backed, so a refresh doesn't lose the message

Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
send-and-interrupt, or remove it. Everything below assumes it survives in some form.


1. The queue UI is visually heavy

From the current build (screenshot in thread):

  • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
    producing a heavy double-border container that dominates the bottom of the thread.
  • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
    pushes the composer far up the viewport.
  • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
  • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
    they act on, and are always visible rather than on hover/focus.
  • The footer now carries three competing controls — a progress spinner, a saturated red stop
    button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
    being the secondary action.
  • Nothing states the contract: that these send automatically when the turn finishes.
  • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
than a second panel, and add a short "Sends when this turn finishes" hint.

2. Images can't be queued

Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
is dropped, and you get a warning toast:

Images aren't queued — Queued messages are text-only; attached images were not added to the
queued message.

Image-only (no text) is refused outright with a similar toast.

The current behaviour is deliberate and correctly implemented — the code comments in
ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
enqueue never misreports. The limitation is the storage layer:

// apps/web/src/outbox/threadOutboxStorage.ts
// localStorage-backed persistence for the thread outbox ...

localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
Needs a migration path for messages already in localStorage, and a quota/eviction story.

Note this also affects the mobile outbox, which mirrors the same design.

3. Send-and-interrupt

Add a way to stop the running turn and immediately send the composed message. Requires a contract
change — either a new RPC or an optional message payload on the interrupt input, since the current
one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
long-running turn by muscle memory.


Suggested order

  1. Settle §0 — it determines whether §1 is polish or removal.
  2. §3 send-and-interrupt (contract change, highest user value).
  3. §1 UI polish, scoped by the §0 outcome.
  4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    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

      Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

      Description

      @radroid

      Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
      design decision that should be settled first, because it changes how much of the rest is worth
      building.


      0. The decision: does the queue earn its keep?

      The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
      type, hit send, the current turn is interrupted and your message starts the next one immediately.
      That is what most agent UIs do, and it is what people reach for reflexively.

      I checked whether upstream already covers this — it does not:

      // packages/contracts/src/provider.ts:87
      export const ProviderInterruptTurnInput = Schema.Struct({
      threadId: ThreadId,
      turnId: Schema.optional(TurnId),
      });
      

      Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
      turn and submits text. The queue is not redundant today.

      But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

      • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
      • Disconnected buffering — composing while the backend is unreachable
      • Survives reload — localStorage-backed, so a refresh doesn't lose the message

      Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
      queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
      send-and-interrupt, or remove it. Everything below assumes it survives in some form.


      1. The queue UI is visually heavy

      From the current build (screenshot in thread):

      • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
        producing a heavy double-border container that dominates the bottom of the thread.
      • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
        pushes the composer far up the viewport.
      • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
      • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
        they act on, and are always visible rather than on hover/focus.
      • The footer now carries three competing controls — a progress spinner, a saturated red stop
        button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
        being the secondary action.
      • Nothing states the contract: that these send automatically when the turn finishes.
      • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

      Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
      revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
      than a second panel, and add a short "Sends when this turn finishes" hint.

      2. Images can't be queued

      Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
      is dropped, and you get a warning toast:

      Images aren't queued — Queued messages are text-only; attached images were not added to the
      queued message.

      Image-only (no text) is refused outright with a similar toast.

      The current behaviour is deliberate and correctly implemented — the code comments in
      ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
      enqueue never misreports. The limitation is the storage layer:

      // apps/web/src/outbox/threadOutboxStorage.ts
      // localStorage-backed persistence for the thread outbox ...
      

      localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
      reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
      quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
      Needs a migration path for messages already in localStorage, and a quota/eviction story.

      Note this also affects the mobile outbox, which mirrors the same design.

      3. Send-and-interrupt

      Add a way to stop the running turn and immediately send the composed message. Requires a contract
      change — either a new RPC or an optional message payload on the interrupt input, since the current
      one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
      long-running turn by muscle memory.


      Suggested order

      1. Settle §0 — it determines whether §1 is polish or removal.
      2. §3 send-and-interrupt (contract change, highest user value).
      3. §1 UI polish, scoped by the §0 outcome.
      4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        No labels
        No labels

        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

          Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

          Description

          @radroid

          Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
          design decision that should be settled first, because it changes how much of the rest is worth
          building.


          0. The decision: does the queue earn its keep?

          The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
          type, hit send, the current turn is interrupted and your message starts the next one immediately.
          That is what most agent UIs do, and it is what people reach for reflexively.

          I checked whether upstream already covers this — it does not:

          // packages/contracts/src/provider.ts:87
          export const ProviderInterruptTurnInput = Schema.Struct({
          threadId: ThreadId,
          turnId: Schema.optional(TurnId),
          });
          

          Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
          turn and submits text. The queue is not redundant today.

          But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

          • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
          • Disconnected buffering — composing while the backend is unreachable
          • Survives reload — localStorage-backed, so a refresh doesn't lose the message

          Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
          queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
          send-and-interrupt, or remove it. Everything below assumes it survives in some form.


          1. The queue UI is visually heavy

          From the current build (screenshot in thread):

          • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
            producing a heavy double-border container that dominates the bottom of the thread.
          • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
            pushes the composer far up the viewport.
          • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
          • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
            they act on, and are always visible rather than on hover/focus.
          • The footer now carries three competing controls — a progress spinner, a saturated red stop
            button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
            being the secondary action.
          • Nothing states the contract: that these send automatically when the turn finishes.
          • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

          Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
          revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
          than a second panel, and add a short "Sends when this turn finishes" hint.

          2. Images can't be queued

          Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
          is dropped, and you get a warning toast:

          Images aren't queued — Queued messages are text-only; attached images were not added to the
          queued message.

          Image-only (no text) is refused outright with a similar toast.

          The current behaviour is deliberate and correctly implemented — the code comments in
          ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
          enqueue never misreports. The limitation is the storage layer:

          // apps/web/src/outbox/threadOutboxStorage.ts
          // localStorage-backed persistence for the thread outbox ...
          

          localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
          reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
          quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
          Needs a migration path for messages already in localStorage, and a quota/eviction story.

          Note this also affects the mobile outbox, which mirrors the same design.

          3. Send-and-interrupt

          Add a way to stop the running turn and immediately send the composed message. Requires a contract
          change — either a new RPC or an optional message payload on the interrupt input, since the current
          one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
          long-running turn by muscle memory.


          Suggested order

          1. Settle §0 — it determines whether §1 is polish or removal.
          2. §3 send-and-interrupt (contract change, highest user value).
          3. §1 UI polish, scoped by the §0 outcome.
          4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            No labels
            No labels

            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

              Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

              Description

              @radroid

              Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
              design decision that should be settled first, because it changes how much of the rest is worth
              building.


              0. The decision: does the queue earn its keep?

              The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
              type, hit send, the current turn is interrupted and your message starts the next one immediately.
              That is what most agent UIs do, and it is what people reach for reflexively.

              I checked whether upstream already covers this — it does not:

              // packages/contracts/src/provider.ts:87
              export const ProviderInterruptTurnInput = Schema.Struct({
              threadId: ThreadId,
              turnId: Schema.optional(TurnId),
              });
              

              Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
              turn and submits text. The queue is not redundant today.

              But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

              • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
              • Disconnected buffering — composing while the backend is unreachable
              • Survives reload — localStorage-backed, so a refresh doesn't lose the message

              Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
              queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
              send-and-interrupt, or remove it. Everything below assumes it survives in some form.


              1. The queue UI is visually heavy

              From the current build (screenshot in thread):

              • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
                producing a heavy double-border container that dominates the bottom of the thread.
              • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
                pushes the composer far up the viewport.
              • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
              • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
                they act on, and are always visible rather than on hover/focus.
              • The footer now carries three competing controls — a progress spinner, a saturated red stop
                button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
                being the secondary action.
              • Nothing states the contract: that these send automatically when the turn finishes.
              • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

              Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
              revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
              than a second panel, and add a short "Sends when this turn finishes" hint.

              2. Images can't be queued

              Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
              is dropped, and you get a warning toast:

              Images aren't queued — Queued messages are text-only; attached images were not added to the
              queued message.

              Image-only (no text) is refused outright with a similar toast.

              The current behaviour is deliberate and correctly implemented — the code comments in
              ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
              enqueue never misreports. The limitation is the storage layer:

              // apps/web/src/outbox/threadOutboxStorage.ts
              // localStorage-backed persistence for the thread outbox ...
              

              localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
              reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
              quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
              Needs a migration path for messages already in localStorage, and a quota/eviction story.

              Note this also affects the mobile outbox, which mirrors the same design.

              3. Send-and-interrupt

              Add a way to stop the running turn and immediately send the composed message. Requires a contract
              change — either a new RPC or an optional message payload on the interrupt input, since the current
              one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
              long-running turn by muscle memory.


              Suggested order

              1. Settle §0 — it determines whether §1 is polish or removal.
              2. §3 send-and-interrupt (contract change, highest user value).
              3. §1 UI polish, scoped by the §0 outcome.
              4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                No labels
                No labels

                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

                  Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

                  Description

                  @radroid

                  Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
                  design decision that should be settled first, because it changes how much of the rest is worth
                  building.


                  0. The decision: does the queue earn its keep?

                  The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
                  type, hit send, the current turn is interrupted and your message starts the next one immediately.
                  That is what most agent UIs do, and it is what people reach for reflexively.

                  I checked whether upstream already covers this — it does not:

                  // packages/contracts/src/provider.ts:87
                  export const ProviderInterruptTurnInput = Schema.Struct({
                  threadId: ThreadId,
                  turnId: Schema.optional(TurnId),
                  });
                  

                  Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
                  turn and submits text. The queue is not redundant today.

                  But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

                  • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
                  • Disconnected buffering — composing while the backend is unreachable
                  • Survives reload — localStorage-backed, so a refresh doesn't lose the message

                  Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
                  queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
                  send-and-interrupt, or remove it. Everything below assumes it survives in some form.


                  1. The queue UI is visually heavy

                  From the current build (screenshot in thread):

                  • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
                    producing a heavy double-border container that dominates the bottom of the thread.
                  • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
                    pushes the composer far up the viewport.
                  • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
                  • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
                    they act on, and are always visible rather than on hover/focus.
                  • The footer now carries three competing controls — a progress spinner, a saturated red stop
                    button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
                    being the secondary action.
                  • Nothing states the contract: that these send automatically when the turn finishes.
                  • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

                  Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
                  revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
                  than a second panel, and add a short "Sends when this turn finishes" hint.

                  2. Images can't be queued

                  Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
                  is dropped, and you get a warning toast:

                  Images aren't queued — Queued messages are text-only; attached images were not added to the
                  queued message.

                  Image-only (no text) is refused outright with a similar toast.

                  The current behaviour is deliberate and correctly implemented — the code comments in
                  ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
                  enqueue never misreports. The limitation is the storage layer:

                  // apps/web/src/outbox/threadOutboxStorage.ts
                  // localStorage-backed persistence for the thread outbox ...
                  

                  localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
                  reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
                  quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
                  Needs a migration path for messages already in localStorage, and a quota/eviction story.

                  Note this also affects the mobile outbox, which mirrors the same design.

                  3. Send-and-interrupt

                  Add a way to stop the running turn and immediately send the composed message. Requires a contract
                  change — either a new RPC or an optional message payload on the interrupt input, since the current
                  one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
                  long-running turn by muscle memory.


                  Suggested order

                  1. Settle §0 — it determines whether §1 is polish or removal.
                  2. §3 send-and-interrupt (contract change, highest user value).
                  3. §1 UI polish, scoped by the §0 outcome.
                  4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    No labels
                    No labels

                    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

                      Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

                      Description

                      @radroid

                      Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
                      design decision that should be settled first, because it changes how much of the rest is worth
                      building.


                      0. The decision: does the queue earn its keep?

                      The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
                      type, hit send, the current turn is interrupted and your message starts the next one immediately.
                      That is what most agent UIs do, and it is what people reach for reflexively.

                      I checked whether upstream already covers this — it does not:

                      // packages/contracts/src/provider.ts:87
                      export const ProviderInterruptTurnInput = Schema.Struct({
                      threadId: ThreadId,
                      turnId: Schema.optional(TurnId),
                      });
                      

                      Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
                      turn and submits text. The queue is not redundant today.

                      But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

                      • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
                      • Disconnected buffering — composing while the backend is unreachable
                      • Survives reload — localStorage-backed, so a refresh doesn't lose the message

                      Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
                      queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
                      send-and-interrupt, or remove it. Everything below assumes it survives in some form.


                      1. The queue UI is visually heavy

                      From the current build (screenshot in thread):

                      • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
                        producing a heavy double-border container that dominates the bottom of the thread.
                      • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
                        pushes the composer far up the viewport.
                      • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
                      • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
                        they act on, and are always visible rather than on hover/focus.
                      • The footer now carries three competing controls — a progress spinner, a saturated red stop
                        button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
                        being the secondary action.
                      • Nothing states the contract: that these send automatically when the turn finishes.
                      • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

                      Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
                      revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
                      than a second panel, and add a short "Sends when this turn finishes" hint.

                      2. Images can't be queued

                      Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
                      is dropped, and you get a warning toast:

                      Images aren't queued — Queued messages are text-only; attached images were not added to the
                      queued message.

                      Image-only (no text) is refused outright with a similar toast.

                      The current behaviour is deliberate and correctly implemented — the code comments in
                      ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
                      enqueue never misreports. The limitation is the storage layer:

                      // apps/web/src/outbox/threadOutboxStorage.ts
                      // localStorage-backed persistence for the thread outbox ...
                      

                      localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
                      reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
                      quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
                      Needs a migration path for messages already in localStorage, and a quota/eviction story.

                      Note this also affects the mobile outbox, which mirrors the same design.

                      3. Send-and-interrupt

                      Add a way to stop the running turn and immediately send the composed message. Requires a contract
                      change — either a new RPC or an optional message payload on the interrupt input, since the current
                      one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
                      long-running turn by muscle memory.


                      Suggested order

                      1. Settle §0 — it determines whether §1 is polish or removal.
                      2. §3 send-and-interrupt (contract change, highest user value).
                      3. §1 UI polish, scoped by the §0 outcome.
                      4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        No labels
                        No labels

                        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

                          Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

                          Description

                          @radroid

                          Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
                          design decision that should be settled first, because it changes how much of the rest is worth
                          building.


                          0. The decision: does the queue earn its keep?

                          The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
                          type, hit send, the current turn is interrupted and your message starts the next one immediately.
                          That is what most agent UIs do, and it is what people reach for reflexively.

                          I checked whether upstream already covers this — it does not:

                          // packages/contracts/src/provider.ts:87
                          export const ProviderInterruptTurnInput = Schema.Struct({
                          threadId: ThreadId,
                          turnId: Schema.optional(TurnId),
                          });
                          

                          Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
                          turn and submits text. The queue is not redundant today.

                          But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

                          • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
                          • Disconnected buffering — composing while the backend is unreachable
                          • Survives reload — localStorage-backed, so a refresh doesn't lose the message

                          Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
                          queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
                          send-and-interrupt, or remove it. Everything below assumes it survives in some form.


                          1. The queue UI is visually heavy

                          From the current build (screenshot in thread):

                          • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
                            producing a heavy double-border container that dominates the bottom of the thread.
                          • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
                            pushes the composer far up the viewport.
                          • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
                          • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
                            they act on, and are always visible rather than on hover/focus.
                          • The footer now carries three competing controls — a progress spinner, a saturated red stop
                            button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
                            being the secondary action.
                          • Nothing states the contract: that these send automatically when the turn finishes.
                          • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

                          Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
                          revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
                          than a second panel, and add a short "Sends when this turn finishes" hint.

                          2. Images can't be queued

                          Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
                          is dropped, and you get a warning toast:

                          Images aren't queued — Queued messages are text-only; attached images were not added to the
                          queued message.

                          Image-only (no text) is refused outright with a similar toast.

                          The current behaviour is deliberate and correctly implemented — the code comments in
                          ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
                          enqueue never misreports. The limitation is the storage layer:

                          // apps/web/src/outbox/threadOutboxStorage.ts
                          // localStorage-backed persistence for the thread outbox ...
                          

                          localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
                          reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
                          quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
                          Needs a migration path for messages already in localStorage, and a quota/eviction story.

                          Note this also affects the mobile outbox, which mirrors the same design.

                          3. Send-and-interrupt

                          Add a way to stop the running turn and immediately send the composed message. Requires a contract
                          change — either a new RPC or an optional message payload on the interrupt input, since the current
                          one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
                          long-running turn by muscle memory.


                          Suggested order

                          1. Settle §0 — it determines whether §1 is polish or removal.
                          2. §3 send-and-interrupt (contract change, highest user value).
                          3. §1 UI polish, scoped by the §0 outcome.
                          4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            No labels
                            No labels

                            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

                              Thread outbox: UI polish, queue images, send-and-interrupt — and decide whether the queue should survive #35

                              Description

                              @radroid

                              Follow-up on the thread outbox (queue-while-running) feature from #26. Three concrete gaps plus one
                              design decision that should be settled first, because it changes how much of the rest is worth
                              building.


                              0. The decision: does the queue earn its keep?

                              The queue exists because you can't talk to a running turn. The alternative is send-and-interrupt:
                              type, hit send, the current turn is interrupted and your message starts the next one immediately.
                              That is what most agent UIs do, and it is what people reach for reflexively.

                              I checked whether upstream already covers this — it does not:

                              // packages/contracts/src/provider.ts:87
                              export const ProviderInterruptTurnInput = Schema.Struct({
                              threadId: ThreadId,
                              turnId: Schema.optional(TurnId),
                              });
                              

                              Interrupt is a pure stop with no message payload, so there is no upstream path that both stops the
                              turn and submits text. The queue is not redundant today.

                              But if we add send-and-interrupt, the queue's genuinely unique value shrinks to:

                              • Deferred send — "finish what you're doing, then do this" (interrupting would discard in-flight work)
                              • Disconnected buffering — composing while the backend is unreachable
                              • Survives reload — localStorage-backed, so a refresh doesn't lose the message

                              Those are real, but they are a much smaller feature than what exists now. Decide first: keep the
                              queue as the primary mid-turn path, demote it to a secondary "send after this turn" affordance behind
                              send-and-interrupt, or remove it. Everything below assumes it survives in some form.


                              1. The queue UI is visually heavy

                              From the current build (screenshot in thread):

                              • The queued-message list renders as a full-width bordered card stacked on a bordered composer,
                                producing a heavy double-border container that dominates the bottom of the thread.
                              • A one-line message gets a card with card-sized vertical padding. With 3-4 queued messages this
                                pushes the composer far up the viewport.
                              • QUEUED MESSAGE · 1 sits as a caption colliding with the container's top border.
                              • Edit (pencil) and delete (trash) icons are pinned to the far right, visually detached from the text
                                they act on, and are always visible rather than on hover/focus.
                              • The footer now carries three competing controls — a progress spinner, a saturated red stop
                                button, and a blue "Queue" button. The red is the highest-contrast element in the composer despite
                                being the secondary action.
                              • Nothing states the contract: that these send automatically when the turn finishes.
                              • No reordering. removeThreadOutboxMessage exists but there is no move-up/move-down or drag.

                              Suggested direction: collapse each queued item to a single dense row (one line, truncated, hover-
                              revealed actions), drop the nested border so the queue reads as part of the composer chrome rather
                              than a second panel, and add a short "Sends when this turn finishes" hint.

                              2. Images can't be queued

                              Reproducer: attach an image while a turn is running, add text, press Queue. The text queues, the image
                              is dropped, and you get a warning toast:

                              Images aren't queued — Queued messages are text-only; attached images were not added to the
                              queued message.

                              Image-only (no text) is refused outright with a similar toast.

                              The current behaviour is deliberate and correctly implemented — the code comments in
                              ChatView.tsx explain the reasoning, and the toast fires only after a successful enqueue so a failed
                              enqueue never misreports. The limitation is the storage layer:

                              // apps/web/src/outbox/threadOutboxStorage.ts
                              // localStorage-backed persistence for the thread outbox ...
                              

                              localStorage is string-only with a ~5MB quota, so blob-backed image previews cannot survive a
                              reload. Queuing images needs the outbox moved to IndexedDB (stores Blobs natively, far larger
                              quota), with the queued item holding a blob reference and the drain rehydrating it at send time.
                              Needs a migration path for messages already in localStorage, and a quota/eviction story.

                              Note this also affects the mobile outbox, which mirrors the same design.

                              3. Send-and-interrupt

                              Add a way to stop the running turn and immediately send the composed message. Requires a contract
                              change — either a new RPC or an optional message payload on the interrupt input, since the current
                              one carries none. Should be explicit rather than the default Enter behaviour, so nobody discards a
                              long-running turn by muscle memory.


                              Suggested order

                              1. Settle §0 — it determines whether §1 is polish or removal.
                              2. §3 send-and-interrupt (contract change, highest user value).
                              3. §1 UI polish, scoped by the §0 outcome.
                              4. §2 images — largest change (storage migration), lowest urgency given the toast is honest today.

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                No labels
                                No labels

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions